agent/pkg/agentic/commands_workspace.go
Snider 03e5934607 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>
2026-04-14 13:41:07 +01:00

215 lines
7.8 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
core "dappco.re/go/core"
)
func (s *PrepSubsystem) registerWorkspaceCommands() {
c := s.Core()
c.Command("workspace/list", core.Command{Description: "List all agent workspaces with status", Action: s.cmdWorkspaceList})
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})
c.Command("agentic:workspace/watch", core.Command{Description: "Watch workspaces until they complete", Action: s.cmdWorkspaceWatch})
c.Command("watch", core.Command{Description: "Watch workspaces until they complete", Action: s.cmdWorkspaceWatch})
c.Command("agentic:watch", core.Command{Description: "Watch workspaces until they complete", Action: s.cmdWorkspaceWatch})
}
func (s *PrepSubsystem) cmdWorkspaceList(_ core.Options) core.Result {
statusFiles := WorkspaceStatusPaths()
count := 0
for _, sf := range statusFiles {
workspaceDir := core.PathDir(sf)
workspaceName := WorkspaceName(workspaceDir)
result := ReadStatusResult(workspaceDir)
workspaceStatus, ok := workspaceStatusValue(result)
if !ok {
continue
}
core.Print(nil, " %-8s %-8s %-10s %s", workspaceStatus.Status, workspaceStatus.Agent, workspaceStatus.Repo, workspaceName)
count++
}
if count == 0 {
core.Print(nil, " no workspaces")
}
return core.Result{OK: true}
}
func (s *PrepSubsystem) cmdWorkspaceClean(options core.Options) core.Result {
workspaceRoot := WorkspaceRoot()
filesystem := s.Core().Fs()
filter := options.String("_arg")
if filter == "" {
filter = "all"
}
if !workspaceCleanFilterValid(filter) {
core.Print(nil, "usage: core-agent workspace clean [all|completed|failed|blocked]")
return core.Result{Value: core.E("agentic.cmdWorkspaceClean", core.Concat("unknown filter: ", filter), nil), OK: false}
}
statusFiles := WorkspaceStatusPaths()
var toRemove []string
for _, sf := range statusFiles {
workspaceDir := core.PathDir(sf)
workspaceName := WorkspaceName(workspaceDir)
result := ReadStatusResult(workspaceDir)
workspaceStatus, ok := workspaceStatusValue(result)
if !ok {
continue
}
status := workspaceStatus.Status
switch filter {
case "all":
if status == "completed" || status == "failed" || status == "blocked" || status == "merged" || status == "ready-for-review" {
toRemove = append(toRemove, workspaceName)
}
case "completed":
if status == "completed" || status == "merged" || status == "ready-for-review" {
toRemove = append(toRemove, workspaceName)
}
case "failed":
if status == "failed" {
toRemove = append(toRemove, workspaceName)
}
case "blocked":
if status == "blocked" {
toRemove = append(toRemove, workspaceName)
}
}
}
if len(toRemove) == 0 {
core.Print(nil, "nothing to clean")
return core.Result{OK: true}
}
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)
}
core.Print(nil, "\n %d workspaces removed", len(toRemove))
return core.Result{OK: true}
}
func workspaceCleanFilterValid(filter string) bool {
switch filter {
case "all", "completed", "failed", "blocked":
return true
default:
return false
}
}
// 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)
if input.Repo == "" {
core.Print(nil, "usage: core-agent workspace dispatch <repo> --task=\"...\" --issue=N|--pr=N|--branch=X [--agent=codex] [--template=coding] [--plan-template=bug-fix] [--persona=code/reviewer] [--tag=v0.8.0] [--dry-run]")
return core.Result{Value: core.E("agentic.cmdWorkspaceDispatch", "repo is required", nil), OK: false}
}
_, out, err := s.dispatch(context.Background(), nil, input)
if err != nil {
core.Print(nil, "dispatch failed: %s", err.Error())
return core.Result{Value: err, OK: false}
}
agent := out.Agent
if agent == "" {
agent = "codex"
}
core.Print(nil, "dispatched %s to %s", agent, input.Repo)
if out.WorkspaceDir != "" {
core.Print(nil, " workspace: %s", out.WorkspaceDir)
}
if out.PID > 0 {
core.Print(nil, " pid: %d", out.PID)
}
return core.Result{OK: true}
}
func (s *PrepSubsystem) cmdWorkspaceWatch(options core.Options) core.Result {
watchOptions := core.NewOptions(options.Items()...)
if watchOptions.String("workspace") == "" && len(optionStringSliceValue(watchOptions, "workspaces")) == 0 {
if workspace := optionStringValue(options, "_arg"); workspace != "" {
watchOptions.Set("workspace", workspace)
}
}
input := watchInputFromOptions(watchOptions)
_, output, err := s.watch(s.commandContext(), nil, input)
if err != nil {
core.Print(nil, "error: %v", err)
return core.Result{Value: err, OK: false}
}
core.Print(nil, "completed: %d", len(output.Completed))
core.Print(nil, "failed: %d", len(output.Failed))
core.Print(nil, "duration: %s", output.Duration)
return core.Result{Value: output, OK: output.Success}
}
func workspaceDispatchInputFromOptions(options core.Options) DispatchInput {
dispatchOptions := core.NewOptions(options.Items()...)
if dispatchOptions.String("repo") == "" {
if repo := optionStringValue(options, "_arg", "repo"); repo != "" {
dispatchOptions.Set("repo", repo)
}
}
return dispatchInputFromOptions(dispatchOptions)
}