Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,3 @@ Patterns and ideas borrowed from [Gastown](https://github.com/steveyegge/gastown
## License

[Apache License 2.0](LICENSE)

---

_Made with ❤️ by [Vortex Dispatch](https://github.com/tzone85/vortex-dispatch) (NXD is the offline-first sibling). Remove this line freely._
2 changes: 1 addition & 1 deletion docs/demo.tape
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Type "echo ''"
Enter
Type "echo 'NXD: Offline-first AI agent orchestration powered by Ollama.'"
Enter
Type "echo 'Same pipeline as VXD, no cloud APIs needed.'"
Type "echo 'Full multi-agent pipeline, no cloud APIs needed.'"
Enter
Type "echo ''"
Enter
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ func runResume(cmd *cobra.Command, args []string) error {
controller := engine.NewController(s.Config.Controller, nil, s.Events, s.Proj)

// Wire devdb Lifecycle if configured. Nil when provider is "" or "null".
devdbLifecycle := newDevDBLifecycle(s.Config, s.Events)
devdbLifecycle := newDevDBLifecycle(s.Config, s.Events, s.Proj)
if devdbLifecycle != nil {
log.Printf("[startup] devdb enabled: provider=%s", s.Config.DevDB.Provider)
}
Expand Down Expand Up @@ -557,7 +557,7 @@ func newDevDBProvider(cfg config.Config) (devdb.Provider, error) {

// newDevDBLifecycle builds a Lifecycle from config. Returns nil when devdb is
// disabled (provider == "" or "null").
func newDevDBLifecycle(cfg config.Config, events state.EventStore) *devdb.Lifecycle {
func newDevDBLifecycle(cfg config.Config, events state.EventStore, projector devdb.EventProjector) *devdb.Lifecycle {
if cfg.DevDB.Provider == "" || cfg.DevDB.Provider == "null" {
return nil
}
Expand All @@ -571,7 +571,7 @@ func newDevDBLifecycle(cfg config.Config, events state.EventStore) *devdb.Lifecy
Template: cfg.DevDB.Template,
KeepDBOnFail: cfg.DevDB.OnFailure.KeepDB,
RetainHours: time.Duration(cfg.DevDB.OnFailure.RetainHours) * time.Hour,
})
}).WithProjector(projector)
}

// runDevDBOrphanRecovery finds and deletes DB containers left behind by
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/resume_devdb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func TestNewDevDBLifecycle_NullDisabled(t *testing.T) {
cfg := config.Config{}
cfg.DevDB.Provider = "null"

lc := newDevDBLifecycle(cfg, nil)
lc := newDevDBLifecycle(cfg, nil, nil)
if lc != nil {
t.Error("expected nil lifecycle for null provider")
}
Expand All @@ -83,7 +83,7 @@ func TestNewDevDBLifecycle_EmptyDisabled(t *testing.T) {
cfg := config.Config{}
cfg.DevDB.Provider = ""

lc := newDevDBLifecycle(cfg, nil)
lc := newDevDBLifecycle(cfg, nil, nil)
if lc != nil {
t.Error("expected nil lifecycle when devdb section absent")
}
Expand All @@ -101,7 +101,7 @@ func TestNewDevDBLifecycle_DockerReturnsNonNil(t *testing.T) {
cfg.DevDB.Provider = "docker"
cfg.DevDB.Docker.Image = "postgres:16"

lc := newDevDBLifecycle(cfg, es)
lc := newDevDBLifecycle(cfg, es, nil)
if lc == nil {
t.Fatal("expected non-nil lifecycle for docker provider")
}
Expand All @@ -111,7 +111,7 @@ func TestNewDevDBLifecycle_UnsupportedReturnsNil(t *testing.T) {
cfg := config.Config{}
cfg.DevDB.Provider = "ghost"

lc := newDevDBLifecycle(cfg, nil)
lc := newDevDBLifecycle(cfg, nil, nil)
if lc != nil {
t.Error("expected nil lifecycle on unsupported provider (graceful degrade)")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/dashboard/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func (m Model) renderStatusBar() string {
}

bar := left + middle + strings.Repeat(" ", gap) + right
attribution := lipgloss.NewStyle().Foreground(colorDimGray).Render(" Made with ♥ by Vortex Dispatch ")
attribution := lipgloss.NewStyle().Foreground(colorDimGray).Render(" Made with ♥ by Nexus Dispatch ")
return statusBarStyle.Width(m.width).Render(bar) + "\n" + attribution
}

Expand Down
50 changes: 42 additions & 8 deletions internal/devdb/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,22 @@ type EventAppender interface {
Append(state.Event) error
}

// EventProjector projects an emitted event into the read model. The Lifecycle
// emits STORY_DB_* events directly (callers never see them), so it must drive
// the projection itself — otherwise the story_databases table stays empty in
// production even though the projection switch handles those event types.
type EventProjector interface {
Project(state.Event) error
}

// Lifecycle orchestrates a Provider + event emission + worktree file writes.
// Engine code uses Lifecycle, not Provider directly.
type Lifecycle struct {
provider Provider
events EventAppender
cfg Config
clock func() time.Time
provider Provider
events EventAppender
projector EventProjector
cfg Config
clock func() time.Time
}

// NewLifecycle wires a Lifecycle with the supplied Provider, event appender,
Expand All @@ -47,6 +56,25 @@ func NewLifecycle(p Provider, ea EventAppender, cfg Config) *Lifecycle {
}
}

// WithProjector attaches a projector so emitted STORY_DB_* events also update
// the read model. Returns the receiver for chaining. When nil (the default),
// events are appended only.
func (l *Lifecycle) WithProjector(p EventProjector) *Lifecycle {
l.projector = p
return l
}

// emit appends an event and, when a projector is wired, projects it so the
// read model reflects the DB lifecycle. Append failures are swallowed (events
// are best-effort telemetry, matching the prior behaviour); a projection
// failure is non-fatal too — the append already durably recorded the event.
func (l *Lifecycle) emit(evt state.Event) {
_ = l.events.Append(evt)
if l.projector != nil {
_ = l.projector.Project(evt)
}
}

// Provider exposes the underlying provider (used for orphan recovery, ping).
func (l *Lifecycle) Provider() Provider { return l.provider }

Expand Down Expand Up @@ -90,12 +118,17 @@ func (l *Lifecycle) Release(ctx context.Context, db DB, outcome StoryOutcome) er
status = "retained"
}

// The DB name encodes the story ID (FormatDBName). Recover it so the
// emitted events carry StoryID — projectStoryDBDeleted/Failed key their
// UPDATE on story_id, so an empty value would silently match zero rows.
storyID := ParseStoryID(PrefixNXD, db.Name)

if !keep {
if err := l.provider.Delete(ctx, db.ID); err != nil {
// Emit a failed-release event so GC can pick up later. We do not
// return the error after the event is emitted — callers don't
// need to block pipeline progress on release failures.
l.emitFailed("", db.Name, fmt.Sprintf("release: %v", err))
l.emitFailed(storyID, db.Name, fmt.Sprintf("release: %v", err))
return fmt.Errorf("devdb release: %w", err)
}
}
Expand All @@ -111,8 +144,9 @@ func (l *Lifecycle) Release(ctx context.Context, db DB, outcome StoryOutcome) er
"status": status,
}
data, _ := json.Marshal(payload)
_ = l.events.Append(state.Event{
l.emit(state.Event{
Type: state.EventStoryDBDeleted,
StoryID: storyID,
Timestamp: l.clock(),
Payload: data,
})
Expand All @@ -129,7 +163,7 @@ func (l *Lifecycle) emitCreated(storyID string, db DB) {
"conn_string_hash": "sha256:" + hex.EncodeToString(h[:]),
}
data, _ := json.Marshal(payload)
_ = l.events.Append(state.Event{
l.emit(state.Event{
Type: state.EventStoryDBCreated,
StoryID: storyID,
Timestamp: l.clock(),
Expand All @@ -145,7 +179,7 @@ func (l *Lifecycle) emitFailed(storyID, name, errMsg string) {
"error": errMsg,
}
data, _ := json.Marshal(payload)
_ = l.events.Append(state.Event{
l.emit(state.Event{
Type: state.EventStoryDBFailed,
StoryID: storyID,
Timestamp: l.clock(),
Expand Down
89 changes: 89 additions & 0 deletions internal/devdb/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,92 @@ func TestLifecycle_Release_FailedWithoutKeepDB_Deletes(t *testing.T) {
t.Errorf("expected one delete call, got %v", rp.deleted)
}
}

// fakeProjector records events the lifecycle drives into the read model.
type fakeProjector struct {
projected []state.Event
}

func (f *fakeProjector) Project(evt state.Event) error {
f.projected = append(f.projected, evt)
return nil
}

func TestLifecycle_Provision_ProjectsCreatedEvent(t *testing.T) {
es := &fakeEventStore{}
pr := &fakeProjector{}
lc := devdb.NewLifecycle(null.New(), es, devdb.Config{Provider: "null"}).WithProjector(pr)

if _, err := lc.Provision(context.Background(), "story-1", "myproj", t.TempDir()); err != nil {
t.Fatalf("Provision: %v", err)
}
if len(pr.projected) != 1 || pr.projected[0].Type != state.EventStoryDBCreated {
t.Fatalf("STORY_DB_CREATED must be projected, got %+v", pr.projected)
}
if pr.projected[0].StoryID != "story-1" {
t.Errorf("projected created story_id = %q, want story-1", pr.projected[0].StoryID)
}
}

// Release must recover the story ID from the DB name and project the deletion;
// projectStoryDBDeleted keys its UPDATE on story_id, so an empty value would
// silently match zero rows (regression guard for the missing-StoryID bug).
func TestLifecycle_Release_ProjectsDeletedEventWithStoryID(t *testing.T) {
es := &fakeEventStore{}
pr := &fakeProjector{}
lc := devdb.NewLifecycle(null.New(), es, devdb.Config{Provider: "null"}).WithProjector(pr)

db := devdb.DB{ID: "null-nxd-myproj-story-1", Name: "nxd-myproj-story-1"}
if err := lc.Release(context.Background(), db, devdb.OutcomeSuccess); err != nil {
t.Fatalf("Release: %v", err)
}
if len(pr.projected) != 1 || pr.projected[0].Type != state.EventStoryDBDeleted {
t.Fatalf("STORY_DB_DELETED must be projected, got %+v", pr.projected)
}
if pr.projected[0].StoryID != "story-1" {
t.Errorf("deleted event story_id = %q, want story-1 (recovered from db name)", pr.projected[0].StoryID)
}
}

// End-to-end: provisioning then releasing must create and then transition a
// story_databases row in a real projection store. Before the wiring fix the
// table stayed empty in production.
func TestLifecycle_ProjectsRoundTripIntoSQLiteStore(t *testing.T) {
es := &fakeEventStore{}
store, err := state.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
defer store.Close()

lc := devdb.NewLifecycle(null.New(), es, devdb.Config{Provider: "null"}).WithProjector(store)

db, err := lc.Provision(context.Background(), "story-1", "myproj", t.TempDir())
if err != nil {
t.Fatalf("Provision: %v", err)
}
rows, err := store.ListStoryDatabases(state.StoryDBFilter{})
if err != nil {
t.Fatalf("ListStoryDatabases: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected a story_databases row after Provision, got %d", len(rows))
}
if rows[0].Status != "created" {
t.Errorf("status after provision = %q, want created", rows[0].Status)
}

if err := lc.Release(context.Background(), db, devdb.OutcomeSuccess); err != nil {
t.Fatalf("Release: %v", err)
}
rows, err = store.ListStoryDatabases(state.StoryDBFilter{})
if err != nil {
t.Fatalf("ListStoryDatabases after release: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row after Release, got %d", len(rows))
}
if rows[0].Status != "deleted" {
t.Errorf("status after release = %q, want deleted (StoryID-keyed UPDATE must match)", rows[0].Status)
}
}
2 changes: 1 addition & 1 deletion internal/devdb/naming_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func TestParseStoryID_Roundtrip(t *testing.T) {
}

func TestParseStoryID_WrongPrefix(t *testing.T) {
got := devdb.ParseStoryID("vxd", "nxd-myproj-story-1")
got := devdb.ParseStoryID("abc", "nxd-myproj-story-1")
if got != "" {
t.Errorf("ParseStoryID with wrong prefix = %q, want empty", got)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/engine/checkpoint_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ func TestWriteCheckpoint_MultipleAgents(t *testing.T) {
Phase: PhaseMonitoring,
WaveNumber: 3,
ActiveAgents: []CheckpointAgent{
{StoryID: "s-a", SessionName: "vxd-s-a", WorktreePath: "/tmp/a", RuntimeName: "claude", Branch: "feat/a"},
{StoryID: "s-b", SessionName: "vxd-s-b", WorktreePath: "/tmp/b", RuntimeName: "gemini", Branch: "feat/b"},
{StoryID: "s-a", SessionName: "sess-s-a", WorktreePath: "/tmp/a", RuntimeName: "claude", Branch: "feat/a"},
{StoryID: "s-b", SessionName: "sess-s-b", WorktreePath: "/tmp/b", RuntimeName: "gemini", Branch: "feat/b"},
},
Timestamp: time.Now(),
PID: 42,
Expand Down
3 changes: 3 additions & 0 deletions internal/engine/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ func (d *Dispatcher) DispatchWave(dag *graph.DAG, completed map[string]bool, req
if err := d.eventStore.Append(spawnEvt); err != nil {
return nil, fmt.Errorf("emit agent spawned: %w", err)
}
if err := d.projStore.Project(spawnEvt); err != nil {
return nil, fmt.Errorf("project agent spawned: %w", err)
}

// Emit assignment event
assignEvt := state.NewEvent(state.EventStoryAssigned, agentID, story.ID, map[string]any{
Expand Down
46 changes: 45 additions & 1 deletion internal/engine/dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,50 @@ func TestDispatcher_DispatchWave(t *testing.T) {
}
}

func TestDispatcher_ProjectsSpawnedAgents(t *testing.T) {
es, ps, cleanup := newTestStores(t)
defer cleanup()

evt := state.NewEvent(state.EventStoryCreated, "tech-lead", "s-001", map[string]any{
"id": "s-001", "req_id": "r-001", "title": "Task", "description": "desc", "complexity": 2,
})
ps.Project(evt)

dispatcher := engine.NewDispatcher(config.DefaultConfig(), es, ps)
dag := graph.New()
dag.AddNode("s-001")

assignments, err := dispatcher.DispatchWave(dag, map[string]bool{}, "r-001",
[]engine.PlannedStory{{ID: "s-001", Title: "Task", Complexity: 2}}, 1)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(assignments) != 1 {
t.Fatalf("expected 1 assignment, got %d", len(assignments))
}

// The dispatcher must project AGENT_SPAWNED so downstream consumers
// (`nxd agents`, dashboard, crash recovery) can see the dispatched agent.
sqlStore := ps.(*state.SQLiteStore)
agents, err := sqlStore.ListAgents(state.AgentFilter{})
if err != nil {
t.Fatalf("ListAgents: %v", err)
}
if len(agents) != 1 {
t.Fatalf("expected dispatched agent in projection, got %d", len(agents))
}
a := agents[0]
if a.ID != assignments[0].AgentID {
t.Errorf("agent id = %q, want %q", a.ID, assignments[0].AgentID)
}
if a.SessionName != assignments[0].SessionName || a.SessionName == "" {
t.Errorf("agent session = %q, want %q", a.SessionName, assignments[0].SessionName)
}
if a.CurrentStoryID != "s-001" {
t.Errorf("agent current_story_id = %q, want s-001", a.CurrentStoryID)
}
}

func TestDispatcher_EmptyWave(t *testing.T) {
es, ps, cleanup := newTestStores(t)
defer cleanup()
Expand Down Expand Up @@ -287,7 +331,7 @@ func TestDispatchWave_RejectsUnsafeStoryID(t *testing.T) {
"story&evil",
"story|pipe",
"story\nnewline",
"story evil", // space
"story evil", // space
"../traversal",
}

Expand Down
Loading
Loading