feat: workspace CLI commands — list, clean, dispatch stub

workspace/list shows all workspaces with status/agent/repo.
workspace/clean removes completed/failed/blocked/merged workspaces.
workspace/dispatch stub for future CLI-driven dispatch.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-22 14:30:12 +00:00
parent 7f84ac8348
commit f416948660
2 changed files with 165 additions and 1 deletions

View file

@ -124,8 +124,9 @@ func main() {
},
})
// --- Forge CLI commands ---
// --- Forge + Workspace CLI commands ---
registerForgeCommands(c)
registerWorkspaceCommands(c)
// --- CLI commands for feature testing ---

163
cmd/core-agent/workspace.go Normal file
View file

@ -0,0 +1,163 @@
// SPDX-License-Identifier: EUPL-1.2
package main
import (
"os"
"dappco.re/go/core"
"dappco.re/go/agent/pkg/agentic"
)
func registerWorkspaceCommands(c *core.Core) {
// workspace/list — show all workspaces with status
c.Command("workspace/list", core.Command{
Description: "List all agent workspaces with status",
Action: func(opts core.Options) core.Result {
wsRoot := agentic.WorkspaceRoot()
fsys := c.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)
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 {
// Quick parse for status field
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())
count++
}
}
if count == 0 {
core.Print(nil, " no workspaces")
}
return core.Result{OK: true}
},
})
// workspace/clean — remove stale workspaces
c.Command("workspace/clean", core.Command{
Description: "Remove completed/failed/blocked workspaces",
Action: func(opts core.Options) core.Result {
wsRoot := agentic.WorkspaceRoot()
fsys := c.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)
var toRemove []string
for _, e := range entries {
if !e.IsDir() {
continue
}
statusFile := core.JoinPath(wsRoot, e.Name(), "status.json")
sr := fsys.Read(statusFile)
if !sr.OK {
continue
}
status := extractField(sr.Value.(string), "status")
switch filter {
case "all":
if status == "completed" || status == "failed" || status == "blocked" || status == "merged" || status == "ready-for-review" {
toRemove = append(toRemove, e.Name())
}
case "completed":
if status == "completed" || status == "merged" || status == "ready-for-review" {
toRemove = append(toRemove, e.Name())
}
case "failed":
if status == "failed" {
toRemove = append(toRemove, e.Name())
}
case "blocked":
if status == "blocked" {
toRemove = append(toRemove, e.Name())
}
}
}
if len(toRemove) == 0 {
core.Print(nil, "nothing to clean")
return core.Result{OK: true}
}
for _, name := range toRemove {
path := core.JoinPath(wsRoot, name)
fsys.DeleteAll(path)
core.Print(nil, " removed %s", name)
}
core.Print(nil, "\n %d workspaces removed", len(toRemove))
return core.Result{OK: true}
},
})
// workspace/dispatch — dispatch an agent (CLI wrapper for MCP tool)
c.Command("workspace/dispatch", core.Command{
Description: "Dispatch an agent to work on a repo task",
Action: func(opts core.Options) core.Result {
repo := opts.String("_arg")
if repo == "" {
core.Print(nil, "usage: core-agent workspace/dispatch <repo> --task=\"...\" --issue=N|--pr=N|--branch=X [--agent=codex]")
return core.Result{OK: false}
}
core.Print(nil, "dispatch via CLI not yet wired — use MCP agentic_dispatch tool")
core.Print(nil, "repo: %s, task: %s", repo, opts.String("task"))
return core.Result{OK: true}
},
})
}
// extractField does a quick JSON field extraction without full unmarshal.
// Looks for "field":"value" pattern. Good enough for status.json.
func extractField(jsonStr, field string) string {
// Match both "field":"value" and "field": "value"
needle := core.Concat("\"", field, "\"")
idx := -1
for i := 0; i <= len(jsonStr)-len(needle); i++ {
if jsonStr[i:i+len(needle)] == needle {
idx = i + len(needle)
break
}
}
if idx < 0 {
return ""
}
// Skip : and whitespace to find opening quote
for idx < len(jsonStr) && (jsonStr[idx] == ':' || jsonStr[idx] == ' ' || jsonStr[idx] == '\t') {
idx++
}
if idx >= len(jsonStr) || jsonStr[idx] != '"' {
return ""
}
idx++ // skip opening quote
end := idx
for end < len(jsonStr) && jsonStr[end] != '"' {
end++
}
return jsonStr[idx:end]
}