feat: add phase 3 readonly models
This commit is contained in:
43
internal/workflow/model.go
Normal file
43
internal/workflow/model.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package workflow
|
||||
|
||||
import "codex-agent-manager/internal/runtime"
|
||||
|
||||
type View struct {
|
||||
Events []Event `json:"events"`
|
||||
HandoffEdges []HandoffEdge `json:"handoffEdges"`
|
||||
Phases []Phase `json:"phases"`
|
||||
Source SourceEvidence `json:"source"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
ThreadID string `json:"threadId,omitempty"`
|
||||
RelatedID string `json:"relatedId,omitempty"`
|
||||
OccurredAt string `json:"occurredAt,omitempty"`
|
||||
Source SourceEvidence `json:"source"`
|
||||
}
|
||||
|
||||
type HandoffEdge struct {
|
||||
FromThreadID string `json:"fromThreadId"`
|
||||
ToThreadID string `json:"toThreadId"`
|
||||
Label string `json:"label"`
|
||||
Source SourceEvidence `json:"source"`
|
||||
}
|
||||
|
||||
type Phase struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Source SourceEvidence `json:"source"`
|
||||
}
|
||||
|
||||
type SourceEvidence struct {
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Confidence string `json:"confidence"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeReader interface {
|
||||
Snapshot() (runtime.Snapshot, error)
|
||||
}
|
||||
109
internal/workflow/store.go
Normal file
109
internal/workflow/store.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"codex-agent-manager/internal/runtime"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
WorkspaceRoot string
|
||||
Runtime RuntimeReader
|
||||
}
|
||||
|
||||
func (s Store) View() (View, error) {
|
||||
snapshot, err := s.Runtime.Snapshot()
|
||||
if err != nil {
|
||||
return View{}, err
|
||||
}
|
||||
view := View{
|
||||
Events: []Event{},
|
||||
HandoffEdges: []HandoffEdge{},
|
||||
Phases: []Phase{},
|
||||
Source: SourceEvidence{Kind: snapshot.Source.Kind, Path: snapshot.Source.Path, Confidence: snapshot.Source.Confidence, Message: snapshot.Source.Message},
|
||||
}
|
||||
for _, thread := range snapshot.Threads {
|
||||
view.Events = append(view.Events, Event{
|
||||
Kind: "thread",
|
||||
Label: thread.Role,
|
||||
ThreadID: thread.ID,
|
||||
OccurredAt: thread.CreatedAt,
|
||||
Source: fromRuntimeSource(thread.Source),
|
||||
})
|
||||
}
|
||||
for _, edge := range snapshot.SpawnEdges {
|
||||
source := fromRuntimeSource(edge.Source)
|
||||
view.Events = append(view.Events, Event{
|
||||
Kind: "handoff",
|
||||
Label: edge.Reason,
|
||||
ThreadID: edge.FromThreadID,
|
||||
RelatedID: edge.ToThreadID,
|
||||
OccurredAt: edge.CreatedAt,
|
||||
Source: source,
|
||||
})
|
||||
view.HandoffEdges = append(view.HandoffEdges, HandoffEdge{
|
||||
FromThreadID: edge.FromThreadID,
|
||||
ToThreadID: edge.ToThreadID,
|
||||
Label: edge.Reason,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
for _, goal := range snapshot.Goals {
|
||||
view.Events = append(view.Events, Event{
|
||||
Kind: "goal",
|
||||
Label: goal.Status,
|
||||
ThreadID: goal.ThreadID,
|
||||
RelatedID: goal.Goal,
|
||||
OccurredAt: goal.UpdatedAt,
|
||||
Source: fromRuntimeSource(goal.Source),
|
||||
})
|
||||
}
|
||||
planEvents, phases := readPlanEvidence(s.WorkspaceRoot)
|
||||
view.Events = append(view.Events, planEvents...)
|
||||
view.Phases = phases
|
||||
sort.SliceStable(view.Events, func(i, j int) bool {
|
||||
if view.Events[i].OccurredAt == view.Events[j].OccurredAt {
|
||||
return view.Events[i].Kind < view.Events[j].Kind
|
||||
}
|
||||
return view.Events[i].OccurredAt < view.Events[j].OccurredAt
|
||||
})
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func fromRuntimeSource(source runtime.SourceEvidence) SourceEvidence {
|
||||
return SourceEvidence{
|
||||
Kind: source.Kind,
|
||||
Path: source.Path,
|
||||
Confidence: source.Confidence,
|
||||
Message: source.Message,
|
||||
}
|
||||
}
|
||||
|
||||
func readPlanEvidence(root string) ([]Event, []Phase) {
|
||||
path := filepath.Join(root, "task_plan.md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return []Event{}, []Phase{}
|
||||
}
|
||||
source := SourceEvidence{Kind: "plan_file", Path: path, Confidence: "medium"}
|
||||
events := []Event{{Kind: "plan_file", Label: filepath.Base(path), Source: source}}
|
||||
var phases []Phase
|
||||
re := regexp.MustCompile(`^\|\s*([^|]+?)\s*\|\s*([A-Za-z_]+)\s*\|`)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
match := re.FindStringSubmatch(line)
|
||||
if len(match) != 3 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(match[1])
|
||||
status := strings.TrimSpace(match[2])
|
||||
if strings.EqualFold(name, "Phase") || strings.EqualFold(status, "Status") {
|
||||
continue
|
||||
}
|
||||
phases = append(phases, Phase{Name: name, Status: status, Source: source})
|
||||
}
|
||||
return events, phases
|
||||
}
|
||||
65
internal/workflow/store_test.go
Normal file
65
internal/workflow/store_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"codex-agent-manager/internal/runtime"
|
||||
)
|
||||
|
||||
func TestStoreBuildsDynamicEventsWithoutFixedRoles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "task_plan.md"), []byte("| 3 | in_progress | Runtime model |\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := runtime.Snapshot{
|
||||
Threads: []runtime.Thread{
|
||||
{ID: "thread-a", Role: "cartographer", Status: "done", CreatedAt: "2026-05-25T01:00:00Z"},
|
||||
{ID: "thread-b", Role: "navigator", Status: "running", CreatedAt: "2026-05-25T01:02:00Z"},
|
||||
},
|
||||
SpawnEdges: []runtime.SpawnEdge{
|
||||
{FromThreadID: "thread-a", ToThreadID: "thread-b", Reason: "map complete", CreatedAt: "2026-05-25T01:02:00Z"},
|
||||
},
|
||||
Goals: []runtime.Goal{
|
||||
{ThreadID: "thread-b", Goal: "verify route", Status: "blocked", UpdatedAt: "2026-05-25T01:03:00Z"},
|
||||
},
|
||||
Source: runtime.SourceEvidence{Kind: "test", Confidence: "high", Path: "memory"},
|
||||
}
|
||||
|
||||
view, err := Store{WorkspaceRoot: root, Runtime: StaticRuntime{SnapshotValue: snapshot}}.View()
|
||||
if err != nil {
|
||||
t.Fatalf("View returned error: %v", err)
|
||||
}
|
||||
if len(view.Events) != 5 {
|
||||
t.Fatalf("events = %#v", view.Events)
|
||||
}
|
||||
assertHasEvent(t, view.Events, "thread", "cartographer")
|
||||
assertHasEvent(t, view.Events, "handoff", "map complete")
|
||||
assertHasEvent(t, view.Events, "goal", "blocked")
|
||||
assertHasEvent(t, view.Events, "plan_file", "task_plan.md")
|
||||
if len(view.HandoffEdges) != 1 || view.HandoffEdges[0].FromThreadID != "thread-a" || view.HandoffEdges[0].ToThreadID != "thread-b" {
|
||||
t.Fatalf("unexpected handoff edges: %#v", view.HandoffEdges)
|
||||
}
|
||||
if len(view.Phases) != 1 || view.Phases[0].Name != "3" || view.Phases[0].Status != "in_progress" || view.Phases[0].Source.Confidence != "medium" {
|
||||
t.Fatalf("unexpected phases: %#v", view.Phases)
|
||||
}
|
||||
}
|
||||
|
||||
type StaticRuntime struct {
|
||||
SnapshotValue runtime.Snapshot
|
||||
}
|
||||
|
||||
func (s StaticRuntime) Snapshot() (runtime.Snapshot, error) {
|
||||
return s.SnapshotValue, nil
|
||||
}
|
||||
|
||||
func assertHasEvent(t *testing.T, events []Event, kind string, contains string) {
|
||||
t.Helper()
|
||||
for _, event := range events {
|
||||
if event.Kind == kind && event.Label == contains {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing event kind=%q label=%q in %#v", kind, contains, events)
|
||||
}
|
||||
Reference in New Issue
Block a user