fix(ax): make status projections explicit
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
d005f881b7
commit
be3e68ec0f
9 changed files with 225 additions and 28 deletions
|
|
@ -423,6 +423,7 @@ Every exported function MUST have a usage-example comment:
|
|||
|
||||
## Changelog
|
||||
|
||||
- 2026-03-30: runner workspace status projections now use explicit typed copies, and `ReadStatusResult` gained direct AX-7 coverage in both runner and agentic packages.
|
||||
- 2026-03-30: transport helpers preserve request and read causes, brain direct API calls surface upstream bodies, and review queue retry parsing no longer uses `MustCompile`.
|
||||
- 2026-03-30: direct Core process calls replaced the `proc.go` wrapper layer; PID helpers now live in `pid.go` and the workspace template documents `c.Process()` directly.
|
||||
- 2026-03-30: main now logs startup failures with structured context, and the workspace contract reference restored usage-example comments for the Action lifecycle messages.
|
||||
|
|
|
|||
|
|
@ -117,17 +117,17 @@ func ReadStatusResult(wsDir string) core.Result {
|
|||
if !r.OK {
|
||||
err, _ := r.Value.(error)
|
||||
if err == nil {
|
||||
return core.Result{Value: core.E("ReadStatus", "status not found", nil), OK: false}
|
||||
return core.Result{Value: core.E("ReadStatusResult", "status not found", nil), OK: false}
|
||||
}
|
||||
return core.Result{Value: core.E("ReadStatus", core.Concat("status not found for ", wsDir), err), OK: false}
|
||||
return core.Result{Value: core.E("ReadStatusResult", core.Concat("status not found for ", wsDir), err), OK: false}
|
||||
}
|
||||
var s WorkspaceStatus
|
||||
if ur := core.JSONUnmarshalString(r.Value.(string), &s); !ur.OK {
|
||||
err, _ := ur.Value.(error)
|
||||
if err == nil {
|
||||
return core.Result{Value: core.E("ReadStatus", "invalid status json", nil), OK: false}
|
||||
return core.Result{Value: core.E("ReadStatusResult", "invalid status json", nil), OK: false}
|
||||
}
|
||||
return core.Result{Value: core.E("ReadStatus", "invalid status json", err), OK: false}
|
||||
return core.Result{Value: core.E("ReadStatusResult", "invalid status json", err), OK: false}
|
||||
}
|
||||
return core.Result{Value: &s, OK: true}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,3 +43,24 @@ func ExampleReadStatus() {
|
|||
// completed
|
||||
// claude
|
||||
}
|
||||
|
||||
func ExampleReadStatusResult() {
|
||||
fsys := (&core.Fs{}).NewUnrestricted()
|
||||
dir := fsys.TempDir("agentic-status-result")
|
||||
defer fsys.DeleteAll(dir)
|
||||
|
||||
status := &WorkspaceStatus{
|
||||
Status: "completed",
|
||||
Agent: "codex",
|
||||
Repo: "go-io",
|
||||
}
|
||||
core.Println(fs.Write(core.JoinPath(dir, "status.json"), core.JSONMarshalString(status)).OK)
|
||||
|
||||
result := ReadStatusResult(dir)
|
||||
core.Println(result.OK)
|
||||
core.Println(result.Value.(*WorkspaceStatus).Repo)
|
||||
// Output:
|
||||
// true
|
||||
// true
|
||||
// go-io
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,56 @@ func TestStatus_ReadStatus_Good(t *testing.T) {
|
|||
assert.Equal(t, "https://forge.lthn.ai/core/go-log/pulls/5", read.PRURL)
|
||||
}
|
||||
|
||||
func TestStatus_ReadStatusResult_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
status := &WorkspaceStatus{
|
||||
Status: "completed",
|
||||
Agent: "codex",
|
||||
Repo: "go-log",
|
||||
Task: "add logging",
|
||||
Branch: "agent/add-logging",
|
||||
StartedAt: time.Now().Truncate(time.Second),
|
||||
UpdatedAt: time.Now().Truncate(time.Second),
|
||||
Runs: 2,
|
||||
PRURL: "https://forge.lthn.ai/core/go-log/pulls/5",
|
||||
}
|
||||
|
||||
require.True(t, fs.Write(core.JoinPath(dir, "status.json"), core.JSONMarshalString(status)).OK)
|
||||
|
||||
result := ReadStatusResult(dir)
|
||||
require.True(t, result.OK)
|
||||
|
||||
read, ok := result.Value.(*WorkspaceStatus)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "completed", read.Status)
|
||||
assert.Equal(t, "codex", read.Agent)
|
||||
assert.Equal(t, "go-log", read.Repo)
|
||||
assert.Equal(t, "add logging", read.Task)
|
||||
assert.Equal(t, "agent/add-logging", read.Branch)
|
||||
assert.Equal(t, 2, read.Runs)
|
||||
assert.Equal(t, "https://forge.lthn.ai/core/go-log/pulls/5", read.PRURL)
|
||||
}
|
||||
|
||||
func TestStatus_ReadStatusResult_Bad_NoFile(t *testing.T) {
|
||||
result := ReadStatusResult(t.TempDir())
|
||||
assert.False(t, result.OK)
|
||||
err, ok := result.Value.(error)
|
||||
require.True(t, ok)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStatus_ReadStatusResult_Ugly_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.True(t, fs.Write(core.JoinPath(dir, "status.json"), "{not-json").OK)
|
||||
|
||||
result := ReadStatusResult(dir)
|
||||
assert.False(t, result.OK)
|
||||
err, ok := result.Value.(error)
|
||||
require.True(t, ok)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStatus_ReadStatus_Bad_NoFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := ReadStatus(dir)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,44 @@ import (
|
|||
// fs reuses the shared unrestricted filesystem used by agentic.
|
||||
var fs = agentic.LocalFs()
|
||||
|
||||
func runnerWorkspaceStatusFromAgentic(status *agentic.WorkspaceStatus) *WorkspaceStatus {
|
||||
if status == nil {
|
||||
return nil
|
||||
}
|
||||
return &WorkspaceStatus{
|
||||
Status: status.Status,
|
||||
Agent: status.Agent,
|
||||
Repo: status.Repo,
|
||||
Org: status.Org,
|
||||
Task: status.Task,
|
||||
Branch: status.Branch,
|
||||
PID: status.PID,
|
||||
Question: status.Question,
|
||||
PRURL: status.PRURL,
|
||||
StartedAt: status.StartedAt,
|
||||
Runs: status.Runs,
|
||||
}
|
||||
}
|
||||
|
||||
func agenticWorkspaceStatusFromRunner(status *WorkspaceStatus) *agentic.WorkspaceStatus {
|
||||
if status == nil {
|
||||
return nil
|
||||
}
|
||||
return &agentic.WorkspaceStatus{
|
||||
Status: status.Status,
|
||||
Agent: status.Agent,
|
||||
Repo: status.Repo,
|
||||
Org: status.Org,
|
||||
Task: status.Task,
|
||||
Branch: status.Branch,
|
||||
PID: status.PID,
|
||||
Question: status.Question,
|
||||
PRURL: status.PRURL,
|
||||
StartedAt: status.StartedAt,
|
||||
Runs: status.Runs,
|
||||
}
|
||||
}
|
||||
|
||||
// WorkspaceRoot returns the root directory for agent workspaces.
|
||||
//
|
||||
// root := runner.WorkspaceRoot() // ~/Code/.core/workspace
|
||||
|
|
@ -52,19 +90,14 @@ func ReadStatus(wsDir string) (*WorkspaceStatus, error) {
|
|||
func ReadStatusResult(wsDir string) core.Result {
|
||||
status, err := agentic.ReadStatus(wsDir)
|
||||
if err != nil {
|
||||
return core.Result{Value: core.E("runner.ReadStatus", "failed to read status", err), OK: false}
|
||||
return core.Result{Value: core.E("runner.ReadStatusResult", "failed to read status", err), OK: false}
|
||||
}
|
||||
|
||||
json := core.JSONMarshalString(status)
|
||||
var st WorkspaceStatus
|
||||
if result := core.JSONUnmarshalString(json, &st); !result.OK {
|
||||
parseErr, _ := result.Value.(error)
|
||||
if parseErr == nil {
|
||||
parseErr = core.E("runner.ReadStatus", "failed to parse status", nil)
|
||||
}
|
||||
return core.Result{Value: parseErr, OK: false}
|
||||
st := runnerWorkspaceStatusFromAgentic(status)
|
||||
if st == nil {
|
||||
return core.Result{Value: core.E("runner.ReadStatusResult", "invalid status payload", nil), OK: false}
|
||||
}
|
||||
return core.Result{Value: &st, OK: true}
|
||||
return core.Result{Value: st, OK: true}
|
||||
}
|
||||
|
||||
// WriteStatus writes `status.json` for one workspace directory.
|
||||
|
|
@ -75,11 +108,10 @@ func WriteStatus(wsDir string, st *WorkspaceStatus) {
|
|||
return
|
||||
}
|
||||
|
||||
json := core.JSONMarshalString(st)
|
||||
var status agentic.WorkspaceStatus
|
||||
if result := core.JSONUnmarshalString(json, &status); !result.OK {
|
||||
status := agenticWorkspaceStatusFromRunner(st)
|
||||
if status == nil {
|
||||
return
|
||||
}
|
||||
status.UpdatedAt = time.Now()
|
||||
fs.WriteAtomic(agentic.WorkspaceStatusPath(wsDir), core.JSONMarshalString(&status))
|
||||
fs.WriteAtomic(agentic.WorkspaceStatusPath(wsDir), core.JSONMarshalString(status))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,25 @@ func ExampleReadStatus() {
|
|||
// go-io
|
||||
}
|
||||
|
||||
func ExampleReadStatusResult() {
|
||||
fsys := (&core.Fs{}).NewUnrestricted()
|
||||
dir := fsys.TempDir("runner-paths-result")
|
||||
defer fsys.DeleteAll(dir)
|
||||
|
||||
WriteStatus(dir, &WorkspaceStatus{
|
||||
Status: "completed",
|
||||
Agent: "codex",
|
||||
Repo: "go-io",
|
||||
})
|
||||
|
||||
result := ReadStatusResult(dir)
|
||||
core.Println(result.OK)
|
||||
core.Println(result.Value.(*WorkspaceStatus).Repo)
|
||||
// Output:
|
||||
// true
|
||||
// go-io
|
||||
}
|
||||
|
||||
func ExampleWriteStatus() {
|
||||
fsys := (&core.Fs{}).NewUnrestricted()
|
||||
dir := fsys.TempDir("runner-paths")
|
||||
|
|
|
|||
|
|
@ -71,6 +71,59 @@ func TestPaths_ReadStatus_Good(t *testing.T) {
|
|||
assert.Equal(t, 2, st.Runs)
|
||||
}
|
||||
|
||||
func TestPaths_ReadStatusResult_Good(t *testing.T) {
|
||||
wsDir := t.TempDir()
|
||||
status := &agentic.WorkspaceStatus{
|
||||
Status: "completed",
|
||||
Agent: "claude",
|
||||
Repo: "go-log",
|
||||
Org: "core",
|
||||
Task: "finish AX cleanup",
|
||||
Branch: "agent/ax-cleanup",
|
||||
PID: 21,
|
||||
Question: "Ready?",
|
||||
PRURL: "https://forge.test/core/go-log/pulls/12",
|
||||
StartedAt: time.Now(),
|
||||
Runs: 4,
|
||||
}
|
||||
require.True(t, agentic.LocalFs().WriteAtomic(agentic.WorkspaceStatusPath(wsDir), core.JSONMarshalString(status)).OK)
|
||||
|
||||
result := ReadStatusResult(wsDir)
|
||||
require.True(t, result.OK)
|
||||
|
||||
st, ok := result.Value.(*WorkspaceStatus)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "completed", st.Status)
|
||||
assert.Equal(t, "claude", st.Agent)
|
||||
assert.Equal(t, "go-log", st.Repo)
|
||||
assert.Equal(t, "core", st.Org)
|
||||
assert.Equal(t, "finish AX cleanup", st.Task)
|
||||
assert.Equal(t, "agent/ax-cleanup", st.Branch)
|
||||
assert.Equal(t, 21, st.PID)
|
||||
assert.Equal(t, "Ready?", st.Question)
|
||||
assert.Equal(t, "https://forge.test/core/go-log/pulls/12", st.PRURL)
|
||||
assert.Equal(t, 4, st.Runs)
|
||||
}
|
||||
|
||||
func TestPaths_ReadStatusResult_Bad_NoFile(t *testing.T) {
|
||||
result := ReadStatusResult(t.TempDir())
|
||||
assert.False(t, result.OK)
|
||||
err, ok := result.Value.(error)
|
||||
require.True(t, ok)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPaths_ReadStatusResult_Ugly_InvalidJSON(t *testing.T) {
|
||||
wsDir := t.TempDir()
|
||||
require.True(t, agentic.LocalFs().WriteAtomic(agentic.WorkspaceStatusPath(wsDir), "{not-json").OK)
|
||||
|
||||
result := ReadStatusResult(wsDir)
|
||||
assert.False(t, result.OK)
|
||||
err, ok := result.Value.(error)
|
||||
require.True(t, ok)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPaths_ReadStatus_Bad(t *testing.T) {
|
||||
wsDir := t.TempDir()
|
||||
require.True(t, agentic.LocalFs().WriteAtomic(agentic.WorkspaceStatusPath(wsDir), "{not-json").OK)
|
||||
|
|
|
|||
|
|
@ -218,22 +218,33 @@ func (s *Service) Poke() {
|
|||
}
|
||||
|
||||
// TrackWorkspace registers or updates a workspace in the in-memory Registry.
|
||||
// Accepts any status type — agentic passes *agentic.WorkspaceStatus,
|
||||
// runner stores its own *WorkspaceStatus copy.
|
||||
// Accepts the runner projection directly and the agentic projection from IPC.
|
||||
//
|
||||
// s.TrackWorkspace("core/go-io/task-5", st)
|
||||
// s.TrackWorkspace("core/go-io/task-5", &WorkspaceStatus{Status: "running", Agent: "codex"})
|
||||
// s.TrackWorkspace("core/go-io/task-5", &agentic.WorkspaceStatus{Status: "running", Agent: "codex"})
|
||||
func (s *Service) TrackWorkspace(name string, st any) {
|
||||
if s.workspaces == nil {
|
||||
return
|
||||
}
|
||||
// Convert from agentic's type to runner's via JSON round-trip
|
||||
json := core.JSONMarshalString(st)
|
||||
var ws WorkspaceStatus
|
||||
if r := core.JSONUnmarshalString(json, &ws); r.OK {
|
||||
s.workspaces.Set(name, &ws)
|
||||
// Remove pending reservation now that the real workspace is tracked
|
||||
s.workspaces.Delete(core.Concat("pending/", ws.Repo))
|
||||
var ws *WorkspaceStatus
|
||||
switch value := st.(type) {
|
||||
case *WorkspaceStatus:
|
||||
ws = value
|
||||
case *agentic.WorkspaceStatus:
|
||||
ws = runnerWorkspaceStatusFromAgentic(value)
|
||||
default:
|
||||
json := core.JSONMarshalString(st)
|
||||
var workspace WorkspaceStatus
|
||||
if r := core.JSONUnmarshalString(json, &workspace); r.OK {
|
||||
ws = &workspace
|
||||
}
|
||||
}
|
||||
if ws == nil {
|
||||
return
|
||||
}
|
||||
s.workspaces.Set(name, ws)
|
||||
// Remove pending reservation now that the real workspace is tracked
|
||||
s.workspaces.Delete(core.Concat("pending/", ws.Repo))
|
||||
}
|
||||
|
||||
// Workspaces returns the workspace Registry.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"context"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/agent/pkg/agentic"
|
||||
"dappco.re/go/agent/pkg/messages"
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -93,6 +94,15 @@ func TestRunner_TrackWorkspace_Good(t *testing.T) {
|
|||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestRunner_TrackWorkspace_Good_AgenticStatus(t *testing.T) {
|
||||
svc := New()
|
||||
svc.TrackWorkspace("core/go-io/dev", &agentic.WorkspaceStatus{
|
||||
Status: "running", Agent: "codex", Repo: "go-io", PID: 12345,
|
||||
})
|
||||
r := svc.workspaces.Get("core/go-io/dev")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestRunner_TrackWorkspace_Bad_NilWorkspaces(t *testing.T) {
|
||||
svc := &Service{}
|
||||
assert.NotPanics(t, func() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue