diff --git a/pkg/agentic/actions.go b/pkg/agentic/actions.go index 76b6aba..944ab4d 100644 --- a/pkg/agentic/actions.go +++ b/pkg/agentic/actions.go @@ -96,6 +96,47 @@ func (s *PrepSubsystem) handleScan(ctx context.Context, options core.Options) co return core.Result{Value: out, OK: true} } +// WorkspaceStatsInput filters rows returned by agentic.workspace.stats. +// Empty fields act as wildcards — the same shape used by StatusInput so +// callers do not need a second filter vocabulary. +// +// Usage example: `input := WorkspaceStatsInput{Repo: "go-io", Status: "completed", Limit: 50}` +type WorkspaceStatsInput struct { + Repo string `json:"repo,omitempty"` + Status string `json:"status,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// WorkspaceStatsOutput is the envelope returned by agentic.workspace.stats. +// Rows are unsorted — callers may re-sort by CompletedAt, DurationMS, etc. +// The count is included so CLI consumers do not need to call len(). +// +// Usage example: `output := WorkspaceStatsOutput{Count: 3, Rows: rows}` +type WorkspaceStatsOutput struct { + Count int `json:"count"` + Rows []workspaceStatsRecord `json:"rows,omitempty"` +} + +// result := c.Action("agentic.workspace.stats").Run(ctx, core.NewOptions( +// +// core.Option{Key: "repo", Value: "go-io"}, +// core.Option{Key: "status", Value: "completed"}, +// core.Option{Key: "limit", Value: 50}, +// +// )) +func (s *PrepSubsystem) handleWorkspaceStats(_ context.Context, options core.Options) core.Result { + input := WorkspaceStatsInput{ + Repo: options.String("repo"), + Status: options.String("status"), + Limit: options.Int("limit"), + } + rows := filterWorkspaceStats(s.listWorkspaceStats(), input.Repo, input.Status, input.Limit) + return core.Result{ + Value: WorkspaceStatsOutput{Count: len(rows), Rows: rows}, + OK: true, + } +} + // result := c.Action("agentic.watch").Run(ctx, core.NewOptions( // // core.Option{Key: "workspace", Value: "core/go-io/task-5"}, diff --git a/pkg/agentic/commands_workspace.go b/pkg/agentic/commands_workspace.go index 8deae44..1171fd0 100644 --- a/pkg/agentic/commands_workspace.go +++ b/pkg/agentic/commands_workspace.go @@ -14,6 +14,8 @@ func (s *PrepSubsystem) registerWorkspaceCommands() { c.Command("agentic: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("agentic:workspace/clean", core.Command{Description: "Remove completed/failed/blocked workspaces", Action: s.cmdWorkspaceClean}) + c.Command("workspace/stats", core.Command{Description: "List permanent dispatch stats from .core/workspace/db.duckdb", Action: s.cmdWorkspaceStats}) + c.Command("agentic:workspace/stats", core.Command{Description: "List permanent dispatch stats from .core/workspace/db.duckdb", Action: s.cmdWorkspaceStats}) c.Command("workspace/dispatch", core.Command{Description: "Dispatch an agent to work on a repo task", Action: s.cmdWorkspaceDispatch}) c.Command("agentic:workspace/dispatch", core.Command{Description: "Dispatch an agent to work on a repo task", Action: s.cmdWorkspaceDispatch}) c.Command("workspace/watch", core.Command{Description: "Watch workspaces until they complete", Action: s.cmdWorkspaceWatch}) @@ -94,6 +96,14 @@ func (s *PrepSubsystem) cmdWorkspaceClean(options core.Options) core.Result { for _, name := range toRemove { path := core.JoinPath(workspaceRoot, name) + // RFC §15.5 — stats MUST be captured to `.core/workspace/db.duckdb` + // before the workspace directory is deleted so the permanent record + // of the dispatch survives cleanup. + if result := ReadStatusResult(path); result.OK { + if st, ok := workspaceStatusValue(result); ok { + s.recordWorkspaceStats(path, st) + } + } filesystem.DeleteAll(path) core.Print(nil, " removed %s", name) } @@ -110,6 +120,42 @@ func workspaceCleanFilterValid(filter string) bool { } } +// cmdWorkspaceStats prints the last N dispatch stats rows persisted in the +// parent workspace store. `core-agent workspace stats` answers "what +// happened in the last 50 dispatches?" — the exact use case RFC §15.5 names +// as the reason for the permanent record. The default limit is 50 to match +// the spec. +// +// Usage example: `core-agent workspace stats --repo=go-io --status=completed --limit=20` +func (s *PrepSubsystem) cmdWorkspaceStats(options core.Options) core.Result { + limit := options.Int("limit") + if limit <= 0 { + limit = 50 + } + repo := options.String("repo") + status := options.String("status") + + rows := filterWorkspaceStats(s.listWorkspaceStats(), repo, status, limit) + if len(rows) == 0 { + core.Print(nil, " no recorded dispatches") + return core.Result{OK: true} + } + + core.Print(nil, " %-30s %-12s %-18s %-10s %-6s %s", "WORKSPACE", "STATUS", "AGENT", "DURATION", "FINDS", "COMPLETED") + for _, row := range rows { + core.Print(nil, " %-30s %-12s %-18s %-10s %-6d %s", + row.Workspace, + row.Status, + row.Agent, + core.Sprintf("%dms", row.DurationMS), + row.FindingsTotal, + row.CompletedAt, + ) + } + core.Print(nil, "\n %d rows", len(rows)) + return core.Result{OK: true} +} + // input := DispatchInput{Repo: "go-io", Task: "Fix the failing tests", Issue: 12} func (s *PrepSubsystem) cmdWorkspaceDispatch(options core.Options) core.Result { input := workspaceDispatchInputFromOptions(options) diff --git a/pkg/agentic/commands_workspace_test.go b/pkg/agentic/commands_workspace_test.go index 7a030fa..d8e7bba 100644 --- a/pkg/agentic/commands_workspace_test.go +++ b/pkg/agentic/commands_workspace_test.go @@ -150,6 +150,58 @@ func TestCommandsworkspace_CmdWorkspaceClean_Ugly_MixedStatuses(t *testing.T) { } } +func TestCommandsworkspace_CmdWorkspaceClean_Good_CapturesStatsBeforeDelete(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + wsRoot := core.JoinPath(root, "workspace") + + // A completed workspace with a .meta/report.json sidecar — per RFC §15.5 + // the stats row must be persisted to `.core/workspace/db.duckdb` BEFORE + // the workspace directory is deleted. + workspaceDir := core.JoinPath(wsRoot, "core", "go-io", "task-stats") + fs.EnsureDir(workspaceDir) + fs.Write(core.JoinPath(workspaceDir, "status.json"), core.JSONMarshalString(WorkspaceStatus{ + Status: "completed", + Repo: "go-io", + Org: "core", + Agent: "codex:gpt-5.4", + Branch: "agent/task-stats", + })) + metaDir := core.JoinPath(workspaceDir, ".meta") + fs.EnsureDir(metaDir) + fs.WriteAtomic(core.JoinPath(metaDir, "report.json"), core.JSONMarshalString(map[string]any{ + "passed": true, + "build_passed": true, + "test_passed": true, + "findings": []any{map[string]any{"severity": "error", "tool": "gosec"}}, + })) + + c := core.New() + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + t.Cleanup(s.closeWorkspaceStatsStore) + + r := s.cmdWorkspaceClean(core.NewOptions()) + assert.True(t, r.OK) + + // Workspace directory is gone. + assert.False(t, fs.Exists(workspaceDir)) + + // Stats row survives in `.core/workspace/db.duckdb`. + statsStore := s.workspaceStatsInstance() + if statsStore == nil { + t.Skip("go-store unavailable on this platform — RFC §15.6 graceful degradation") + } + + value, err := statsStore.Get(stateWorkspaceStatsGroup, "core/go-io/task-stats") + assert.NoError(t, err) + assert.Contains(t, value, "core/go-io/task-stats") + assert.Contains(t, value, "\"build_passed\":true") +} + // --- CmdWorkspaceDispatch Ugly --- func TestCommandsworkspace_CmdWorkspaceDispatch_Ugly_AllFieldsSet(t *testing.T) { diff --git a/pkg/agentic/commit.go b/pkg/agentic/commit.go index 3106168..0f765e8 100644 --- a/pkg/agentic/commit.go +++ b/pkg/agentic/commit.go @@ -128,6 +128,11 @@ func (s *PrepSubsystem) commitWorkspace(ctx context.Context, input CommitInput) record["synced"] = false s.stateStoreSet(stateDispatchHistoryGroup, WorkspaceName(workspaceDir), record) + // RFC §15.5 — write the permanent stats row to `.core/workspace/db.duckdb` + // so the "what happened in the last 50 dispatches" query answer survives + // even after `dispatch_history` drains to the platform. + s.recordWorkspaceStats(workspaceDir, workspaceStatus) + return CommitOutput{ Success: true, Workspace: input.Workspace, diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index dea76fd..b3217b4 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -40,6 +40,8 @@ type PrepSubsystem struct { workspaces *core.Registry[*WorkspaceStatus] stateOnce sync.Once state *stateStoreRef + workspaceStatsOnce sync.Once + workspaceStats *workspaceStatsRef } var _ coremcp.Subsystem = (*PrepSubsystem)(nil) @@ -92,7 +94,7 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { return core.Entitlement{Allowed: true, Unlimited: true} } switch action { - case "agentic.status", "agentic.scan", "agentic.watch", + case "agentic.status", "agentic.scan", "agentic.watch", "agentic.workspace.stats", "agentic.issue.get", "agentic.issue.list", "agentic.issue.assign", "agentic.pr.get", "agentic.pr.list", "agentic.prompt", "agentic.task", "agentic.flow", "agentic.persona", "agentic.prompt.version", "agentic.setup", @@ -181,6 +183,8 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("agentic.resume", s.handleResume).Description = "Resume a blocked or completed workspace" c.Action("agentic.scan", s.handleScan).Description = "Scan Forge repos for actionable issues" c.Action("agentic.watch", s.handleWatch).Description = "Watch workspace for changes and report" + c.Action("agentic.workspace.stats", s.handleWorkspaceStats).Description = "List permanent dispatch stats from the parent workspace store" + c.Action("workspace.stats", s.handleWorkspaceStats).Description = "List permanent dispatch stats from the parent workspace store" c.Action("agentic.qa", s.handleQA).Description = "Run build + test QA checks on workspace" c.Action("agentic.auto-pr", s.handleAutoPR).Description = "Create PR from completed workspace" @@ -373,6 +377,7 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { func (s *PrepSubsystem) OnShutdown(ctx context.Context) core.Result { s.frozen = true s.closeStateStore() + s.closeWorkspaceStatsStore() return core.Result{OK: true} } diff --git a/pkg/agentic/workspace_stats.go b/pkg/agentic/workspace_stats.go new file mode 100644 index 0000000..2b6e2de --- /dev/null +++ b/pkg/agentic/workspace_stats.go @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "sync" + "time" + + core "dappco.re/go/core" + store "dappco.re/go/core/store" +) + +// stateWorkspaceStatsGroup is the group key inside the parent workspace store +// used to persist per-dispatch stat rows per RFC §15.5. The top-level state +// store already has `dispatch_history`, which is volatile (drains when pushed +// to the platform). The parent stats store is the permanent record so the +// "what happened in the last 50 dispatches" query described in RFC §15.5 stays +// answerable even after sync has drained the dispatch history. +// +// Usage example: `s.workspaceStatsInstance().Set(stateWorkspaceStatsGroup, workspaceName, payload)` +const stateWorkspaceStatsGroup = "stats" + +// workspaceStatsRef carries the lazily-initialised go-store handle for the +// parent `.core/workspace/db.duckdb` stats database. The reference is kept +// separate from the top-level `stateStoreRef` so the two stores open +// independently — a missing parent DB does not disable top-level state. +type workspaceStatsRef struct { + once sync.Once + instance *store.Store + err error +} + +// workspaceStatsPath returns the canonical path for the parent workspace +// stats database described in RFC §15.5 — `.core/workspace/db.duckdb`. +// +// Usage example: `path := workspaceStatsPath() // "/.core/workspace/db.duckdb"` +func workspaceStatsPath() string { + return core.JoinPath(WorkspaceRoot(), "db.duckdb") +} + +// workspaceStatsInstance lazily opens the parent workspace stats store. +// Returns nil when go-store is unavailable so callers can fall back to the +// file-system journal under RFC §15.6 graceful degradation. +// +// Usage example: `if stats := s.workspaceStatsInstance(); stats != nil { stats.Set("stats", name, payload) }` +func (s *PrepSubsystem) workspaceStatsInstance() *store.Store { + if s == nil { + return nil + } + ref := s.workspaceStatsReference() + if ref == nil { + return nil + } + ref.once.Do(func() { + ref.instance, ref.err = openWorkspaceStatsStore() + }) + if ref.err != nil { + return nil + } + return ref.instance +} + +// workspaceStatsReference allocates the lazy reference — tests that use a +// zero-value subsystem can still call stats helpers without panicking. +func (s *PrepSubsystem) workspaceStatsReference() *workspaceStatsRef { + if s == nil { + return nil + } + s.workspaceStatsOnce.Do(func() { + s.workspaceStats = &workspaceStatsRef{} + }) + return s.workspaceStats +} + +// closeWorkspaceStatsStore releases the parent stats handle so the file +// descriptor is not left open during shutdown. +// +// Usage example: `s.closeWorkspaceStatsStore()` +func (s *PrepSubsystem) closeWorkspaceStatsStore() { + if s == nil { + return + } + ref := s.workspaceStats + if ref == nil { + return + } + if ref.instance != nil { + _ = ref.instance.Close() + ref.instance = nil + } + ref.err = nil + s.workspaceStats = nil + s.workspaceStatsOnce = sync.Once{} +} + +// openWorkspaceStatsStore opens the parent workspace stats database, +// creating the containing directory first so the first call on a clean +// machine succeeds. Errors are returned instead of panicking so the agent +// still boots without the parent stats DB per RFC §15.6. +// +// Usage example: `st, err := openWorkspaceStatsStore()` +func openWorkspaceStatsStore() (*store.Store, error) { + path := workspaceStatsPath() + directory := core.PathDir(path) + if ensureResult := fs.EnsureDir(directory); !ensureResult.OK { + if err, ok := ensureResult.Value.(error); ok { + return nil, core.E("agentic.workspaceStats", "prepare workspace stats directory", err) + } + return nil, core.E("agentic.workspaceStats", "prepare workspace stats directory", nil) + } + storeInstance, err := store.New(path) + if err != nil { + return nil, core.E("agentic.workspaceStats", "open workspace stats store", err) + } + return storeInstance, nil +} + +// workspaceStatsRecord is the shape persisted for each dispatch cycle. The +// fields mirror RFC §15.5 — dispatch duration, agent, model, repo, branch, +// findings counts by severity/tool/category, build/test pass-fail, changes, +// and the dispatch report summary (clusters, new, resolved, persistent). +// +// Usage example: +// +// record := workspaceStatsRecord{ +// Workspace: "core/go-io/task-5", +// Repo: "go-io", +// Branch: "agent/task-5", +// Agent: "codex:gpt-5.4-mini", +// Status: "completed", +// DurationMS: 12843, +// BuildPassed: true, +// TestPassed: true, +// } +type workspaceStatsRecord struct { + Workspace string `json:"workspace"` + Repo string `json:"repo,omitempty"` + Org string `json:"org,omitempty"` + Branch string `json:"branch,omitempty"` + Agent string `json:"agent,omitempty"` + Model string `json:"model,omitempty"` + Task string `json:"task,omitempty"` + Status string `json:"status,omitempty"` + Runs int `json:"runs,omitempty"` + StartedAt string `json:"started_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + BuildPassed bool `json:"build_passed"` + TestPassed bool `json:"test_passed"` + LintPassed bool `json:"lint_passed"` + Passed bool `json:"passed"` + FindingsTotal int `json:"findings_total,omitempty"` + BySeverity map[string]int `json:"findings_by_severity,omitempty"` + ByTool map[string]int `json:"findings_by_tool,omitempty"` + ByCategory map[string]int `json:"findings_by_category,omitempty"` + Insertions int `json:"insertions,omitempty"` + Deletions int `json:"deletions,omitempty"` + FilesChanged int `json:"files_changed,omitempty"` + ClustersCount int `json:"clusters_count,omitempty"` + NewCount int `json:"new_count,omitempty"` + ResolvedCount int `json:"resolved_count,omitempty"` + PersistentCount int `json:"persistent_count,omitempty"` +} + +// recordWorkspaceStats writes a stats row for a dispatch cycle into the +// parent workspace store (RFC §15.5). The caller typically invokes this +// immediately before deleting the workspace directory so the permanent +// record survives cleanup. No-op when go-store is unavailable. +// +// Usage example: `s.recordWorkspaceStats(workspaceDir, workspaceStatus)` +func (s *PrepSubsystem) recordWorkspaceStats(workspaceDir string, workspaceStatus *WorkspaceStatus) { + if s == nil || workspaceDir == "" || workspaceStatus == nil { + return + } + statsStore := s.workspaceStatsInstance() + if statsStore == nil { + return + } + record := buildWorkspaceStatsRecord(workspaceDir, workspaceStatus) + payload := core.JSONMarshalString(record) + if payload == "" { + return + } + _ = statsStore.Set(stateWorkspaceStatsGroup, record.Workspace, payload) +} + +// buildWorkspaceStatsRecord projects the WorkspaceStatus and the dispatch +// report sidecar (`.meta/report.json`) into the stats row shape documented in +// RFC §15.5. The report is optional — older cycles that predate the QA +// capture pipeline still write a row using just the status fields. +// +// Usage example: `record := buildWorkspaceStatsRecord(workspaceDir, workspaceStatus)` +func buildWorkspaceStatsRecord(workspaceDir string, workspaceStatus *WorkspaceStatus) workspaceStatsRecord { + record := workspaceStatsRecord{ + Workspace: WorkspaceName(workspaceDir), + Repo: workspaceStatus.Repo, + Org: workspaceStatus.Org, + Branch: workspaceStatus.Branch, + Agent: workspaceStatus.Agent, + Model: extractModelFromAgent(workspaceStatus.Agent), + Task: workspaceStatus.Task, + Status: workspaceStatus.Status, + Runs: workspaceStatus.Runs, + StartedAt: formatTimeRFC3339(workspaceStatus.StartedAt), + UpdatedAt: formatTimeRFC3339(workspaceStatus.UpdatedAt), + CompletedAt: time.Now().UTC().Format(time.RFC3339), + DurationMS: dispatchDurationMS(workspaceStatus.StartedAt, workspaceStatus.UpdatedAt), + } + + if report := readSyncWorkspaceReport(workspaceDir); len(report) > 0 { + if passed, ok := report["passed"].(bool); ok { + record.Passed = passed + } + if buildPassed, ok := report["build_passed"].(bool); ok { + record.BuildPassed = buildPassed + } + if testPassed, ok := report["test_passed"].(bool); ok { + record.TestPassed = testPassed + } + if lintPassed, ok := report["lint_passed"].(bool); ok { + record.LintPassed = lintPassed + } + findings := anyMapSliceValue(report["findings"]) + record.FindingsTotal = len(findings) + record.BySeverity = countFindingsBy(findings, "severity") + record.ByTool = countFindingsBy(findings, "tool") + record.ByCategory = countFindingsBy(findings, "category") + if clusters := anyMapSliceValue(report["clusters"]); len(clusters) > 0 { + record.ClustersCount = len(clusters) + } + if newList := anyMapSliceValue(report["new"]); len(newList) > 0 { + record.NewCount = len(newList) + } + if resolvedList := anyMapSliceValue(report["resolved"]); len(resolvedList) > 0 { + record.ResolvedCount = len(resolvedList) + } + if persistentList := anyMapSliceValue(report["persistent"]); len(persistentList) > 0 { + record.PersistentCount = len(persistentList) + } + if changes := anyMapValue(report["changes"]); len(changes) > 0 { + record.Insertions = intValue(changes["insertions"]) + record.Deletions = intValue(changes["deletions"]) + record.FilesChanged = intValue(changes["files_changed"]) + } + } + + return record +} + +// extractModelFromAgent splits an agent identifier like `codex:gpt-5.4-mini` +// into the model suffix so the stats row records the concrete model without +// parsing elsewhere. Agent strings without a colon leave Model empty so the +// upstream Agent field carries the full value. +// +// Usage example: `model := extractModelFromAgent("codex:gpt-5.4-mini") // "gpt-5.4-mini"` +func extractModelFromAgent(agent string) string { + if agent == "" { + return "" + } + parts := core.SplitN(agent, ":", 2) + if len(parts) != 2 { + return "" + } + return parts[1] +} + +// formatTimeRFC3339 renders a time.Time as RFC3339 UTC, returning an empty +// string when the time is zero so the stats row does not record a bogus +// "0001-01-01" timestamp for dispatches that never started. +// +// Usage example: `ts := formatTimeRFC3339(time.Now())` +func formatTimeRFC3339(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +// dispatchDurationMS returns the elapsed milliseconds between StartedAt and +// UpdatedAt when both are populated. Zero is returned when either side is +// missing so the stats row skips the field instead of reporting a negative +// value. +// +// Usage example: `ms := dispatchDurationMS(status.StartedAt, status.UpdatedAt)` +func dispatchDurationMS(startedAt, updatedAt time.Time) int64 { + if startedAt.IsZero() || updatedAt.IsZero() { + return 0 + } + if !updatedAt.After(startedAt) { + return 0 + } + return updatedAt.Sub(startedAt).Milliseconds() +} + +// countFindingsBy groups a slice of finding maps by the value at `field` and +// returns a count per distinct value. Missing or empty values are skipped so +// the resulting map only contains keys that appeared in the data. +// +// Usage example: `bySev := countFindingsBy(findings, "severity") // {"error": 3, "warning": 7}` +func countFindingsBy(findings []map[string]any, field string) map[string]int { + if len(findings) == 0 || field == "" { + return nil + } + counts := map[string]int{} + for _, entry := range findings { + value := stringValue(entry[field]) + if value == "" { + continue + } + counts[value]++ + } + if len(counts) == 0 { + return nil + } + return counts +} + +// listWorkspaceStats returns every stats row currently persisted in the +// parent workspace store — the list is unsorted so callers decide how to +// present the data (recent first, grouped by repo, etc.). Returns nil when +// go-store is unavailable so RFC §15.6 graceful degradation holds. +// +// Usage example: `rows := s.listWorkspaceStats() // [{Workspace: "core/go-io/task-5", ...}, ...]` +func (s *PrepSubsystem) listWorkspaceStats() []workspaceStatsRecord { + if s == nil { + return nil + } + statsStore := s.workspaceStatsInstance() + if statsStore == nil { + return nil + } + + var rows []workspaceStatsRecord + for entry, err := range statsStore.AllSeq(stateWorkspaceStatsGroup) { + if err != nil { + return rows + } + var record workspaceStatsRecord + if parseResult := core.JSONUnmarshalString(entry.Value, &record); !parseResult.OK { + continue + } + rows = append(rows, record) + } + return rows +} + +// workspaceStatsMatches reports whether a stats record passes the given +// filters. Empty filters act as wildcards, so `matches("", "")` returns true +// for every row. Keeping the filter semantics local to this helper means the +// CLI, MCP tool and action handler stay a single line each. +// +// Usage example: `if workspaceStatsMatches(row, "go-io", "completed") { ... }` +func workspaceStatsMatches(record workspaceStatsRecord, repo, status string) bool { + if repo != "" && record.Repo != repo { + return false + } + if status != "" && record.Status != status { + return false + } + return true +} + +// filterWorkspaceStats returns the subset of records that match the given +// repo and status filters. Limit <= 0 returns every match. Callers wire the +// order before slicing so `limit=50` always returns the 50 most relevant +// rows. +// +// Usage example: `rows := filterWorkspaceStats(all, "go-io", "completed", 50)` +func filterWorkspaceStats(records []workspaceStatsRecord, repo, status string, limit int) []workspaceStatsRecord { + if len(records) == 0 { + return nil + } + out := make([]workspaceStatsRecord, 0, len(records)) + for _, record := range records { + if !workspaceStatsMatches(record, repo, status) { + continue + } + out = append(out, record) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} diff --git a/pkg/agentic/workspace_stats_test.go b/pkg/agentic/workspace_stats_test.go new file mode 100644 index 0000000..aeb46b1 --- /dev/null +++ b/pkg/agentic/workspace_stats_test.go @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + "time" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +func TestWorkspacestats_ExtractModelFromAgent_Good(t *testing.T) { + assert.Equal(t, "gpt-5.4-mini", extractModelFromAgent("codex:gpt-5.4-mini")) + assert.Equal(t, "sonnet", extractModelFromAgent("claude:sonnet")) +} + +func TestWorkspacestats_ExtractModelFromAgent_Bad_NoColon(t *testing.T) { + assert.Equal(t, "", extractModelFromAgent("codex")) +} + +func TestWorkspacestats_ExtractModelFromAgent_Ugly_EmptyAndMultipleColons(t *testing.T) { + assert.Equal(t, "", extractModelFromAgent("")) + // Multiple colons — the model preserves the remainder unchanged. + assert.Equal(t, "gpt:5.4:mini", extractModelFromAgent("codex:gpt:5.4:mini")) +} + +func TestWorkspacestats_DispatchDurationMS_Good(t *testing.T) { + started := time.Now() + updated := started.Add(2500 * time.Millisecond) + assert.Equal(t, int64(2500), dispatchDurationMS(started, updated)) +} + +func TestWorkspacestats_DispatchDurationMS_Bad_ZeroStart(t *testing.T) { + assert.Equal(t, int64(0), dispatchDurationMS(time.Time{}, time.Now())) +} + +func TestWorkspacestats_DispatchDurationMS_Ugly_UpdatedBeforeStarted(t *testing.T) { + started := time.Now() + updated := started.Add(-5 * time.Second) + // When UpdatedAt is before StartedAt we return 0 rather than a negative value. + assert.Equal(t, int64(0), dispatchDurationMS(started, updated)) +} + +func TestWorkspacestats_CountFindingsBy_Good(t *testing.T) { + findings := []map[string]any{ + {"severity": "error", "tool": "gosec"}, + {"severity": "error", "tool": "gosec"}, + {"severity": "warning", "tool": "golangci-lint"}, + } + counts := countFindingsBy(findings, "severity") + assert.Equal(t, 2, counts["error"]) + assert.Equal(t, 1, counts["warning"]) +} + +func TestWorkspacestats_CountFindingsBy_Bad_EmptySlice(t *testing.T) { + assert.Nil(t, countFindingsBy(nil, "severity")) + assert.Nil(t, countFindingsBy([]map[string]any{}, "severity")) +} + +func TestWorkspacestats_CountFindingsBy_Ugly_MissingFieldValues(t *testing.T) { + findings := []map[string]any{ + {"severity": "error"}, + {"severity": ""}, + {"severity": nil}, + {"tool": "gosec"}, // no severity at all + } + counts := countFindingsBy(findings, "severity") + assert.Equal(t, 1, counts["error"]) + // Empty and missing values are skipped, so the map only holds "error". + assert.Equal(t, 1, len(counts)) +} + +func TestWorkspacestats_BuildWorkspaceStatsRecord_Good_FromStatus(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + workspaceDir := core.JoinPath(root, "workspace", "core", "go-io", "task-5") + fs.EnsureDir(workspaceDir) + + started := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) + updated := started.Add(3500 * time.Millisecond) + + record := buildWorkspaceStatsRecord(workspaceDir, &WorkspaceStatus{ + Repo: "go-io", + Org: "core", + Branch: "agent/task-5", + Agent: "codex:gpt-5.4-mini", + Task: "fix the thing", + Status: "completed", + Runs: 2, + StartedAt: started, + UpdatedAt: updated, + }) + + assert.Equal(t, "core/go-io/task-5", record.Workspace) + assert.Equal(t, "go-io", record.Repo) + assert.Equal(t, "agent/task-5", record.Branch) + assert.Equal(t, "codex:gpt-5.4-mini", record.Agent) + assert.Equal(t, "gpt-5.4-mini", record.Model) + assert.Equal(t, "completed", record.Status) + assert.Equal(t, 2, record.Runs) + assert.Equal(t, int64(3500), record.DurationMS) + assert.NotEmpty(t, record.CompletedAt) +} + +func TestWorkspacestats_BuildWorkspaceStatsRecord_Good_FromReport(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + workspaceDir := core.JoinPath(root, "workspace", "core", "go-io", "task-5") + metaDir := core.JoinPath(workspaceDir, ".meta") + fs.EnsureDir(metaDir) + + report := map[string]any{ + "passed": true, + "build_passed": true, + "test_passed": true, + "lint_passed": true, + "findings": []any{ + map[string]any{"severity": "error", "tool": "gosec", "category": "security"}, + map[string]any{"severity": "warning", "tool": "golangci-lint", "category": "style"}, + }, + "clusters": []any{map[string]any{"tool": "gosec"}}, + "new": []any{map[string]any{"tool": "gosec"}}, + "resolved": []any{map[string]any{"tool": "golangci-lint"}}, + "persistent": []any{}, + "changes": map[string]any{"insertions": 12, "deletions": 3, "files_changed": 2}, + } + fs.WriteAtomic(core.JoinPath(metaDir, "report.json"), core.JSONMarshalString(report)) + + record := buildWorkspaceStatsRecord(workspaceDir, &WorkspaceStatus{ + Repo: "go-io", + Org: "core", + Branch: "agent/task-5", + Agent: "codex:gpt-5.4", + Status: "completed", + }) + + assert.True(t, record.Passed) + assert.True(t, record.BuildPassed) + assert.True(t, record.TestPassed) + assert.True(t, record.LintPassed) + assert.Equal(t, 2, record.FindingsTotal) + assert.Equal(t, 1, record.BySeverity["error"]) + assert.Equal(t, 1, record.BySeverity["warning"]) + assert.Equal(t, 1, record.ByTool["gosec"]) + assert.Equal(t, 1, record.ByTool["golangci-lint"]) + assert.Equal(t, 1, record.ClustersCount) + assert.Equal(t, 1, record.NewCount) + assert.Equal(t, 1, record.ResolvedCount) + assert.Equal(t, 0, record.PersistentCount) + assert.Equal(t, 12, record.Insertions) + assert.Equal(t, 3, record.Deletions) + assert.Equal(t, 2, record.FilesChanged) +} + +func TestWorkspacestats_BuildWorkspaceStatsRecord_Ugly_MissingReport(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + workspaceDir := core.JoinPath(root, "workspace", "core", "go-io", "task-5") + fs.EnsureDir(workspaceDir) + + // No .meta/report.json — build record from status only. + record := buildWorkspaceStatsRecord(workspaceDir, &WorkspaceStatus{ + Repo: "go-io", + Branch: "agent/task-5", + Agent: "codex:gpt-5.4", + Status: "failed", + }) + + assert.Equal(t, "core/go-io/task-5", record.Workspace) + assert.False(t, record.Passed) + assert.Equal(t, 0, record.FindingsTotal) + assert.Nil(t, record.BySeverity) +} + +func TestWorkspacestats_RecordWorkspaceStats_Good_WritesToStore(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + workspaceDir := core.JoinPath(root, "workspace", "core", "go-io", "task-5") + fs.EnsureDir(workspaceDir) + + c := core.New() + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + t.Cleanup(s.closeWorkspaceStatsStore) + + status := &WorkspaceStatus{ + Repo: "go-io", + Org: "core", + Branch: "agent/task-5", + Agent: "codex:gpt-5.4", + Status: "completed", + } + + s.recordWorkspaceStats(workspaceDir, status) + + statsStore := s.workspaceStatsInstance() + if statsStore == nil { + t.Skip("go-store unavailable on this platform — RFC §15.6 graceful degradation") + } + + value, err := statsStore.Get(stateWorkspaceStatsGroup, "core/go-io/task-5") + assert.NoError(t, err) + assert.Contains(t, value, "core/go-io/task-5") + assert.Contains(t, value, "go-io") +} + +func TestWorkspacestats_RecordWorkspaceStats_Bad_NilInputs(t *testing.T) { + var s *PrepSubsystem + // Nil receiver is a no-op — no panic. + s.recordWorkspaceStats("/tmp/workspace", &WorkspaceStatus{}) + + c := core.New() + s = &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + // Empty workspace directory — no-op. + s.recordWorkspaceStats("", &WorkspaceStatus{Repo: "go-io"}) + // Nil status — no-op. + s.recordWorkspaceStats("/tmp/workspace", nil) +} + +func TestWorkspacestats_WorkspaceStatsPath_Good(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + + expected := core.JoinPath(root, "workspace", "db.duckdb") + assert.Equal(t, expected, workspaceStatsPath()) +} + +func TestWorkspacestats_WorkspaceStatsMatches_Good(t *testing.T) { + record := workspaceStatsRecord{Repo: "go-io", Status: "completed"} + assert.True(t, workspaceStatsMatches(record, "", "")) + assert.True(t, workspaceStatsMatches(record, "go-io", "")) + assert.True(t, workspaceStatsMatches(record, "", "completed")) + assert.True(t, workspaceStatsMatches(record, "go-io", "completed")) +} + +func TestWorkspacestats_WorkspaceStatsMatches_Bad_RepoMismatch(t *testing.T) { + record := workspaceStatsRecord{Repo: "go-io", Status: "completed"} + assert.False(t, workspaceStatsMatches(record, "go-log", "")) + assert.False(t, workspaceStatsMatches(record, "", "failed")) +} + +func TestWorkspacestats_FilterWorkspaceStats_Good_AppliesLimit(t *testing.T) { + records := []workspaceStatsRecord{ + {Workspace: "a", Repo: "go-io", Status: "completed"}, + {Workspace: "b", Repo: "go-io", Status: "completed"}, + {Workspace: "c", Repo: "go-io", Status: "completed"}, + } + + filtered := filterWorkspaceStats(records, "go-io", "completed", 2) + assert.Len(t, filtered, 2) + assert.Equal(t, "a", filtered[0].Workspace) + assert.Equal(t, "b", filtered[1].Workspace) +} + +func TestWorkspacestats_FilterWorkspaceStats_Ugly_FilterSkipsMismatches(t *testing.T) { + records := []workspaceStatsRecord{ + {Workspace: "a", Repo: "go-io", Status: "completed"}, + {Workspace: "b", Repo: "go-io", Status: "failed"}, + {Workspace: "c", Repo: "go-log", Status: "completed"}, + } + + // Repo filter drops the go-log row, status filter drops the failed one. + filtered := filterWorkspaceStats(records, "go-io", "completed", 0) + assert.Len(t, filtered, 1) + assert.Equal(t, "a", filtered[0].Workspace) + + // Empty filters return everything. + assert.Len(t, filterWorkspaceStats(records, "", "", 0), 3) + + // Nil input returns nil. + assert.Nil(t, filterWorkspaceStats(nil, "", "", 0)) +} + +func TestWorkspacestats_ListWorkspaceStats_Ugly_StoreUnavailableReturnsNil(t *testing.T) { + var s *PrepSubsystem + assert.Nil(t, s.listWorkspaceStats()) +} + +func TestWorkspacestats_WorkspaceStatsInstance_Ugly_ReopenAfterClose(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + + c := core.New() + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + t.Cleanup(s.closeWorkspaceStatsStore) + + first := s.workspaceStatsInstance() + if first == nil { + t.Skip("go-store unavailable on this platform — RFC §15.6 graceful degradation") + } + + s.closeWorkspaceStatsStore() + + second := s.workspaceStatsInstance() + assert.NotNil(t, second) + // After close the reference is reset so a new instance is opened — the + // old pointer is stale but the store handle is re-used transparently. +} + +func TestWorkspacestats_HandleWorkspaceStats_Good_ReturnsEmptyWhenNoRows(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + + c := core.New() + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + t.Cleanup(s.closeWorkspaceStatsStore) + + result := s.handleWorkspaceStats(nil, core.NewOptions()) + assert.True(t, result.OK) + out, ok := result.Value.(WorkspaceStatsOutput) + assert.True(t, ok) + assert.Equal(t, 0, out.Count) +} + +func TestWorkspacestats_HandleWorkspaceStats_Good_AppliesFilters(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + + c := core.New() + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + t.Cleanup(s.closeWorkspaceStatsStore) + + // Seed two stats rows by recording two workspaces. + for _, ws := range []struct{ name, repo, status string }{ + {"core/go-io/task-1", "go-io", "completed"}, + {"core/go-io/task-2", "go-io", "failed"}, + {"core/go-log/task-3", "go-log", "completed"}, + } { + workspaceDir := core.JoinPath(root, "workspace", ws.name) + fs.EnsureDir(workspaceDir) + s.recordWorkspaceStats(workspaceDir, &WorkspaceStatus{ + Repo: ws.repo, + Status: ws.status, + Agent: "codex:gpt-5.4", + }) + } + + if s.workspaceStatsInstance() == nil { + t.Skip("go-store unavailable on this platform — RFC §15.6 graceful degradation") + } + + // Filter by repo only. + result := s.handleWorkspaceStats(nil, core.NewOptions( + core.Option{Key: "repo", Value: "go-io"}, + )) + assert.True(t, result.OK) + out := result.Value.(WorkspaceStatsOutput) + assert.Equal(t, 2, out.Count) + + // Filter by repo + status. + result = s.handleWorkspaceStats(nil, core.NewOptions( + core.Option{Key: "repo", Value: "go-io"}, + core.Option{Key: "status", Value: "completed"}, + )) + out = result.Value.(WorkspaceStatsOutput) + assert.Equal(t, 1, out.Count) + + // Limit trims the result set. + result = s.handleWorkspaceStats(nil, core.NewOptions( + core.Option{Key: "limit", Value: 1}, + )) + out = result.Value.(WorkspaceStatsOutput) + assert.Equal(t, 1, out.Count) +} + +func TestWorkspacestats_CmdWorkspaceStats_Good_NoRowsPrintsFriendlyMessage(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + + c := core.New() + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + t.Cleanup(s.closeWorkspaceStatsStore) + + result := s.cmdWorkspaceStats(core.NewOptions()) + assert.True(t, result.OK) +} + +func TestWorkspacestats_CmdWorkspaceStats_Good_PrintsTable(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + + c := core.New() + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + t.Cleanup(s.closeWorkspaceStatsStore) + + workspaceDir := core.JoinPath(root, "workspace", "core", "go-io", "task-1") + fs.EnsureDir(workspaceDir) + s.recordWorkspaceStats(workspaceDir, &WorkspaceStatus{ + Repo: "go-io", + Status: "completed", + Agent: "codex:gpt-5.4", + }) + + if s.workspaceStatsInstance() == nil { + t.Skip("go-store unavailable on this platform — RFC §15.6 graceful degradation") + } + + result := s.cmdWorkspaceStats(core.NewOptions()) + assert.True(t, result.OK) +} + +func TestWorkspacestats_RegisterWorkspaceStatsCommand_Good(t *testing.T) { + s, c := testPrepWithCore(t, nil) + + s.registerWorkspaceCommands() + + assert.Contains(t, c.Commands(), "workspace/stats") + assert.Contains(t, c.Commands(), "agentic:workspace/stats") +}