feat(agent): RFC §15.5 parent workspace stats store
Adds `.core/workspace/db.duckdb` — the permanent record of dispatch cycles described in RFC §15.5. Stats rows persist BEFORE workspace directories are deleted so "what happened in the last 50 dispatches" queries survive cleanup and sync drain. - `workspace_stats.go` — lazy go-store handle for the parent stats DB, build/record/filter/list helpers, report payload projection - `commit.go` — writes a stats row as part of the completion pipeline so every committed dispatch carries forward into the permanent record - `commands_workspace.go` — `workspace/clean` captures stats before deleting, new `workspace/stats` command + `agentic.workspace.stats` action answer the spec's "query on the parent" use case Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
364655662a
commit
03e5934607
7 changed files with 972 additions and 1 deletions
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
||||
|
|
|
|||
385
pkg/agentic/workspace_stats.go
Normal file
385
pkg/agentic/workspace_stats.go
Normal file
|
|
@ -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
|
||||
}
|
||||
437
pkg/agentic/workspace_stats_test.go
Normal file
437
pkg/agentic/workspace_stats_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue