diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 64b3af9959..a7da5fbdf5 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -725,6 +725,7 @@ type appBackend struct { collections *workspace.Store collectionsClosed bool collectionsPath string + workstreams appWorkstreams // services is the app's one supervisor of kept-running processes // (appservices.go); nil is the capability absent. services *appServices diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 4471d60792..370454a14e 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -35,6 +35,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream" ) // appCollectionsPath is where the collections store is, the CLI's own default. @@ -97,11 +98,16 @@ func (b *appBackend) closeCollections() { // appCollectionError is a store refusal in the wire's codes. func appCollectionError(err error) error { var wire *appwire.Error + var taken *workspace.WorkstreamTaken switch { case err == nil: return nil case errors.As(err, &wire): return wire + case errors.As(err, &taken): + return &appwire.Error{Code: appwire.CodeInOtherWorkstream, Message: taken.Error(), Detail: appwire.WorkstreamTaken{Factory: taken.Factory, Job: taken.Job, WorkstreamID: taken.WorkstreamID, WorkstreamName: taken.WorkstreamName}} + case errors.Is(err, workspace.ErrWorkstreamDelete): + return appwire.Errorf(appwire.CodeBadParams, "%s", err) case errors.Is(err, workspace.ErrNameTaken): return appwire.Errorf(appwire.CodeConflict, "%s", workspace.ErrNameTaken.Error()) case errors.Is(err, workspace.ErrCycle): @@ -123,11 +129,11 @@ func (b *appBackend) collectionChanged(id string, deleted bool) { } func appStoreRef(r appwire.Ref) workspace.Ref { - return workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: r.SessionID} + return workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: r.SessionID, Factory: r.Factory} } func appWireRef(r workspace.Ref) appwire.Ref { - return appwire.Ref{Kind: string(r.Kind), ID: r.ID, SessionID: r.SessionID} + return appwire.Ref{Kind: string(r.Kind), ID: r.ID, SessionID: r.SessionID, Factory: r.Factory} } // appMoment is a stored moment on the wire: absent when unknown. @@ -144,7 +150,7 @@ func appWireMember(m workspace.Member) appwire.Member { } func appWireCollection(s workspace.Summary) appwire.Collection { - return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds} + return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds, Workstream: s.Workstream} } // describe is one collection as the wire says it. @@ -202,10 +208,20 @@ func (b *appBackend) RenameCollection(id, name string) (appwire.Collection, erro if err != nil { return appwire.Collection{}, err } + before, err := s.Describe(context.Background(), id) + if err != nil { + return appwire.Collection{}, appCollectionError(err) + } + if before.Workstream && before.Name == name { + return appWireCollection(before), nil + } if err := s.Rename(context.Background(), id, name); err != nil { return appwire.Collection{}, appCollectionError(err) } b.collectionChanged(id, false) + if before.Workstream && before.Name != name { + b.workstreamCollectionChanged(id, workstream.PartWorkstream) + } return describeCollection(s, id) } @@ -237,6 +253,7 @@ func (b *appBackend) DeleteCollection(id string) error { b.collectionChanged(id, true) for _, parent := range parents { b.collectionChanged(parent, false) + b.workstreamCollectionChanged(parent, workstream.PartJobs) } return nil } @@ -268,6 +285,7 @@ func (b *appBackend) AddToCollection(id string, refs []appwire.Member) (appwire. } if len(added) > 0 { b.collectionChanged(id, false) + b.workstreamCollectionChanged(id, workstream.PartJobs) } return out, nil } @@ -292,6 +310,7 @@ func (b *appBackend) RemoveFromCollection(id string, refs []appwire.Ref) (appwir } if len(removed) > 0 { b.collectionChanged(id, false) + b.workstreamCollectionChanged(id, workstream.PartJobs) } return out, nil } @@ -308,6 +327,7 @@ func (b *appBackend) OpenCollection(id string, depth, limit int, after string) ( return appwire.CollectionOpenResult{}, appCollectionError(err) } r := newCollectionResolver(b) + r.prepareWorkstreamTree(tree.Level) var nodes func(level workspace.Level) []appwire.Node nodes = func(level workspace.Level) []appwire.Node { out := make([]appwire.Node, 0, len(level.Members)) @@ -359,6 +379,7 @@ func (b *appBackend) ExpandCollection(id string, depth, limit int) (appwire.Coll Missing: e.Deleted, Unread: e.Unread, Collections: e.Collections, More: e.More, } r := newCollectionResolver(b) + r.prepareWorkstreamRefs(e.Refs) for _, m := range e.Refs { target := r.target(m.Ref) switch target.State { @@ -400,6 +421,7 @@ func (b *appBackend) RelinkCollection(id string, from, to appwire.Ref) (appwire. // Relinking a member to itself changed nothing, so it says nothing. if from != to { b.collectionChanged(id, false) + b.workstreamCollectionChanged(id, workstream.PartJobs) } return appwire.CollectionRelinkResult{Member: appWireMember(m), Already: already}, nil } @@ -499,9 +521,10 @@ type collectionResolver struct { crewByID map[string]int crewsErr error - tasks map[string]appOwned[appwire.Task] - procs map[string]appOwned[appwire.Process] - git map[string]*appGitTree + tasks map[string]appOwned[appwire.Task] + procs map[string]appOwned[appwire.Process] + git map[string]*appGitTree + factoryTargets map[workspace.Ref]appwire.Target } // appOwned is one agent's tasks or processes by wire id, or why they could @@ -517,6 +540,11 @@ func newCollectionResolver(b *appBackend) *collectionResolver { func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { switch ref.Kind { + case workspace.FactoryJobKind: + if _, ok := r.factoryTargets[ref]; !ok { + r.prepareWorkstreamRefs([]workspace.Member{{Ref: ref}}) + } + return r.factoryTargets[ref] case workspace.ConversationKind: a, not := r.agent(ref.ID) if not != nil { diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 6287c13030..138e9753de 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -1,108 +1,965 @@ package main -import "github.com/Agent-Field/codeaf/internal/appwire" +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream" +) var _ appwire.Workstreamer = (*appBackend)(nil) -// WP-1 makes this true when the store lands. -func (b *appBackend) ServesWorkstreams() bool { return false } +const ( + workstreamFactoryTimeout = 3 * time.Second + workstreamFactoryFresh = 10 * time.Second + workstreamFactoryCacheMax = 32 + workstreamMissingJobsMax = 20 + workstreamFactoryParallel = 8 +) + +// appWorkstreams belongs to one backend, so cache entries and create retries +// cannot cross profiles. The function seams replace external owners in tests. +type appWorkstreams struct { + createMu sync.Mutex + createAgent func(appwire.CreateAsk) (appwire.Agent, error) + closeAgent func(string) error + archiveChats func([]string) error + queue func(context.Context, string) (api.Queue, error) + job func(context.Context, string, int) (api.Job, error) + spend func(string) *float64 + cacheMu sync.Mutex + cache map[string]workstreamQueueRead + flights map[string]chan struct{} +} -func (b *appBackend) WorkstreamList(appwire.WorkstreamListParams) (appwire.WorkstreamListResult, error) { - return appwire.WorkstreamListResult{}, appwire.ErrWorkstreamsNotYet +type workstreamQueueRead struct { + at time.Time + jobs map[int]api.Job + err error } -func (b *appBackend) WorkstreamGet(appwire.WorkstreamGetParams) (appwire.WorkstreamGetResult, error) { - return appwire.WorkstreamGetResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) ServesWorkstreams() bool { return true } + +func (b *appBackend) workstreamStore(create bool) (*workstream.Store, error) { + s, err := b.collectionStore(create) + if err != nil || s == nil { + return nil, err + } + return workstream.New(s, workstream.Options{Now: b.now}), nil } -func (b *appBackend) WorkstreamCreate(appwire.WorkstreamCreateParams) (appwire.WorkstreamCreateResult, error) { - return appwire.WorkstreamCreateResult{}, appwire.ErrWorkstreamsNotYet +// workstreamDo emits committed receipts, including a read's first observation. +// Unchanged and refused writes have empty receipts and do not notify watchers. +func workstreamDo[P interface{ Check() error }, R any](b *appBackend, p P, call func(*workstream.Store) (R, workstream.Change, error)) (R, error) { + var zero R + if err := p.Check(); err != nil { + return zero, err + } + s, err := b.workstreamStore(false) + if err != nil { + return zero, err + } + if s == nil { + return zero, appwire.Errorf(appwire.CodeNotFound, "workstream target not found") + } + out, change, err := call(s) + // A refresh can fail after a write commits; its receipt still belongs to watchers. + b.workstreamChanged(change) + if err != nil { + return zero, appWorkstreamError(err) + } + return out, nil } -func (b *appBackend) WorkstreamUpdate(appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { - return appwire.WorkstreamUpdateResult{}, appwire.ErrWorkstreamsNotYet +func appWorkstreamError(err error) error { + var stale *workstream.VersionConflict + switch { + case errors.As(err, &stale): + return &appwire.Error{Code: appwire.CodeVersionConflict, Message: stale.Error(), Detail: appwire.WorkstreamVersionConflict{ID: stale.ID, Version: stale.Version, UpdatedAt: stale.UpdatedAt, UpdatedBy: stale.UpdatedBy}} + case errors.Is(err, workstream.ErrNotFound): + return appwire.Errorf(appwire.CodeNotFound, "%s", err) + case errors.Is(err, workstream.ErrConflict): + return appwire.Errorf(appwire.CodeConflict, "%s", err) + case errors.Is(err, workstream.ErrCycle): + return appwire.Errorf(appwire.CodeCycle, "%s", err) + case errors.Is(err, workstream.ErrTooLarge): + return appwire.Errorf(appwire.CodeTooLarge, "%s", err) + } + return appCollectionError(err) } -func (b *appBackend) WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams) (appwire.WorkstreamMarkDoneResult, error) { - return appwire.WorkstreamMarkDoneResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) workstreamChanged(change workstream.Change) { + if change.ID == "" || len(change.Parts) == 0 { + return + } + parts := make([]string, 0, len(change.Parts)) + for _, p := range change.Parts { + parts = append(parts, string(p)) + } + b.emit.Emit(appwire.EventWorkstreamChanged, appwire.WorkstreamChanged{ID: change.ID, Parts: parts}) } -func (b *appBackend) WorkstreamReopen(appwire.WorkstreamReopenParams) (appwire.WorkstreamReopenResult, error) { - return appwire.WorkstreamReopenResult{}, appwire.ErrWorkstreamsNotYet +// workstreamCollectionChanged is used after collection writes that share the +// workstream's identity. Only workstream collections gain this second event. +func (b *appBackend) workstreamCollectionChanged(id string, part workstream.ChangedPart) { + s, err := b.existingStore() + if err != nil { + return + } + c, err := s.Describe(context.Background(), id) + if err == nil && c.Workstream { + parts := []workstream.ChangedPart{workstream.PartWorkstream} + if part != workstream.PartWorkstream { + parts = append(parts, part) + } + b.workstreamChanged(workstream.Change{ID: id, Parts: parts}) + } } -func (b *appBackend) WorkstreamPlanGet(appwire.WorkstreamPlanGetParams) (appwire.WorkstreamPlanGetResult, error) { - return appwire.WorkstreamPlanGetResult{}, appwire.ErrWorkstreamsNotYet +func workstreamValue[T any](p *T) T { + if p != nil { + return *p + } + var zero T + return zero +} +func workstreamOptional(s string) *string { + if s == "" { + return nil + } + return &s +} +func workstreamPositive(n int) *int { + if n == 0 { + return nil + } + return &n } -func (b *appBackend) WorkstreamPlanSet(appwire.WorkstreamPlanSetParams) (appwire.WorkstreamPlanSetResult, error) { - return appwire.WorkstreamPlanSetResult{}, appwire.ErrWorkstreamsNotYet +func appWorkstream(w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts, spend *float64) appwire.Workstream { + jobs := w.Jobs() + states := make([]string, 0, w.Total) + for _, j := range jobs { + states = append(states, facts[j].State) + } + numbers := workstream.CountNumbers(jobs, facts, spend, w.Proposed) + return appwire.Workstream{ID: w.ID, Name: w.Name, Why: w.Why, Stage: workstream.Stage(w.DoneAt, w.StartedAt, states), AgentID: w.AgentID, OriginChatID: workstreamOptional(w.OriginChatID), Autonomy: w.Autonomy, Wake: appwire.WorkstreamWake{Morning: w.Wake.Morning, RelatedIssues: w.Wake.RelatedIssues}, BudgetUSD: w.BudgetUSD, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, StartedAt: w.StartedAt, DoneAt: w.DoneAt, HowItWent: workstreamOptional(w.HowItWent), Total: numbers.Total, Merged: numbers.Merged, NeedsYou: numbers.NeedsYou, SpendUSD: numbers.SpendUSD} } -func (b *appBackend) WorkstreamPageList(appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { - return appwire.WorkstreamPageListResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.WorkstreamListResult, error) { + if err := p.Check(); err != nil { + return appwire.WorkstreamListResult{}, err + } + s, err := b.workstreamStore(false) + out := appwire.WorkstreamListResult{Workstreams: []appwire.WorkstreamRow{}} + if err != nil || s == nil { + return out, err + } + rows, err := s.List(context.Background()) + if err != nil { + return out, appWorkstreamError(err) + } + jobs := []workstream.Job{} + for _, w := range rows { + jobs = append(jobs, w.Jobs()...) + } + facts := b.workstreamFacts(jobs) + for _, w := range rows { + var c workstream.Change + w, c, err = b.workstreamObserved(s, w, facts, workstream.Change{}) + b.workstreamChanged(c) + if err != nil { + return appwire.WorkstreamListResult{}, err + } + v := appWorkstream(w, facts, b.workstreamSpend(w.AgentID)) + out.Workstreams = append(out.Workstreams, appwire.WorkstreamRow{ID: v.ID, Name: v.Name, Stage: v.Stage, Total: v.Total, Merged: v.Merged, NeedsYou: v.NeedsYou, SpendUSD: v.SpendUSD, BudgetUSD: v.BudgetUSD, UpdatedAt: v.UpdatedAt}) + } + return out, nil } -func (b *appBackend) WorkstreamPageGet(appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { - return appwire.WorkstreamPageGetResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.WorkstreamGetResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGetResult, workstream.Change, error) { + w, err := s.Get(context.Background(), p.ID) + if err != nil { + return appwire.WorkstreamGetResult{}, workstream.Change{}, err + } + facts := b.workstreamFacts(w.Jobs()) + w, c, err := b.workstreamObserved(s, w, facts, workstream.Change{}) + return appwire.WorkstreamGetResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err + }) } -func (b *appBackend) WorkstreamPageSave(appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { - return appwire.WorkstreamPageSaveResult{}, appwire.ErrWorkstreamsNotYet +// workstreamAgentAsk is the single agent-creation hook for workstreams. Empty +// text is a bare open in prepareCreate and cannot start a model turn. +func workstreamAgentAsk(p appwire.WorkstreamCreateParams) appwire.CreateAsk { + // WP-10 attaches the workstream system prompt and tools here. + return appwire.CreateAsk{Model: workstreamValue(p.Model), CreatedBy: "workstream"} } -func (b *appBackend) WorkstreamPageCreate(appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { - return appwire.WorkstreamPageCreateResult{}, appwire.ErrWorkstreamsNotYet +// workstreamPreflight checks store-owned refusals without writing or migrating. +// The final Create transaction repeats ownership and naming checks for peers. +func workstreamPreflight(s *workspace.Store, p appwire.WorkstreamCreateParams) error { + ctx := context.Background() + return s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + for _, r := range p.Refs { + if r.Kind == appwire.RefCollection { + return workspace.ErrNotFound + } + } + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT name FROM collections") + if err != nil { + return err + } + taken := false + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return err + } + taken = taken || strings.EqualFold(name, strings.TrimSpace(p.Name)) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + if taken { + return workspace.ErrNameTaken + } + for _, r := range p.Refs { + if r.Kind == appwire.RefCollection { + var exists bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM collections WHERE id=?)", r.ID).Scan(&exists); err != nil { + return err + } + if !exists { + return workspace.ErrNotFound + } + } + if version >= 3 && r.Kind == appwire.RefFactoryJob { + var held workspace.WorkstreamTaken + held.Factory = workstreamValue(r.Factory) + held.Job, _ = strconv.Atoi(r.ID) + err := tx.QueryRowContext(ctx, `SELECT j.workstream_id,c.name FROM workstream_jobs j JOIN collections c ON c.id=j.workstream_id WHERE j.factory=? AND j.job=?`, held.Factory, held.Job).Scan(&held.WorkstreamID, &held.WorkstreamName) + if err == nil { + return &held + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + } + } + return nil + }) } -func (b *appBackend) WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams) (appwire.WorkstreamPageUpdateResult, error) { - return appwire.WorkstreamPageUpdateResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire.WorkstreamCreateResult, error) { + var zero appwire.WorkstreamCreateResult + if err := p.Check(); err != nil { + return zero, err + } + b.workstreams.createMu.Lock() + defer b.workstreams.createMu.Unlock() + // A retry is recognized before origin lookup or agent creation. The domain + // checks client_id again inside its transaction to settle competing peers. + existing, err := b.workstreamStore(false) + if err != nil { + return zero, err + } + if existing != nil && workstreamValue(p.ClientID) != "" { + w, err := existing.ByClientID(context.Background(), *p.ClientID) + if err == nil { + return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil + } + if !errors.Is(err, workstream.ErrNotFound) { + return zero, appWorkstreamError(err) + } + } + if p.OriginChatID != nil { + if _, err := b.find(*p.OriginChatID); err != nil { + return zero, err + } + } + store, err := b.collectionStore(false) + if err != nil { + return zero, err + } + if store != nil { + if err := workstreamPreflight(store, p); err != nil { + return zero, appWorkstreamError(err) + } + } else { + for _, ref := range p.Refs { + if ref.Kind == appwire.RefCollection { + return zero, appCollectionError(workspace.ErrNotFound) + } + } + } + create := b.workstreams.createAgent + if create == nil { + create = b.CreateAgent + } + agent, err := create(workstreamAgentAsk(p)) + if err != nil { + return zero, err + } + refs := make([]workspace.Ref, 0, len(p.Refs)) + for _, r := range p.Refs { + refs = append(refs, workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: workstreamValue(r.SessionID), Factory: workstreamValue(r.Factory)}) + } + var w workstream.Workstream + var change workstream.Change + store, err = b.collectionStore(true) + if err == nil { + s := workstream.New(store, workstream.Options{Now: b.now}) + w, change, err = s.Create(context.Background(), workstream.Create{Name: strings.TrimSpace(p.Name), Why: workstreamValue(p.Why), OriginChatID: workstreamValue(p.OriginChatID), Refs: refs, AgentID: agent.ID, ClientID: workstreamValue(p.ClientID)}) + } + if err != nil || w.AgentID != agent.ID { + closeAgent := b.workstreams.closeAgent + if closeAgent == nil { + closeAgent = func(id string) error { return b.MoveToClosed([]string{id}) } + } + if cleanup := closeAgent(agent.ID); cleanup != nil { + if err != nil { + mapped := appWorkstreamError(err) + var wire *appwire.Error + if errors.As(mapped, &wire) { + copy := *wire + copy.Message += "; the unused workstream agent could not be closed" + return zero, © + } + } + return zero, appwire.Errorf(appwire.CodeEngine, "the unused workstream agent could not be closed: %s", appUnaskedReason("the conversation", cleanup)) + } + } + if err != nil { + return zero, appWorkstreamError(err) + } + b.workstreamChanged(change) + if change.ID != "" { + b.collectionChanged(w.ID, false) + } + return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil +} + +func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamUpdateResult, workstream.Change, error) { + patch := workstream.Patch{Name: p.Patch.Name, Why: p.Patch.Why, Autonomy: p.Patch.Autonomy, BudgetUSD: p.Patch.BudgetUSD} + if p.Patch.Wake != nil { + patch.Wake = &workstream.Wake{Morning: p.Patch.Wake.Morning, RelatedIssues: p.Patch.Wake.RelatedIssues} + } + w, c, err := s.Update(context.Background(), p.ID, patch) + if err != nil { + return appwire.WorkstreamUpdateResult{}, c, err + } + if c.ID != "" && p.Patch.Name != nil { + b.collectionChanged(w.ID, false) + } + facts := b.workstreamFacts(w.Jobs()) + w, c, err = b.workstreamObserved(s, w, facts, c) + return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err + }) +} + +func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (appwire.WorkstreamMarkDoneResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamMarkDoneResult, workstream.Change, error) { + w, c, err := s.MarkDone(context.Background(), p.ID, workstreamValue(p.HowItWent)) + if err != nil { + return appwire.WorkstreamMarkDoneResult{}, c, err + } + facts := b.workstreamFacts(w.Jobs()) + w, c, err = b.workstreamObserved(s, w, facts, c) + // The workstream is already durable even if archiving a chat fails. Publish + // that receipt first; retrying mark_done can finish the archive without a + // second workstream mutation or notification. + b.workstreamChanged(c) + if err != nil { + return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err + } + if workstreamValue(p.ArchiveChats) { + ids := []string{} + for _, r := range w.Members { + if r.Kind == workspace.ConversationKind && r.ID != w.AgentID { + ids = append(ids, r.ID) + } + } + archive := b.workstreams.archiveChats + if archive == nil { + archive = b.Archive + } + if len(ids) > 0 { + err = archive(ids) + } + } + if err != nil { + return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err + } + return appwire.WorkstreamMarkDoneResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, workstream.Change{}, nil + }) +} + +func (b *appBackend) WorkstreamReopen(p appwire.WorkstreamReopenParams) (appwire.WorkstreamReopenResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamReopenResult, workstream.Change, error) { + w, c, err := s.Reopen(context.Background(), p.ID) + if err != nil { + return appwire.WorkstreamReopenResult{}, c, err + } + facts := b.workstreamFacts(w.Jobs()) + w, c, err = b.workstreamObserved(s, w, facts, c) + return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err + }) +} + +func appWorkstreamPlan(p workstream.Plan) appwire.WorkstreamPlan { + out := appwire.WorkstreamPlan{WorkstreamID: p.WorkstreamID, Version: p.Version, UpdatedAt: p.UpdatedAt, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}} + for _, m := range p.Milestones { + out.Milestones = append(out.Milestones, appwire.WorkstreamMilestone{ID: m.ID, Title: m.Title}) + } + for _, w := range p.Waves { + wave := appwire.WorkstreamWave{N: w.N, Title: workstreamOptional(w.Title), MilestoneID: workstreamOptional(w.MilestoneID), AutoStart: w.AutoStart, Items: []appwire.WorkstreamPlanItem{}} + for _, i := range w.Items { + wave.Items = append(wave.Items, appwire.WorkstreamPlanItem{Kind: i.Kind, Factory: workstreamOptional(i.Factory), Job: workstreamPositive(i.Job), DraftID: workstreamOptional(i.DraftID), Title: workstreamOptional(i.Title), Repo: workstreamOptional(i.Repo), Text: workstreamOptional(i.Text)}) + } + out.Waves = append(out.Waves, wave) + } + return out +} +func (b *appBackend) WorkstreamPlanGet(p appwire.WorkstreamPlanGetParams) (appwire.WorkstreamPlanGetResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPlanGetResult, workstream.Change, error) { + v, err := s.Plan(context.Background(), p.ID) + return appwire.WorkstreamPlanGetResult{Plan: appWorkstreamPlan(v)}, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamPlanSet(p appwire.WorkstreamPlanSetParams) (appwire.WorkstreamPlanSetResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPlanSetResult, workstream.Change, error) { + milestones := make([]workstream.Milestone, 0, len(p.Milestones)) + waves := make([]workstream.Wave, 0, len(p.Waves)) + for _, m := range p.Milestones { + milestones = append(milestones, workstream.Milestone{ID: m.ID, Title: m.Title}) + } + for _, w := range p.Waves { + wave := workstream.Wave{N: w.N, Title: workstreamValue(w.Title), MilestoneID: workstreamValue(w.MilestoneID), AutoStart: w.AutoStart, Items: []workstream.PlanItem{}} + for _, i := range w.Items { + wave.Items = append(wave.Items, workstream.PlanItem{Kind: i.Kind, Factory: workstreamValue(i.Factory), Job: workstreamValue(i.Job), DraftID: workstreamValue(i.DraftID), Title: workstreamValue(i.Title), Repo: workstreamValue(i.Repo), Text: workstreamValue(i.Text)}) + } + waves = append(waves, wave) + } + v, c, err := s.SetPlan(context.Background(), p.ID, p.BaseVersion, milestones, waves, workstream.Attribution{}) + return appwire.WorkstreamPlanSetResult{Plan: appWorkstreamPlan(v)}, c, err + }) +} + +func appWorkstreamSource(p *workstream.Source) *appwire.WorkstreamSource { + if p == nil { + return nil + } + return &appwire.WorkstreamSource{Kind: p.Kind, ID: p.ID, ChatID: workstreamOptional(p.ChatID), Factory: workstreamOptional(p.Factory), Label: workstreamOptional(p.Label)} +} +func workstreamSource(p *appwire.WorkstreamSource) *workstream.Source { + if p == nil { + return nil + } + return &workstream.Source{Kind: p.Kind, ID: p.ID, ChatID: workstreamValue(p.ChatID), Factory: workstreamValue(p.Factory), Label: workstreamValue(p.Label)} +} +func appWorkstreamPageNode(p workstream.PageNode) appwire.WorkstreamPageNode { + return appwire.WorkstreamPageNode{ID: p.ID, WorkstreamID: p.WorkstreamID, ParentID: workstreamOptional(p.ParentID), Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs, Position: p.Position, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, UpdatedFrom: appWorkstreamSource(p.UpdatedFrom)} +} +func appWorkstreamPage(p workstream.Page) appwire.WorkstreamPage { + n := appWorkstreamPageNode(p.PageNode) + return appwire.WorkstreamPage{ID: n.ID, WorkstreamID: n.WorkstreamID, ParentID: n.ParentID, Title: n.Title, Owner: n.Owner, ReadByJobs: n.ReadByJobs, Position: n.Position, Version: n.Version, UpdatedAt: n.UpdatedAt, UpdatedBy: n.UpdatedBy, UpdatedFrom: n.UpdatedFrom, Doc: p.Doc, Markdown: p.Markdown} +} +func (b *appBackend) WorkstreamPageList(p appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageListResult, workstream.Change, error) { + pages, err := s.Pages(context.Background(), p.WorkstreamID) + out := appwire.WorkstreamPageListResult{Pages: []appwire.WorkstreamPageNode{}} + for _, page := range pages { + out.Pages = append(out.Pages, appWorkstreamPageNode(page)) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamPageGet(p appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageGetResult, workstream.Change, error) { + v, err := s.Page(context.Background(), p.ID, workstreamValue(p.Markdown)) + return appwire.WorkstreamPageGetResult{Page: appWorkstreamPage(v)}, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamPageSave(p appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageSaveResult, workstream.Change, error) { + v, c, err := s.SavePage(context.Background(), p.ID, p.BaseVersion, p.Doc, workstream.Attribution{}) + return appwire.WorkstreamPageSaveResult{Page: appWorkstreamPage(v)}, c, err + }) +} +func (b *appBackend) WorkstreamPageCreate(p appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageCreateResult, workstream.Change, error) { + v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: workstreamValue(p.ReadByJobs), Doc: p.Doc}) + return appwire.WorkstreamPageCreateResult{Page: appWorkstreamPage(v)}, c, err + }) +} +func (b *appBackend) WorkstreamPageUpdate(p appwire.WorkstreamPageUpdateParams) (appwire.WorkstreamPageUpdateResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageUpdateResult, workstream.Change, error) { + v, c, err := s.UpdatePage(context.Background(), p.ID, workstream.PagePatch{Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs}) + return appwire.WorkstreamPageUpdateResult{Page: appWorkstreamPageNode(v)}, c, err + }) +} +func (b *appBackend) WorkstreamPageMove(p appwire.WorkstreamPageMoveParams) (appwire.WorkstreamPageMoveResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageMoveResult, workstream.Change, error) { + v, c, err := s.MovePage(context.Background(), p.ID, workstreamValue(p.ParentID), workstreamValue(p.AfterID)) + return appwire.WorkstreamPageMoveResult{Page: appWorkstreamPageNode(v)}, c, err + }) +} +func (b *appBackend) WorkstreamPageDelete(p appwire.WorkstreamPageDeleteParams) (appwire.WorkstreamPageDeleteResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageDeleteResult, workstream.Change, error) { + v, c, err := s.DeletePage(context.Background(), p.ID) + return appwire.WorkstreamPageDeleteResult{Deleted: v}, c, err + }) +} +func (b *appBackend) WorkstreamPageHistory(p appwire.WorkstreamPageHistoryParams) (appwire.WorkstreamPageHistoryResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageHistoryResult, workstream.Change, error) { + limit := 50 + if p.Limit != nil { + limit = *p.Limit + } + versions, err := s.PageHistory(context.Background(), p.ID, limit) + out := appwire.WorkstreamPageHistoryResult{Versions: []appwire.WorkstreamPageVersion{}} + for _, v := range versions { + out.Versions = append(out.Versions, appwire.WorkstreamPageVersion{Version: v.Version, SavedAt: v.SavedAt, SavedBy: v.SavedBy, From: appWorkstreamSource(v.From), Title: v.Title, Bytes: v.Bytes}) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamPageRestore(p appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageRestoreResult, workstream.Change, error) { + v, c, err := s.RestorePage(context.Background(), p.ID, p.Version, p.BaseVersion, workstream.Attribution{}) + return appwire.WorkstreamPageRestoreResult{Page: appWorkstreamPage(v)}, c, err + }) +} + +func appWorkstreamRecord(r workstream.Record) appwire.WorkstreamRecord { + return appwire.WorkstreamRecord{ID: r.ID, WorkstreamID: r.WorkstreamID, Kind: r.Kind, Text: r.Text, State: r.State, By: r.By, Source: appWorkstreamSource(r.Source), CreatedAt: r.CreatedAt, DecidedAt: r.DecidedAt, Supersedes: workstreamOptional(r.Supersedes), SupersededBy: workstreamOptional(r.SupersededBy)} +} +func (b *appBackend) WorkstreamRecordList(p appwire.WorkstreamRecordListParams) (appwire.WorkstreamRecordListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordListResult, workstream.Change, error) { + records, err := s.Records(context.Background(), p.WorkstreamID, workstreamValue(p.Kind), workstreamValue(p.State)) + out := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} + for _, r := range records { + out.Records = append(out.Records, appWorkstreamRecord(r)) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamRecordPropose(p appwire.WorkstreamRecordProposeParams) (appwire.WorkstreamRecordProposeResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordProposeResult, workstream.Change, error) { + v, c, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: p.WorkstreamID, Kind: p.Kind, Text: p.Text, Source: workstreamSource(p.Source), Kept: workstreamValue(p.Kept)}) + return appwire.WorkstreamRecordProposeResult{Record: appWorkstreamRecord(v)}, c, err + }) +} +func (b *appBackend) WorkstreamRecordKeep(p appwire.WorkstreamRecordKeepParams) (appwire.WorkstreamRecordKeepResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordKeepResult, workstream.Change, error) { + v, c, err := s.KeepRecord(context.Background(), p.ID) + return appwire.WorkstreamRecordKeepResult{Record: appWorkstreamRecord(v)}, c, err + }) +} +func (b *appBackend) WorkstreamRecordDrop(p appwire.WorkstreamRecordDropParams) (appwire.WorkstreamRecordDropResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordDropResult, workstream.Change, error) { + v, c, err := s.DropRecord(context.Background(), p.ID) + return appwire.WorkstreamRecordDropResult{Record: appWorkstreamRecord(v)}, c, err + }) +} +func (b *appBackend) WorkstreamRecordSupersede(p appwire.WorkstreamRecordSupersedeParams) (appwire.WorkstreamRecordSupersedeResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordSupersedeResult, workstream.Change, error) { + v, old, c, err := s.SupersedeRecord(context.Background(), p.ID, workstream.RecordReplacement{Kind: workstreamValue(p.Kind), Text: p.Text, Source: workstreamSource(p.Source)}) + return appwire.WorkstreamRecordSupersedeResult{Record: appWorkstreamRecord(v), Superseded: appWorkstreamRecord(old)}, c, err + }) +} +func appWorkstreamGoAhead(g workstream.GoAhead) appwire.WorkstreamGoAhead { + return appwire.WorkstreamGoAhead{ID: g.ID, WorkstreamID: g.WorkstreamID, Scope: g.Scope, Factory: workstreamOptional(g.Factory), Job: workstreamPositive(g.Job), Wave: workstreamPositive(g.Wave), MilestoneID: workstreamOptional(g.MilestoneID), CapUSD: g.CapUSD, EndsAt: g.EndsAt, State: g.State, EndReason: workstreamOptional(g.EndReason), GrantedAt: g.GrantedAt, EndedAt: g.EndedAt} +} +func (b *appBackend) WorkstreamGoAheadList(p appwire.WorkstreamGoAheadListParams) (appwire.WorkstreamGoAheadListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadListResult, workstream.Change, error) { + grants, err := s.GoAheads(context.Background(), p.WorkstreamID, workstreamValue(p.Ended)) + out := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} + for _, g := range grants { + out.GoAheads = append(out.GoAheads, appWorkstreamGoAhead(g)) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamGoAheadGrant(p appwire.WorkstreamGoAheadGrantParams) (appwire.WorkstreamGoAheadGrantResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadGrantResult, workstream.Change, error) { + v, c, err := s.GrantGoAhead(context.Background(), workstream.GoAheadGrant{WorkstreamID: p.WorkstreamID, Scope: p.Scope, Factory: workstreamValue(p.Factory), Job: workstreamValue(p.Job), Wave: workstreamValue(p.Wave), MilestoneID: workstreamValue(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt}) + return appwire.WorkstreamGoAheadGrantResult{GoAhead: appWorkstreamGoAhead(v)}, c, err + }) +} +func (b *appBackend) WorkstreamGoAheadRevoke(p appwire.WorkstreamGoAheadRevokeParams) (appwire.WorkstreamGoAheadRevokeResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadRevokeResult, workstream.Change, error) { + v, c, err := s.RevokeGoAhead(context.Background(), p.ID) + return appwire.WorkstreamGoAheadRevokeResult{GoAhead: appWorkstreamGoAhead(v)}, c, err + }) } -func (b *appBackend) WorkstreamPageMove(appwire.WorkstreamPageMoveParams) (appwire.WorkstreamPageMoveResult, error) { - return appwire.WorkstreamPageMoveResult{}, appwire.ErrWorkstreamsNotYet +// workstreamQueue shares successful and failed reads briefly, including an +// in-flight read, so concurrent list/get calls cannot multiply Factory work. +func (b *appBackend) workstreamQueue(ctx context.Context, factory string) (entry workstreamQueueRead) { + state := &b.workstreams + entry, flight, readQueue := state.workstreamQueueStart(factory, b.now()) + if flight == nil { + return entry + } + if !readQueue { + select { + case <-flight: + return b.workstreamQueue(ctx, factory) + case <-ctx.Done(): + return workstreamQueueRead{err: ctx.Err()} + } + } + // Complete ownership even when the guarded reader panics. Only a completed + // read replaces the unavailable entry, so a fault cannot publish success. + entry = workstreamQueueRead{err: errors.New("the Factory queue read did not complete")} + defer func() { + entry.at = b.now() + state.workstreamQueueFinish(factory, flight, entry) + }() + read := state.queue + if read == nil { + read = b.FactoryQueue + } + queue, err := read(ctx, factory) + jobs := map[int]api.Job{} + for _, j := range queue.Jobs { + jobs[j.ID] = j + } + entry = workstreamQueueRead{err: err, jobs: jobs} + return entry } -func (b *appBackend) WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams) (appwire.WorkstreamPageDeleteResult, error) { - return appwire.WorkstreamPageDeleteResult{}, appwire.ErrWorkstreamsNotYet +func (state *appWorkstreams) workstreamQueueStart(factory string, now time.Time) (workstreamQueueRead, chan struct{}, bool) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if entry, ok := state.cache[factory]; ok && now.Sub(entry.at) < workstreamFactoryFresh { + return entry, nil, false + } + if flight := state.flights[factory]; flight != nil { + return workstreamQueueRead{}, flight, false + } + if state.flights == nil { + state.flights = map[string]chan struct{}{} + } + flight := make(chan struct{}) + state.flights[factory] = flight + return workstreamQueueRead{}, flight, true } -func (b *appBackend) WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams) (appwire.WorkstreamPageHistoryResult, error) { - return appwire.WorkstreamPageHistoryResult{}, appwire.ErrWorkstreamsNotYet +func (state *appWorkstreams) workstreamQueueFinish(factory string, flight chan struct{}, entry workstreamQueueRead) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if state.cache == nil { + state.cache = map[string]workstreamQueueRead{} + } + if len(state.cache) >= workstreamFactoryCacheMax { + oldest := "" + var at time.Time + for id, v := range state.cache { + if oldest == "" || v.at.Before(at) { + oldest, at = id, v.at + } + } + delete(state.cache, oldest) + } + state.cache[factory] = entry + delete(state.flights, factory) + close(flight) } -func (b *appBackend) WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) { - return appwire.WorkstreamPageRestoreResult{}, appwire.ErrWorkstreamsNotYet +type workstreamJobRead struct { + job api.Job + err error + notFound bool } -func (b *appBackend) WorkstreamRecordList(appwire.WorkstreamRecordListParams) (appwire.WorkstreamRecordListResult, error) { - return appwire.WorkstreamRecordListResult{}, appwire.ErrWorkstreamsNotYet +// workstreamJobReads gives each factory one concurrent queue read. Missing +// jobs share a global fallback allowance and the same overall deadline. +func (b *appBackend) workstreamJobReads(jobs []workstream.Job) map[workstream.Job]workstreamJobRead { + out := map[workstream.Job]workstreamJobRead{} + grouped := map[string][]workstream.Job{} + for _, j := range jobs { + if _, seen := out[j]; !seen { + out[j] = workstreamJobRead{err: context.DeadlineExceeded} + grouped[j.Factory] = append(grouped[j.Factory], j) + } + } + if len(grouped) == 0 { + return out + } + ctx, cancel := context.WithTimeout(context.Background(), workstreamFactoryTimeout) + defer cancel() + var wg sync.WaitGroup + var mu sync.Mutex + missing := 0 + workstreamMissingAllowed := func() bool { + mu.Lock() + defer mu.Unlock() + if missing >= workstreamMissingJobsMax { + return false + } + missing++ + return true + } + workstreamRememberRead := func(j workstream.Job, read workstreamJobRead) { + mu.Lock() + defer mu.Unlock() + out[j] = read + } + slots := make(chan struct{}, workstreamFactoryParallel) + for factory, members := range grouped { + wg.Add(1) + guard.Go("app/workstream-factory", func() { + defer wg.Done() + select { + case slots <- struct{}{}: + defer func() { <-slots }() + case <-ctx.Done(): + return + } + queue := b.workstreamQueue(ctx, factory) + for _, j := range members { + job, ok := queue.jobs[j.Number] + err := queue.err + if err == nil && !ok { + allowed := workstreamMissingAllowed() + switch { + case !allowed: + err = errors.New("the missing-job read allowance was reached") + case ctx.Err() != nil: + err = ctx.Err() + default: + read := b.workstreams.job + if read == nil { + read = b.FactoryJob + } + job, err = read(ctx, j.Factory, j.Number) + if err == nil && job.ID != j.Number { + err = errors.New("the Factory returned another job") + } + } + } + var wire *appwire.Error + notFound := queue.err == nil && errors.As(err, &wire) && wire.Code == appwire.CodeNotFound + workstreamRememberRead(j, workstreamJobRead{job: job, err: err, notFound: notFound}) + } + }) + } + wg.Wait() + return out } -func (b *appBackend) WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams) (appwire.WorkstreamRecordProposeResult, error) { - return appwire.WorkstreamRecordProposeResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) workstreamFacts(jobs []workstream.Job) map[workstream.Job]workstream.JobFacts { + out := map[workstream.Job]workstream.JobFacts{} + for j, read := range b.workstreamJobReads(jobs) { + if read.err != nil { + continue + } + job := read.job + merged := job.PR != nil && job.PR.MergedAt != nil && !job.PR.MergedAt.IsZero() + out[j] = workstream.JobFacts{State: job.State, Merged: &merged, CostUSD: &job.Cost} + } + return out } -func (b *appBackend) WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams) (appwire.WorkstreamRecordKeepResult, error) { - return appwire.WorkstreamRecordKeepResult{}, appwire.ErrWorkstreamsNotYet +// Collection resolution uses the same bounded batch reader, with each job +// asked once even when it appears through several nested collections. +func (r *collectionResolver) prepareWorkstreamRefs(members []workspace.Member) { + jobs := []workstream.Job{} + refs := map[workstream.Job]workspace.Ref{} + for _, member := range members { + if member.Kind != workspace.FactoryJobKind { + continue + } + number, _ := strconv.Atoi(member.ID) + job := workstream.Job{Factory: member.Factory, Number: number} + jobs = append(jobs, job) + refs[job] = member.Ref + } + r.factoryTargets = map[workspace.Ref]appwire.Target{} + for job, read := range r.b.workstreamJobReads(jobs) { + var target appwire.Target + if read.err != nil { + if read.notFound { + target = appwire.Target{State: appwire.TargetDeleted} + } else { + target = appUnknown("the Factory", read.err) + target.Status = "unknown" + } + } else { + target = appwire.Target{State: appwire.TargetLive, Title: read.job.Title, Status: read.job.State, Repo: read.job.Repo} + } + r.factoryTargets[refs[job]] = target + } } -func (b *appBackend) WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams) (appwire.WorkstreamRecordDropResult, error) { - return appwire.WorkstreamRecordDropResult{}, appwire.ErrWorkstreamsNotYet +func (r *collectionResolver) prepareWorkstreamTree(level workspace.Level) { + members := []workspace.Member{} + var collect func(workspace.Level) + collect = func(level workspace.Level) { + for _, node := range level.Members { + if node.Kind == workspace.FactoryJobKind { + members = append(members, node.Member) + } + if node.Children != nil { + collect(*node.Children) + } + } + } + collect(level) + r.prepareWorkstreamRefs(members) } -func (b *appBackend) WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams) (appwire.WorkstreamRecordSupersedeResult, error) { - return appwire.WorkstreamRecordSupersedeResult{}, appwire.ErrWorkstreamsNotYet +// workstreamObserved adds the first observed start to the caller's +// receipt, so mutations announce their write and observation in one event. +func (b *appBackend) workstreamObserved(s *workstream.Store, w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts, c workstream.Change) (workstream.Workstream, workstream.Change, error) { + if !w.StartedAt.IsZero() { + return w, c, nil + } + states := map[workstream.Job]string{} + active := false + for _, j := range w.Jobs() { + states[j] = facts[j].State + switch states[j] { + case "queued", "running", "needs_you", "done": + active = true + } + } + if !active { + return w, c, nil + } + observed, err := s.ObserveJobStates(context.Background(), w.ID, states) + if err != nil { + return w, c, appWorkstreamError(err) + } + if c.ID == "" { + c = observed + } else { + for _, part := range observed.Parts { + if !slices.Contains(c.Parts, part) { + c.Parts = append(c.Parts, part) + } + } + } + // Another reader may have won the write, so reread even with an empty receipt. + w, err = s.Get(context.Background(), w.ID) + return w, c, appWorkstreamError(err) } -func (b *appBackend) WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams) (appwire.WorkstreamGoAheadListResult, error) { - return appwire.WorkstreamGoAheadListResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) workstreamSpend(id string) *float64 { + if id == "" { + return nil + } + if read := b.workstreams.spend; read != nil { + return read(id) + } + a, err := b.find(id) + if err != nil { + row, ok := workstreamAgentRow(id) + if !ok { + return nil + } + b.remember(row) + a, err = b.find(id) + if err != nil { + return nil + } + } + var transcript string + b.locked(func() { transcript = a.transcript }) + if transcript == "" { + return nil + } + if row, ok := session.ReadRows([]string{transcript})[transcript]; ok { + if meta, err := session.LoadMeta(row.Dir); err != nil || meta.ID == "" { + return nil + } + return &row.Spend + } + // The ordinary chat listing deliberately omits bare conversations. Their + // freshly recorded metadata still establishes known zero spend here. + meta, err := session.LoadMeta(session.DirOf(transcript)) + if err == nil && meta.ID != "" && meta.LastUserAt.IsZero() && meta.SpentUSD == 0 { + zero := 0.0 + return &zero + } + return nil } -func (b *appBackend) WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams) (appwire.WorkstreamGoAheadGrantResult, error) { - return appwire.WorkstreamGoAheadGrantResult{}, appwire.ErrWorkstreamsNotYet +// workstreamKeepsConversation protects a deliberately bare agent from both +// app and terminal blank-chat cleanup. Its durable owner is the SQLite row; +// an unreadable owner is a reason to retain a conversation, never to delete it. +func workstreamKeepsConversation(dir string) bool { + path := home.Join("v3", "collections.db") + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return false + } else if err != nil { + return true + } + s, err := workspace.Open(path) + if err != nil { + return true + } + defer s.Close() + kept := false + err = s.Read(context.Background(), func(tx *sql.Tx, version int) error { + if version < 3 { + return nil + } + return tx.QueryRow("SELECT EXISTS(SELECT 1 FROM workstreams WHERE agent_id=?)", filepath.Base(filepath.Clean(dir))).Scan(&kept) + }) + return kept || err != nil } -func (b *appBackend) WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams) (appwire.WorkstreamGoAheadRevokeResult, error) { - return appwire.WorkstreamGoAheadRevokeResult{}, appwire.ErrWorkstreamsNotYet +// workstreamAgentRow restores a bare agent omitted by the ordinary chat +// listing. Workstream reads already obtained its id from the durable owner; +// remembering it lets agents.open use the existing opener after a restart. +func workstreamAgentRow(id string) (session.SessionRow, bool) { + if id == "" || id != filepath.Base(id) || strings.HasPrefix(id, ".") { + return session.SessionRow{}, false + } + root := session.PlacesRoot() + buckets, err := os.ReadDir(root) + if err != nil { + return session.SessionRow{}, false + } + for _, bucket := range buckets { + if !bucket.IsDir() { + continue + } + dir := filepath.Join(root, bucket.Name(), id) + meta, err := session.LoadMeta(dir) + if err != nil || meta.ID != id || !meta.LastUserAt.IsZero() { + continue + } + transcript := (session.Place{Dir: dir}).Transcript() + if _, err := os.Stat(transcript); err != nil { + continue + } + return session.SessionRow{ID: id, Dir: dir, Transcript: transcript, Workspace: meta.Workspace, Owned: meta.Owned, Title: meta.Title, Model: meta.Model, Created: meta.Created, At: meta.Created, Spend: meta.SpentUSD, Archived: meta.Archived}, true + } + return session.SessionRow{}, false } diff --git a/cmd/codeaf/appworkstreams_test.go b/cmd/codeaf/appworkstreams_test.go index 94da8f9508..7351dc9d41 100644 --- a/cmd/codeaf/appworkstreams_test.go +++ b/cmd/codeaf/appworkstreams_test.go @@ -1,142 +1,1311 @@ package main import ( + "bytes" + "context" + "encoding/json" "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" ) -// V4: the real backend implements every contract stub without touching its fields. -func TestAppWorkstreamsAreNotYetServed(t *testing.T) { - var backend appwire.Workstreamer = &appBackend{} - if backend.ServesWorkstreams() { - t.Fatal("the stub backend claims workstreams") +func workstreamPtr[T any](value T) *T { return &value } + +func workstreamBackend(t *testing.T) (*appBackend, *appRecorder, *int, appwire.Agent) { + t.Helper() + b, rec, origin, _ := collectionsBackend(t) + calls := new(int) + b.workstreams.createAgent = func(ask appwire.CreateAsk) (appwire.Agent, error) { + *calls++ + if ask.Text != "" || ask.Workspace != "" || ask.CreatedBy != "workstream" || len(ask.Attachments) != 0 { + t.Errorf("agent must be a bare default-workspace open: %+v", ask) + } + return appwire.Agent{ID: fmt.Sprintf("workstream-agent-%d", *calls)}, nil } - calls := []struct { - name string - call func() error + b.workstreams.spend = func(string) *float64 { return workstreamPtr(0.0) } + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{}, appwire.Errorf(appwire.CodeNotFound, "no Factory attached") + } + return b, rec, calls, origin +} + +func workstreamCreateForTest(t *testing.T, b *appBackend, name string, refs ...appwire.WorkstreamRef) appwire.Workstream { + t.Helper() + v, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: name, Refs: refs}) + if err != nil { + t.Fatal(err) + } + return v.Workstream +} +func workstreamJobRef(number string) appwire.WorkstreamRef { + return appwire.WorkstreamRef{Kind: appwire.RefFactoryJob, ID: number, Factory: workstreamPtr("f")} +} +func workstreamEvents(rec *appRecorder) []appwire.WorkstreamChanged { + rec.mu.Lock() + defer rec.mu.Unlock() + out := []appwire.WorkstreamChanged{} + for _, data := range rec.data { + if event, ok := data.(appwire.WorkstreamChanged); ok { + out = append(out, event) + } + } + return out +} +func workstreamCheckError(t *testing.T, err error, code string) *appwire.Error { + t.Helper() + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != code { + t.Fatalf("got %v, want %s", err, code) + } + return wire +} + +// (c): the public create/list/page/collection interfaces expose one identity, +// one Overview and one agent, including simultaneous retries with one client id. +func TestAppWorkstreamCreateIsIdempotent(t *testing.T) { + b, rec, calls, origin := workstreamBackend(t) + ask := appwire.WorkstreamCreateParams{Name: "Launch", OriginChatID: &origin.ID, ClientID: workstreamPtr("create-one"), Model: workstreamPtr("test-model")} + originalCreate := b.workstreams.createAgent + b.workstreams.createAgent = func(p appwire.CreateAsk) (appwire.Agent, error) { + if p.Model != "test-model" { + t.Errorf("model = %q", p.Model) + } + return originalCreate(p) + } + var wg sync.WaitGroup + results := make(chan appwire.WorkstreamCreateResult, 8) + errs := make(chan error, 8) + for range 8 { + wg.Add(1) + guard.Go("app/workstream-create-test", func() { + defer wg.Done() + v, err := b.WorkstreamCreate(ask) + results <- v + errs <- err + }) + } + wg.Wait() + close(results) + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + var id, agentID string + for v := range results { + if id == "" { + id, agentID = v.Workstream.ID, v.Workstream.AgentID + } + if v.Workstream.ID != id || v.Workstream.AgentID != agentID || v.Workstream.Stage != "shaping" { + t.Fatalf("create: %+v", v) + } + } + if *calls != 1 { + t.Fatalf("created %d agents", *calls) + } + rows, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil || len(rows.Workstreams) != 1 { + t.Fatalf("rows %+v, %v", rows, err) + } + collections, err := b.ListCollections(nil, "", 200) + if err != nil || len(collections.Collections) != 1 || collections.Collections[0].ID != id || !collections.Collections[0].Workstream { + t.Fatalf("collections %+v, %v", collections, err) + } + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: id}) + if err != nil || len(pages.Pages) != 1 || pages.Pages[0].Title != "Overview" || pages.Pages[0].Version != 1 { + t.Fatalf("pages %+v, %v", pages, err) + } + opened, err := b.OpenCollection(id, 1, 200, "") + if err != nil || len(opened.Members) != 2 { + t.Fatalf("members %+v, %v", opened, err) + } + ids := []string{} + for _, node := range opened.Members { + ids = append(ids, node.ID) + } + if !slices.Contains(ids, origin.ID) || !slices.Contains(ids, agentID) { + t.Fatalf("chat members: %v", ids) + } + if len(workstreamEvents(rec)) != 1 { + t.Fatalf("retry emitted: %+v", workstreamEvents(rec)) + } +} + +// (b): both doors refuse the whole request and retain both timestamps and all +// memberships. A plain collection remains free to reference the same job. +func TestAppWorkstreamOwnershipRefusal(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + a := workstreamCreateForTest(t, b, "Owner", workstreamJobRef("7")) + other := workstreamCreateForTest(t, b, "Other") + beforeEvents := len(workstreamEvents(rec)) + beforeCalls := *calls + refs := []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefConversation, ID: "extra"}}, {Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}} + _, err := b.AddToCollection(other.ID, refs) + wire := workstreamCheckError(t, err, appwire.CodeInOtherWorkstream) + want := appwire.WorkstreamTaken{Factory: "f", Job: 7, WorkstreamID: a.ID, WorkstreamName: a.Name} + if !reflect.DeepEqual(wire.Detail, want) { + t.Fatalf("detail %+v", wire.Detail) + } + _, err = b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Refused", Refs: []appwire.WorkstreamRef{{Kind: appwire.RefConversation, ID: "extra"}, workstreamJobRef("7")}}) + wire = workstreamCheckError(t, err, appwire.CodeInOtherWorkstream) + if !reflect.DeepEqual(wire.Detail, want) || *calls != beforeCalls { + t.Fatalf("preflight: %+v, %d agents", wire, *calls) + } + for _, before := range []appwire.Workstream{a, other} { + after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: before.ID}) + if err != nil || after.Workstream.UpdatedAt != before.UpdatedAt || after.Workstream.Total != before.Total { + t.Fatalf("refusal changed %+v: %+v, %v", before, after, err) + } + } + opened, err := b.OpenCollection(other.ID, 1, 200, "") + if err != nil || len(opened.Members) != 1 { + t.Fatalf("partial membership: %+v, %v", opened, err) + } + if len(workstreamEvents(rec)) != beforeEvents { + t.Fatal("refusal emitted a workstream event") + } + plain, err := b.DuplicateCollection(a.ID, "Plain copy") + if err != nil || plain.Workstream { + t.Fatalf("duplicate %+v, %v", plain, err) + } + if _, err := b.AddToCollection(plain.ID, refs); err != nil { + t.Fatal(err) + } + wire = workstreamCheckError(t, b.DeleteCollection(a.ID), appwire.CodeBadParams) + if wire.Message != "mark a workstream done instead" { + t.Fatal(wire) + } +} + +// (d), (j): job state comes from recorded Factory data; an observed transition +// is persisted and announced once. Issue numbers cannot alias document ids. +func TestAppWorkstreamStageAndMeasuredNumbers(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + state := "backlog" + reads := 0 + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + reads++ + job := api.Job{ID: 7, Num: 1107, State: state, Cost: 0, Title: "Ship", Repo: "acme/api"} + if state == "done" { + at := time.Now() + job.PR = &api.PR{MergedAt: &at} + } + return api.Queue{Jobs: []api.Job{job}}, nil + } + w := workstreamCreateForTest(t, b, "Stages", workstreamJobRef("7")) + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Stage != "shaping" || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 0 { + t.Fatalf("backlog %+v, %v", got, err) + } + encoded, err := json.Marshal(got) + if err != nil || !bytes.Contains(encoded, []byte(`"spend_usd":0`)) || !bytes.Contains(encoded, []byte(`"merged":0`)) || !bytes.Contains(encoded, []byte(`"needs_you":0`)) { + t.Fatalf("measured zero omitted: %s, %v", encoded, err) + } + before := len(workstreamEvents(rec)) + state = "queued" + func() { + b.workstreams.cacheMu.Lock() + defer b.workstreams.cacheMu.Unlock() + b.workstreams.cache = nil + }() + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Stage != "building" || got.Workstream.StartedAt.IsZero() { + t.Fatalf("queued %+v, %v", got, err) + } + if len(workstreamEvents(rec)) != before+1 { + t.Fatal("first observation did not emit exactly once") + } + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || len(workstreamEvents(rec)) != before+1 || reads != 2 { + t.Fatalf("cached read: %+v, %v, reads %d", got, err, reads) + } + state = "done" + b.workstreams.cache = nil + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Stage != "building" || got.Workstream.Merged == nil || *got.Workstream.Merged != 1 { + t.Fatalf("all merged %+v, %v", got, err) + } + done, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) + if err != nil || done.Workstream.Stage != "done" { + t.Fatalf("done %+v, %v", done, err) + } + state = "backlog" + b.workstreams.cache = nil + reopened, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}) + if err != nil || reopened.Workstream.Stage != "building" { + t.Fatalf("reopen %+v, %v", reopened, err) + } +} + +// (j): mutation results retain the same measured job and agent numbers as get. +func TestAppWorkstreamMutationNumbersMatchGet(t *testing.T) { + for _, method := range []string{"update", "mark_done", "reopen", "mark_done_again"} { + t.Run(method, func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + reads := 0 + mergedAt := time.Now() + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + reads++ + return api.Queue{Jobs: []api.Job{{ID: 7, Num: 1107, State: "needs_you", Cost: 1.25, PR: &api.PR{MergedAt: &mergedAt}}}}, nil + } + b.workstreams.spend = func(string) *float64 { return workstreamPtr(2.5) } + w := workstreamCreateForTest(t, b, "Mutation numbers", workstreamJobRef("7")) + if _, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Ready?"}); err != nil { + t.Fatal(err) + } + before := w + if method == "reopen" || method == "mark_done_again" { + done, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID, HowItWent: workstreamPtr("First report")}) + if err != nil { + t.Fatal(err) + } + before = done.Workstream + } + events := len(workstreamEvents(rec)) + var result appwire.Workstream + switch method { + case "update": + v, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Updated")}}) + result = v.Workstream + if err != nil { + t.Fatal(err) + } + case "mark_done", "mark_done_again": + v, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID, HowItWent: workstreamPtr("New report")}) + result = v.Workstream + if err != nil { + t.Fatal(err) + } + case "reopen": + v, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}) + result = v.Workstream + if err != nil { + t.Fatal(err) + } + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || !reflect.DeepEqual(result, got.Workstream) { + t.Fatalf("mutation %+v differs from get %+v: %v", result, got.Workstream, err) + } + if result.Merged == nil || *result.Merged != 1 || result.NeedsYou == nil || *result.NeedsYou != 2 || result.SpendUSD == nil || *result.SpendUSD != 3.75 { + t.Fatalf("measured numbers: %+v", result) + } + if reads != 1 { + t.Fatalf("queue reads = %d, want one cached read", reads) + } + if method == "reopen" && result.Stage != "building" { + t.Fatalf("reopen stage = %q", result.Stage) + } + if method == "mark_done_again" && (!reflect.DeepEqual(result, before) || len(workstreamEvents(rec)) != events) { + t.Fatal("marking an already done workstream changed it or emitted an event") + } + }) + } +} + +// (d), (i), (j): a mutation observes the first running job before any get, +// publishes one combined receipt, and keeps that start through Factory failure. +func TestAppWorkstreamMutationPersistsFirstStart(t *testing.T) { + for _, method := range []string{"update", "mark_done", "reopen"} { + t.Run(method, func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + b.now = func() time.Time { return time.UnixMilli(clock.Load()) } + reads := 0 + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + reads++ + if reads > 1 { + return api.Queue{}, errors.New("unreachable") + } + return api.Queue{Jobs: []api.Job{{ID: 7, State: "running", Cost: 1.25}}}, nil + } + w := workstreamCreateForTest(t, b, "First start", workstreamJobRef("7")) + events := len(workstreamEvents(rec)) + var result appwire.Workstream + switch method { + case "update": + v, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Updated before get")}}) + if err != nil { + t.Fatal(err) + } + result = v.Workstream + case "mark_done": + v, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) + if err != nil { + t.Fatal(err) + } + result = v.Workstream + case "reopen": + // Reopening an already open workstream still observes its jobs. + v, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}) + if err != nil { + t.Fatal(err) + } + result = v.Workstream + } + stage := "building" + if method == "mark_done" { + stage = "done" + } + if result.Stage != stage || result.StartedAt.IsZero() || !result.UpdatedAt.After(w.UpdatedAt) { + t.Fatalf("first observation: %+v", result) + } + changed := workstreamEvents(rec)[events:] + if len(changed) != 1 || changed[0].ID != w.ID || !reflect.DeepEqual(changed[0].Parts, []string{"workstream"}) { + t.Fatalf("mutation events: %+v", changed) + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || !reflect.DeepEqual(result, got.Workstream) || len(workstreamEvents(rec)) != events+1 || reads != 1 { + t.Fatalf("get after mutation: %+v, %v; events %d, reads %d", got, err, len(workstreamEvents(rec))-events, reads) + } + clock.Add(workstreamFactoryFresh.Milliseconds()) + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Stage != stage || !got.Workstream.StartedAt.Equal(result.StartedAt) || !got.Workstream.UpdatedAt.Equal(result.UpdatedAt) || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil || reads != 2 || len(workstreamEvents(rec)) != events+1 { + t.Fatalf("unreachable Factory lost durable observation: %+v, %v; reads %d", got, err, reads) + } + updated, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Committed while unreachable")}}) + if err != nil || updated.Workstream.Why != "Committed while unreachable" || updated.Workstream.Stage != stage || updated.Workstream.SpendUSD != nil || len(workstreamEvents(rec)) != events+2 { + t.Fatalf("unreachable Factory failed mutation: %+v, %v", updated, err) + } + }) + } +} + +// (e): marking done ends grants of all three scopes and archives member chats +// through the existing archive door, excluding the workstream's agent record. +func TestAppWorkstreamDoneEndsGoAheadsAndArchivesChats(t *testing.T) { + b, _, _, origin := workstreamBackend(t) + made, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Done", OriginChatID: &origin.ID, Refs: []appwire.WorkstreamRef{workstreamJobRef("7"), {Kind: appwire.RefConversation, ID: "second-chat"}}}) + if err != nil { + t.Fatal(err) + } + id := made.Workstream.ID + _, err = b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: id, Milestones: []appwire.WorkstreamMilestone{{ID: "m", Title: "Ship"}}, Waves: []appwire.WorkstreamWave{{N: 1, MilestoneID: workstreamPtr("m"), Items: []appwire.WorkstreamPlanItem{}}}}) + if err != nil { + t.Fatal(err) + } + for _, p := range []appwire.WorkstreamGoAheadGrantParams{ + {Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7)}, + {Scope: "wave", Wave: workstreamPtr(1)}, + {Scope: "milestone", MilestoneID: workstreamPtr("m")}, + } { + p.WorkstreamID = id + p.CapUSD = 40 + p.EndsAt = time.Now().Add(time.Hour) + if _, err := b.WorkstreamGoAheadGrant(p); err != nil { + t.Fatal(err) + } + } + var archived []string + b.workstreams.archiveChats = func(ids []string) error { archived = append(archived, ids...); return nil } + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: id, ArchiveChats: workstreamPtr(true)}); err != nil { + t.Fatal(err) + } + grants, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: id, Ended: workstreamPtr(true)}) + if err != nil || len(grants.GoAheads) != 3 { + t.Fatalf("grants %+v, %v", grants, err) + } + for _, g := range grants.GoAheads { + if g.State != "ended" || workstreamValue(g.EndReason) != "workstream_done" || g.EndedAt.IsZero() { + t.Fatalf("unended %+v", g) + } + } + if slices.Contains(archived, made.Workstream.AgentID) || len(archived) != 2 || !slices.Contains(archived, origin.ID) || !slices.Contains(archived, "second-chat") { + t.Fatalf("archived %v", archived) + } +} + +// (f): stale saves preserve the document and history; restore creates a new +// version instead of modifying an old one, with complete typed stale detail. +func TestAppWorkstreamPageVersions(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Pages") + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) + if err != nil { + t.Fatal(err) + } + id := pages.Pages[0].ID + doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Saved"}]}]}`) + saved, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: doc}) + if err != nil || saved.Page.Version != 2 { + t.Fatalf("save %+v, %v", saved, err) + } + before := len(workstreamEvents(rec)) + _, err = b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: json.RawMessage(`{}`)}) + wire := workstreamCheckError(t, err, appwire.CodeVersionConflict) + detail, ok := wire.Detail.(appwire.WorkstreamVersionConflict) + if !ok || detail.ID != id || detail.Version != 2 || detail.UpdatedAt != saved.Page.UpdatedAt || detail.UpdatedBy != "you" { + t.Fatalf("detail %+v", wire.Detail) + } + read, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: id, Markdown: workstreamPtr(true)}) + if err != nil || !bytes.Equal(read.Page.Doc, doc) || read.Page.Markdown == nil || !strings.Contains(*read.Page.Markdown, "Saved") { + t.Fatalf("read %+v, %v", read, err) + } + history, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) + if err != nil || len(history.Versions) != 2 || history.Versions[0].Version != 2 { + t.Fatalf("history %+v, %v", history, err) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("stale save or reads emitted") + } + restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id, Version: 1, BaseVersion: 2}) + if err != nil || restored.Page.Version != 3 || string(restored.Page.Doc) != `{"type":"doc","content":[]}` { + t.Fatalf("restore %+v, %v", restored, err) + } + history, err = b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) + if err != nil || len(history.Versions) != 3 || history.Versions[0].Version != 3 { + t.Fatalf("restored history %+v, %v", history, err) + } + _, err = b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeBadParams) +} + +// (g): keep/drop support undo; a replacement links both records and closes +// every later mutation of the superseded record with conflict. +func TestAppWorkstreamRecordLifecycle(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Records") + proposed, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Ship?", Source: &appwire.WorkstreamSource{Kind: "chat", ID: "chat"}}) + if err != nil || proposed.Record.State != "proposed" || proposed.Record.By != "agent" { + t.Fatalf("propose %+v, %v", proposed, err) + } + id := proposed.Record.ID + kept, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}) + if err != nil || kept.Record.State != "kept" { + t.Fatalf("keep %+v, %v", kept, err) + } + dropped, err := b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: id}) + if err != nil || dropped.Record.State != "dropped" { + t.Fatalf("drop %+v, %v", dropped, err) + } + if _, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}); err != nil { + t.Fatal(err) + } + replacement, err := b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: id, Text: "Ship it", Kind: workstreamPtr("decision")}) + if err != nil || replacement.Record.State != "kept" || replacement.Record.Kind != "decision" || workstreamValue(replacement.Record.Supersedes) != id || replacement.Superseded.State != "superseded" || workstreamValue(replacement.Superseded.SupersededBy) != replacement.Record.ID { + t.Fatalf("supersede %+v, %v", replacement, err) + } + _, err = b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeConflict) + _, err = b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeConflict) + _, err = b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: id, Text: "Again"}) + workstreamCheckError(t, err, appwire.CodeConflict) +} + +// (h): missing scope, cap or end date, and clock-dependent invalid ends, store +// nothing and emit nothing, through the backend's own checked entry point. +func TestAppWorkstreamInvalidGoAheads(t *testing.T) { + for _, name := range []string{"scope", "cap", "end", "past", "too far", "too much"} { + t.Run(name, func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Invalid", workstreamJobRef("7")) + p := appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)} + switch name { + case "scope": + p.Scope = "" + case "cap": + p.CapUSD = 0 + case "end": + p.EndsAt = time.Time{} + case "past": + p.EndsAt = time.Now().Add(-time.Second) + case "too far": + p.EndsAt = time.Now().Add(91 * 24 * time.Hour) + case "too much": + p.CapUSD = 10001 + } + before := len(workstreamEvents(rec)) + _, err := b.WorkstreamGoAheadGrant(p) + workstreamCheckError(t, err, appwire.CodeBadParams) + list, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID, Ended: workstreamPtr(true)}) + if err != nil || len(list.GoAheads) != 0 || len(workstreamEvents(rec)) != before { + t.Fatalf("invalid grant stored or emitted: %+v, %v", list, err) + } + }) + } +} + +// (j): encode the actual handler results so absence cannot pass as null or 0. +func TestAppWorkstreamUnknownNumbersAreAbsent(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Unknown", workstreamJobRef("7")) + for _, method := range []string{"get", "list"} { + var result any + var err error + if method == "get" { + result, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + } else { + result, err = b.WorkstreamList(appwire.WorkstreamListParams{}) + } + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{`"merged":`, `"needs_you":`, `"spend_usd":`} { + if bytes.Contains(data, []byte(field)) { + t.Fatalf("%s fabricated %s: %s", method, field, data) + } + } + if !bytes.Contains(data, []byte(`"total":1`)) { + t.Fatalf("total absent: %s", data) + } + } + opened, err := b.OpenCollection(w.ID, 1, 200, "") + if err != nil { + t.Fatal(err) + } + for _, node := range opened.Members { + if node.Kind == appwire.RefFactoryJob && node.Target.State != appwire.TargetUnknown { + t.Fatalf("unattached Factory fabricated target state: %+v", node.Target) + } + } +} + +func TestAppWorkstreamCreatePreflightAndCleanup(t *testing.T) { + for _, name := range []string{"name", "origin", "ref", "bounds"} { + t.Run(name, func(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + p := appwire.WorkstreamCreateParams{Name: "Refused"} + code := appwire.CodeBadParams + switch name { + case "name": + if _, err := b.CreateCollection("Refused"); err != nil { + t.Fatal(err) + } + code = appwire.CodeConflict + case "origin": + p.OriginChatID = workstreamPtr("no-chat") + code = appwire.CodeNotFound + case "ref": + p.Refs = []appwire.WorkstreamRef{{Kind: appwire.RefCollection, ID: "no-collection"}} + code = appwire.CodeNotFound + case "bounds": + p.Why = workstreamPtr(strings.Repeat("x", 4001)) + } + before := len(workstreamEvents(rec)) + _, err := b.WorkstreamCreate(p) + workstreamCheckError(t, err, code) + if *calls != 0 || len(workstreamEvents(rec)) != before { + t.Fatal("preflight created or emitted") + } + if name != "name" { + if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("preflight created store folder: %v", err) + } + } + }) + } + t.Run("peer name race closes unused agent", func(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + create := b.workstreams.createAgent + b.workstreams.createAgent = func(p appwire.CreateAsk) (appwire.Agent, error) { + if _, err := b.CreateCollection("Raced"); err != nil { + return appwire.Agent{}, err + } + return create(p) + } + closed := "" + b.workstreams.closeAgent = func(id string) error { closed = id; return nil } + _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Raced"}) + workstreamCheckError(t, err, appwire.CodeConflict) + list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil || len(list.Workstreams) != 0 || *calls != 1 || closed != "workstream-agent-1" || len(workstreamEvents(rec)) != 0 { + t.Fatalf("cleanup: %+v, %v, closed %q", list, err, closed) + } + }) + t.Run("agent refusal creates no store", func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + b.workstreams.createAgent = func(appwire.CreateAsk) (appwire.Agent, error) { + return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "agent refused") + } + _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Refused"}) + workstreamCheckError(t, err, appwire.CodeBadParams) + if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("agent refusal created store folder: %v", err) + } + if len(workstreamEvents(rec)) != 0 { + t.Fatal("agent refusal emitted") + } + }) +} + +// A fresh workstream list remains a non-migrating, non-creating read. +func TestAppWorkstreamFreshReadsDoNotCreate(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil || list.Workstreams == nil || len(list.Workstreams) != 0 { + t.Fatalf("fresh list %+v, %v", list, err) + } + _, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: "missing"}) + workstreamCheckError(t, err, appwire.CodeNotFound) + if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("fresh read made store folder: %v", err) + } +} + +// The shared hello hook must recognize the real backend's capability. +func TestAppWorkstreamHelloCarriesFeature(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + var output bytes.Buffer + server := appwire.NewServer(&output) + if err := server.Serve(context.Background(), strings.NewReader(""), appwire.Hello{}, workstreamHelloEngine{Engine: b, Workstreamer: b}); err != nil { + t.Fatal(err) + } + var frame struct { + Event string `json:"event"` + Data appwire.Hello `json:"data"` + } + if err := json.NewDecoder(&output).Decode(&frame); err != nil { + t.Fatal(err) + } + if frame.Event != appwire.EventHello || !slices.Contains(frame.Data.Features, appwire.FeatureWorkstreams) { + t.Fatalf("hello %+v", frame) + } +} + +// Only the tested capability is exposed, leaving the real backend's unrelated +// process warm-up outside this protocol test. +type workstreamHelloEngine struct { + appwire.Engine + appwire.Workstreamer +} + +// Session metadata is the recorded spend source, including a fresh bare open +// excluded from ordinary agents.list. Missing/corrupt metadata stays unknown. +func TestAppWorkstreamRecordedAgentSpend(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Spend") + b.workstreams.spend = nil + dir := t.TempDir() + transcript := filepath.Join(dir, "transcript.jsonl") + a := b.agent(w.AgentID) + b.locked(func() { a.transcript = transcript }) + if err := os.WriteFile(transcript, nil, 0600); err != nil { + t.Fatal(err) + } + if got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); err != nil || got.Workstream.SpendUSD != nil { + t.Fatalf("missing metadata: %+v, %v", got, err) + } + for _, cost := range []float64{0, 2.5} { + meta := session.Meta{ID: w.AgentID, SpentUSD: cost} + if cost > 0 { + meta.LastUserAt = time.Now() + } + data, err := json.Marshal(meta) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "meta.json"), data, 0600); err != nil { + t.Fatal(err) + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != cost { + t.Fatalf("recorded %v, got %+v, %v", cost, got, err) + } + } +} + +// (i): every mutation is observed through its public handler and produces one +// event for the changed workstream, containing every part its write changed. +func TestAppWorkstreamEveryWriteEmitsOnce(t *testing.T) { + type fixture struct { + b *appBackend + id, page, parent, record, grant string + base int + } + cases := []struct { + method string + parts []string + done bool + call func(*fixture) error }{ - {"workstream.list", func() error { - _, err := backend.WorkstreamList(appwire.WorkstreamListParams{}) + {"workstream.create", []string{"workstream", "pages", "jobs"}, false, func(f *fixture) error { + v, err := f.b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Second"}) + f.id = v.Workstream.ID return err }}, - {"workstream.get", func() error { - _, err := backend.WorkstreamGet(appwire.WorkstreamGetParams{}) + {"workstream.update", []string{"workstream"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: f.id, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("New why")}}) return err }}, - {"workstream.create", func() error { - _, err := backend.WorkstreamCreate(appwire.WorkstreamCreateParams{}) + {"workstream.mark_done", []string{"workstream", "goaheads"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: f.id}) return err }}, - {"workstream.update", func() error { - _, err := backend.WorkstreamUpdate(appwire.WorkstreamUpdateParams{}) + {"workstream.reopen", []string{"workstream"}, true, func(f *fixture) error { + _, err := f.b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: f.id}) return err }}, - {"workstream.mark_done", func() error { - _, err := backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{}) + {"workstream.plan_set", []string{"workstream", "plan"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: f.id, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}}) return err }}, - {"workstream.reopen", func() error { - _, err := backend.WorkstreamReopen(appwire.WorkstreamReopenParams{}) + {"page.create", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: f.id, Title: "New"}) return err }}, - {"workstream.plan_get", func() error { - _, err := backend.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{}) + {"page.save", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: f.page, BaseVersion: f.base, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}) return err }}, - {"workstream.plan_set", func() error { - _, err := backend.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{}) + {"page.update", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: f.page, Title: workstreamPtr("Renamed")}) return err }}, - {"page.list", func() error { - _, err := backend.WorkstreamPageList(appwire.WorkstreamPageListParams{}) + {"page.move", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: f.page}) return err }}, - {"page.get", func() error { - _, err := backend.WorkstreamPageGet(appwire.WorkstreamPageGetParams{}) + {"page.delete", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: f.parent}) return err }}, - {"page.save", func() error { - _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{}) + {"page.restore", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: f.page, Version: 1, BaseVersion: f.base}) return err }}, - {"page.create", func() error { - _, err := backend.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{}) + {"record.propose", []string{"workstream", "records"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: f.id, Kind: "decision", Text: "New"}) return err }}, - {"page.update", func() error { - _, err := backend.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{}) + {"record.keep", []string{"workstream", "records"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: f.record}) return err }}, - {"page.move", func() error { - _, err := backend.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{}) + {"record.drop", []string{"workstream", "records"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: f.record}) return err }}, - {"page.delete", func() error { - _, err := backend.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{}) + {"record.supersede", []string{"workstream", "records"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: f.record, Text: "Replaced"}) return err }}, - {"page.history", func() error { - _, err := backend.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{}) + {"goahead.grant", []string{"workstream", "goaheads"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: f.id, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 2, EndsAt: time.Now().Add(time.Hour)}) return err }}, - {"page.restore", func() error { - _, err := backend.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{}) + {"goahead.revoke", []string{"workstream", "goaheads"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: f.grant}) return err }}, - {"record.list", func() error { - _, err := backend.WorkstreamRecordList(appwire.WorkstreamRecordListParams{}) + {"collections.add", []string{"workstream", "jobs"}, false, func(f *fixture) error { + _, err := f.b.AddToCollection(f.id, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "8", Factory: "f"}}}) return err }}, - {"record.propose", func() error { - _, err := backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{}) + {"collections.remove", []string{"workstream", "jobs"}, false, func(f *fixture) error { + _, err := f.b.RemoveFromCollection(f.id, []appwire.Ref{{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}) return err }}, - {"record.keep", func() error { - _, err := backend.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{}) + {"collections.rename", []string{"workstream"}, false, func(f *fixture) error { _, err := f.b.RenameCollection(f.id, "Renamed"); return err }}, + {"collections.relink", []string{"workstream", "jobs"}, false, func(f *fixture) error { + _, err := f.b.RelinkCollection(f.id, appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}, appwire.Ref{Kind: appwire.RefFactoryJob, ID: "8", Factory: "f"}) return err }}, - {"record.drop", func() error { - _, err := backend.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{}) + } + for _, tc := range cases { + t.Run(tc.method, func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}, {ID: 8, State: "backlog"}}}, nil + } + w := workstreamCreateForTest(t, b, "Events", workstreamJobRef("7")) + parent, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Parent"}) + if err != nil { + t.Fatal(err) + } + page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, ParentID: &parent.Page.ID, Title: "Child"}) + if err != nil { + t.Fatal(err) + } + saved, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: page.Page.ID, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc"}`)}) + if err != nil { + t.Fatal(err) + } + record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "What?"}) + if err != nil { + t.Fatal(err) + } + grant, err := b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + f := fixture{b: b, id: w.ID, page: page.Page.ID, parent: parent.Page.ID, record: record.Record.ID, grant: grant.GoAhead.ID, base: saved.Page.Version} + if tc.done { + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + } + before := len(workstreamEvents(rec)) + collectionsBefore := len(changedEvents(rec)) + if err := tc.call(&f); err != nil { + t.Fatal(err) + } + events := workstreamEvents(rec)[before:] + if len(events) != 1 || events[0].ID != f.id || !reflect.DeepEqual(events[0].Parts, tc.parts) { + t.Fatalf("%s: %+v, want %s %v", tc.method, events, f.id, tc.parts) + } + if strings.HasPrefix(tc.method, "collections.") && len(changedEvents(rec)) != collectionsBefore+1 { + t.Fatal("membership change lost collection.changed") + } + }) + } +} + +// (i): the read methods and idempotent mutations never manufacture a receipt. +func TestAppWorkstreamReadsAndUnchangedWritesEmitNothing(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Quiet", workstreamJobRef("7")) + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil + } + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) + if err != nil { + t.Fatal(err) + } + page := pages.Pages[0] + r, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "decision", Text: "Keep", Kept: workstreamPtr(true)}) + if err != nil { + t.Fatal(err) + } + g, err := b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + if _, err := b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: g.GoAhead.ID}); err != nil { + t.Fatal(err) + } + calls := []struct { + name string + call func() error + }{ + {"workstream.list", func() error { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); return err }}, + {"workstream.get", func() error { _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); return err }}, + {"workstream.plan_get", func() error { _, err := b.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{ID: w.ID}); return err }}, + {"page.list", func() error { + _, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) return err }}, - {"record.supersede", func() error { - _, err := backend.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{}) + {"page.get", func() error { _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: page.ID}); return err }}, + {"page.history", func() error { + _, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: page.ID}) + return err + }}, + {"record.list", func() error { + _, err := b.WorkstreamRecordList(appwire.WorkstreamRecordListParams{WorkstreamID: w.ID}) return err }}, {"goahead.list", func() error { - _, err := backend.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{}) + _, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID, Ended: workstreamPtr(true)}) + return err + }}, + {"workstream.update empty", func() error { _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID}); return err }}, + {"workstream.reopen open", func() error { _, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}); return err }}, + {"page.update same", func() error { + _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: page.ID, Title: &page.Title}) + return err + }}, + {"record.keep kept", func() error { + _, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: r.Record.ID}) return err }}, - {"goahead.grant", func() error { - _, err := backend.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{}) + {"goahead.revoke ended", func() error { + _, err := b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: g.GoAhead.ID}) return err }}, - {"goahead.revoke", func() error { - _, err := backend.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{}) + {"collections.add already", func() error { + _, err := b.AddToCollection(w.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}}) return err }}, + {"collections.remove absent", func() error { + _, err := b.RemoveFromCollection(w.ID, []appwire.Ref{{Kind: appwire.RefFactoryJob, ID: "99", Factory: "f"}}) + return err + }}, + {"collections.rename same", func() error { _, err := b.RenameCollection(w.ID, w.Name); return err }}, + } + before := len(workstreamEvents(rec)) + for _, tc := range calls { + t.Run(tc.name, func(t *testing.T) { + if err := tc.call(); err != nil { + t.Fatal(err) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("unchanged operation emitted") + } + }) + } + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + before = len(workstreamEvents(rec)) + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("mark_done again emitted") + } +} + +// B3: distinct factories are read concurrently, once per list, and reused by +// get and collections.open. A barrier tests work rather than elapsed time. +func TestAppWorkstreamFactoryBatchCacheAndCollectionTargets(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + a := workstreamCreateForTest(t, b, "One", workstreamJobRef("7")) + second := workstreamJobRef("9") + second.Factory = workstreamPtr("g") + workstreamCreateForTest(t, b, "Two", second) + entered := make(chan string, 2) + release := make(chan struct{}) + var mu sync.Mutex + calls := map[string]int{} + workstreamRecordCall := func(factory string) { + mu.Lock() + defer mu.Unlock() + calls[factory]++ } - if len(calls) != len(appwire.WorkstreamMethods) { - t.Fatal("stub test does not cover every contract method") + b.workstreams.queue = func(ctx context.Context, factory string) (api.Queue, error) { + workstreamRecordCall(factory) + entered <- factory + select { + case <-release: + case <-ctx.Done(): + return api.Queue{}, ctx.Err() + } + id := 7 + if factory == "g" { + id = 9 + } + return api.Queue{Jobs: []api.Job{{ID: id, Num: 1107, Title: "Recorded title", State: "backlog", Repo: "acme/api", Cost: 0}}}, nil } - for i, call := range calls { - if call.name != appwire.WorkstreamMethods[i] { - t.Fatalf("stub test index differs at %s", call.name) + done := make(chan error, 1) + guard.Go("app/workstream-list-test", func() { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); done <- err }) + for range 2 { + select { + case <-entered: + case <-time.After(time.Second): + close(release) + t.Fatal("factories were not read concurrently") } - t.Run(call.name, func(t *testing.T) { - err := call.call() - var wireErr *appwire.Error - if !errors.As(err, &wireErr) { - t.Fatal("stub must return an appwire.Error") + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: a.ID}); err != nil { + t.Fatal(err) + } + open, err := b.OpenCollection(a.ID, 1, 200, "") + if err != nil { + t.Fatal(err) + } + found := false + for _, node := range open.Members { + if node.Kind == appwire.RefFactoryJob { + found = true + if node.Target.State != appwire.TargetLive || node.Target.Title != "Recorded title" || node.Target.Status != "backlog" || node.Target.Repo != "acme/api" { + t.Fatalf("target %+v", node.Target) } - if wireErr.Code != appwire.CodeUnknownMethod { - t.Errorf("code = %s, want unknown_method", wireErr.Code) + } + } + mu.Lock() + defer mu.Unlock() + if !found || calls["f"] != 1 || calls["g"] != 1 { + t.Fatalf("target found %v, queue calls %v", found, calls) + } +} + +// workstreamQueueWaitContext signals when a coalesced reader selects on Done, +// so the panic cannot race ahead of the waiter entering the flight branch. +type workstreamQueueWaitContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *workstreamQueueWaitContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +// (j), B3: a guarded queue panic releases concurrent waiters, caches an +// unavailable reading for the normal window, and permits a successful retry. +func TestAppWorkstreamFactoryPanicFinishesFlight(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + b.now = func() time.Time { return time.UnixMilli(clock.Load()) } + w := workstreamCreateForTest(t, b, "Queue panic", workstreamJobRef("7")) + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var calls atomic.Int32 + b.workstreams.queue = func(ctx context.Context, _ string) (api.Queue, error) { + if calls.Add(1) == 1 { + close(entered) + select { + case <-release: + panic("queue reader fault") + case <-ctx.Done(): + return api.Queue{}, ctx.Err() } - if wireErr.Message != "workstreams: not implemented yet" { - t.Errorf("message = %s", wireErr.Message) + } + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog", Cost: 1.25}}}, nil + } + b.workstreams.job = func(context.Context, string, int) (api.Job, error) { + t.Error("unavailable queue triggered a job fallback") + return api.Job{}, errors.New("unexpected job fallback") + } + type workstreamGetRead struct { + result appwire.WorkstreamGetResult + err error + } + owner := make(chan workstreamGetRead, 1) + guard.Go("app/workstream-panic-owner-test", func() { + result, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + owner <- workstreamGetRead{result: result, err: err} + }) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("queue reader did not enter") + } + _, flight, owns := b.workstreams.workstreamQueueStart("f", b.now()) + if flight == nil || owns { + t.Fatal("queue reader did not own a shared flight") + } + ctx, cancel := context.WithTimeout(context.Background(), workstreamFactoryTimeout) + defer cancel() + waitCtx := &workstreamQueueWaitContext{Context: ctx, waiting: make(chan struct{})} + waiter := make(chan workstreamQueueRead, 1) + guard.Go("app/workstream-panic-waiter-test", func() { waiter <- b.workstreamQueue(waitCtx, "f") }) + select { + case <-waitCtx.waiting: + case <-time.After(time.Second): + t.Fatal("concurrent reader did not wait on the flight") + } + releaseOnce.Do(func() { close(release) }) + select { + case read := <-owner: + if read.err != nil { + t.Fatal(read.err) + } + encoded, err := json.Marshal(read.result) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"merged", "needs_you", "spend_usd"} { + if bytes.Contains(encoded, []byte(`"`+key+`"`)) { + t.Fatalf("panic exposed a number in %s", encoded) } - }) + } + if read.result.Workstream.Total != 1 { + t.Fatalf("panic lost total: %+v", read.result) + } + case <-time.After(time.Second): + t.Fatal("guarded queue owner did not finish") + } + select { + case <-flight: + default: + t.Fatal("panic did not close the flight") + } + select { + case read := <-waiter: + if read.err == nil || errors.Is(read.err, context.DeadlineExceeded) || !read.at.Equal(b.now()) { + t.Fatalf("waiter did not receive the cached failure: %+v", read) + } + case <-time.After(time.Second): + t.Fatal("panic stranded the concurrent waiter") + } + func() { + b.workstreams.cacheMu.Lock() + defer b.workstreams.cacheMu.Unlock() + if _, ok := b.workstreams.flights["f"]; ok { + t.Fatal("panic left an in-flight entry") + } + }() + entry, flight, owns := b.workstreams.workstreamQueueStart("f", b.now()) + if entry.err == nil || flight != nil || owns || calls.Load() != 1 { + t.Fatalf("panic did not remove and cache the failed flight: %+v, %v, %v", entry, flight, owns) + } + clock.Add((workstreamFactoryFresh - time.Millisecond).Milliseconds()) + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil || calls.Load() != 1 { + t.Fatalf("failure did not stay cached: %+v, %v; calls %d", got, err, calls.Load()) + } + clock.Add(1) + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || calls.Load() != 2 || got.Workstream.Merged == nil || *got.Workstream.Merged != 0 || got.Workstream.NeedsYou == nil || *got.Workstream.NeedsYou != 0 || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 1.25 { + t.Fatalf("retry after failure-cache expiry: %+v, %v; calls %d", got, err, calls.Load()) + } +} + +// B3: only successful queue reads allow missing-job fallbacks, each distinct +// address is asked once, and work over the per-call allowance remains unknown. +func TestAppWorkstreamMissingJobReadsAreBounded(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + refs := []appwire.WorkstreamRef{} + for i := 1; i <= 25; i++ { + refs = append(refs, workstreamJobRef(fmt.Sprint(i))) + } + w := workstreamCreateForTest(t, b, "Missing", refs...) + queuedReads, jobReads := 0, 0 + b.workstreams.queue = func(ctx context.Context, _ string) (api.Queue, error) { + queuedReads++ + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > workstreamFactoryTimeout { + t.Error("queue read has no bounded deadline") + } + return api.Queue{}, nil + } + b.workstreams.job = func(ctx context.Context, _ string, job int) (api.Job, error) { + jobReads++ + if _, ok := ctx.Deadline(); !ok { + t.Error("job read has no deadline") + } + return api.Job{ID: job, Num: 999, State: "backlog", Cost: 0}, nil + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || queuedReads != 1 || jobReads != 20 || got.Workstream.Total != 25 || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil { + t.Fatalf("bounded fallback %+v, %v; queue %d jobs %d", got, err, queuedReads, jobReads) + } + b.workstreams.cache = nil + jobReads = 0 + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, errors.New("unreachable") } + if _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + if jobReads != 0 { + t.Fatal("unreachable queue triggered per-job reads") + } + open, err := b.OpenCollection(w.ID, 1, 200, "") + if err != nil { + t.Fatal(err) + } + for _, node := range open.Members { + if node.Kind == appwire.RefFactoryJob && (node.Target.State != appwire.TargetUnknown || node.Target.Status != "unknown") { + t.Fatalf("unavailable target %+v", node.Target) + } + } +} + +func TestAppWorkstreamPlanConflictDetailAndDoneRefusal(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Plan") + p := appwire.WorkstreamPlanSetParams{ID: w.ID, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}} + first, err := b.WorkstreamPlanSet(p) + if err != nil { + t.Fatal(err) + } + before := len(workstreamEvents(rec)) + _, err = b.WorkstreamPlanSet(p) + wire := workstreamCheckError(t, err, appwire.CodeVersionConflict) + detail, ok := wire.Detail.(appwire.WorkstreamVersionConflict) + if !ok || detail.ID != w.ID || detail.Version != 1 || detail.UpdatedAt != first.Plan.UpdatedAt || detail.UpdatedBy != "you" { + t.Fatalf("plan conflict %+v", wire) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("stale plan emitted") + } + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + p.BaseVersion = 1 + _, err = b.WorkstreamPlanSet(p) + workstreamCheckError(t, err, appwire.CodeConflict) +} + +// (c): a bare workstream agent survives the existing cleanup path and can be +// reopened after workstream.get restores it into a fresh backend's inventory. +func TestAppWorkstreamBareAgentSurvivesRestart(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + b, _, _, _ := workstreamBackend(t) + b.collectionsPath = home.Join("v3", "collections.db") + b.workstreams.spend = nil + w := workstreamCreateForTest(t, b, "Persistent") + dir := filepath.Join(session.PlacesRoot(), "project", w.AgentID) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(dir, "transcript.jsonl") + if err := os.WriteFile(transcript, nil, 0600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(dir, session.Meta{ID: w.AgentID, Workspace: t.TempDir(), Created: time.Now()}); err != nil { + t.Fatal(err) + } + a := b.agent(w.AgentID) + b.locked(func() { a.transcript = transcript; a.minted = true }) + if err := b.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dir); err != nil { + t.Fatal(err) + } + restarted, _, _, _ := workstreamBackend(t) + restarted.collectionsPath = home.Join("v3", "collections.db") + restarted.workstreams.spend = nil + got, err := restarted.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 0 { + t.Fatalf("restore %+v, %v", got, err) + } + if _, err := restarted.OpenAgent(w.AgentID); err != nil { + t.Fatalf("agent could not be reopened: %v", err) + } + listed, err := restarted.ListAgents("") + if err != nil { + t.Fatal(err) + } + if _, found := listsAgent(listed, w.AgentID); found { + t.Fatal("restoring the bare agent changed agents.list") + } +} + +// (i): chat archiving is an external owner action after the committed done +// transaction. Its failure cannot hide that change or duplicate it on retry. +func TestAppWorkstreamDoneArchiveFailureStillAnnouncesCommit(t *testing.T) { + b, rec, _, origin := workstreamBackend(t) + made, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Archive retry", OriginChatID: &origin.ID}) + if err != nil { + t.Fatal(err) + } + b.workstreams.archiveChats = func([]string) error { return appwire.Errorf(appwire.CodeBusy, "chat is busy") } + p := appwire.WorkstreamMarkDoneParams{ID: made.Workstream.ID, ArchiveChats: workstreamPtr(true)} + before := len(workstreamEvents(rec)) + _, err = b.WorkstreamMarkDone(p) + workstreamCheckError(t, err, appwire.CodeBusy) + events := workstreamEvents(rec) + if len(events) != before+1 || events[before].ID != made.Workstream.ID { + t.Fatalf("lost done notification: %+v", events) + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: made.Workstream.ID}) + if err != nil || got.Workstream.Stage != "done" { + t.Fatalf("done not committed: %+v, %v", got, err) + } + b.workstreams.archiveChats = func([]string) error { return nil } + if _, err := b.WorkstreamMarkDone(p); err != nil { + t.Fatal(err) + } + if len(workstreamEvents(rec)) != before+1 { + t.Fatal("archive retry duplicated workstream event") } } diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index d447661ee1..9b0c82e262 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -719,6 +719,9 @@ func v3EmptySession(dir string) bool { } func v3EmptyUnassignedSession(dir string) bool { + if workstreamKeepsConversation(dir) { + return false + } place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) if spoken || !sure || session.HasSavedTasks(dir) { diff --git a/cmd/codeaf/collections.go b/cmd/codeaf/collections.go index 70ed21b4e0..e77b1e9299 100644 --- a/cmd/codeaf/collections.go +++ b/cmd/codeaf/collections.go @@ -22,8 +22,8 @@ const collectionsUsage = collectionsSummary + ` codeaf collections show codeaf collections add|remove codeaf collections find - kinds: collection, conversation, task, process, crew, artifact (a file path) - tasks and processes need --session ; all accept --db and --json + kinds: collection, conversation, task, process, crew, factory_job, artifact (a file path) + factory_job needs --factory ; tasks and processes need --session ; all accept --db and --json organize references without moving files or starting work` func runCollections(args []string) error { return runCollectionsTo(args, os.Stdout) } @@ -32,6 +32,7 @@ func runCollectionsTo(args []string, output io.Writer) error { flags := commandFlags("collections") database := flags.String("db", home.Join("v3", "collections.db"), "collection database path; separate from learned memory") sessionID := flags.String("session", "", "owning conversation id for a task reference") + factoryID := flags.String("factory", "", "owning factory id for a factory_job reference") asJSON := flags.Bool("json", false, "print structured records") if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err @@ -63,7 +64,7 @@ func runCollectionsTo(args []string, output io.Writer) error { } var ref workspace.Ref if verb == "add" || verb == "remove" || verb == "find" { - ref = workspace.Ref{Kind: workspace.Kind(rest[len(rest)-2]), ID: rest[len(rest)-1], SessionID: *sessionID} + ref = workspace.Ref{Kind: workspace.Kind(rest[len(rest)-2]), ID: rest[len(rest)-1], SessionID: *sessionID, Factory: *factoryID} if ref.Kind == workspace.ArtifactKind { if ref.ID == "" { return fmt.Errorf("%w: an artifact reference needs a file path", workspace.ErrInvalid) @@ -86,6 +87,8 @@ func runCollectionsTo(args []string, output io.Writer) error { if verb == "add" && ref.Kind == workspace.StandingKind { return fmt.Errorf("%w: standing items are gone, so a collection cannot gain one", workspace.ErrInvalid) } + } else if *factoryID != "" { + return fmt.Errorf("%w: --factory belongs to a factory_job reference in add, remove or find", workspace.ErrInvalid) } else if *sessionID != "" { return fmt.Errorf("%w: --session belongs to a task or process reference in add, remove or find", workspace.ErrInvalid) } @@ -119,7 +122,11 @@ func runCollectionsTo(args []string, output io.Writer) error { result, err = store.Create(ctx, rest[1]) case "rename": err = store.Rename(ctx, rest[1], rest[2]) - result = workspace.Collection{ID: rest[1], Name: rest[2]} + if err == nil { + var summary workspace.Summary + summary, err = store.Describe(ctx, rest[1]) + result = summary.Collection + } case "duplicate": result, err = store.Duplicate(ctx, rest[1], rest[2]) case "delete": @@ -205,6 +212,8 @@ func writeCollectionRef(output io.Writer, ref workspace.Ref) error { var err error if ref.Kind == workspace.TaskKind || ref.Kind == workspace.ProcessKind { _, err = fmt.Fprintf(output, "%s %s conversation %s\n", ref.Kind, ref.ID, ref.SessionID) + } else if ref.Kind == workspace.FactoryJobKind { + _, err = fmt.Fprintf(output, "%s %s factory %s\n", ref.Kind, ref.ID, ref.Factory) } else { _, err = fmt.Fprintf(output, "%s %s\n", ref.Kind, ref.ID) } diff --git a/cmd/codeaf/collections_factory_job_test.go b/cmd/codeaf/collections_factory_job_test.go new file mode 100644 index 0000000000..233234d8a0 --- /dev/null +++ b/cmd/codeaf/collections_factory_job_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// Part A (A2): add/remove/find accept --factory, show names it readably, and +// omitting or misusing the flag is refused before opening storage. +func TestPartACollectionsCLIFactoryJob(t *testing.T) { + db := filepath.Join(t.TempDir(), "collections.db") + c := collectionCreate(t, db, "Jobs") + var out bytes.Buffer + if err := runCollectionsTo([]string{"add", c.ID, "factory_job", "7", "--db", db}, &out); err == nil || !strings.Contains(err.Error(), "factory") { + t.Fatalf("missing factory: %v", err) + } + collectionCommand(t, db, "add", c.ID, "factory_job", "7", "--factory", "f") + if err := runCollectionsTo([]string{"show", c.ID, "--db", db}, &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "factory_job 7 factory f") { + t.Fatalf("show: %q", out.String()) + } + var refs []workspace.Ref + if err := json.Unmarshal([]byte(collectionCommand(t, db, "show", c.ID)), &refs); err != nil || len(refs) != 1 || refs[0].Factory != "f" { + t.Fatalf("refs: %+v %v", refs, err) + } + var parents []workspace.Collection + if err := json.Unmarshal([]byte(collectionCommand(t, db, "find", "factory_job", "7", "--factory", "f")), &parents); err != nil || len(parents) != 1 || parents[0].ID != c.ID { + t.Fatalf("find: %+v %v", parents, err) + } + collectionCommand(t, db, "remove", c.ID, "factory_job", "7", "--factory", "f") + if got := collectionCommand(t, db, "show", c.ID); got != "[]\n" { + t.Fatalf("remove: %s", got) + } + out.Reset() + if err := runCollectionsTo([]string{"add", c.ID, "conversation", "chat", "--factory", "f", "--db", db}, &out); err == nil { + t.Fatal("factory on conversation accepted") + } +} + +func TestPartACollectionRefConversionsRetainFactory(t *testing.T) { + r := appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"} + if got := appWireRef(appStoreRef(r)); got != r { + t.Fatalf("round trip: %+v", got) + } +} diff --git a/docs/changes/unreleased/2270-app-workstreams-store.md b/docs/changes/unreleased/2270-app-workstreams-store.md new file mode 100644 index 0000000000..1966231869 --- /dev/null +++ b/docs/changes/unreleased/2270-app-workstreams-store.md @@ -0,0 +1,27 @@ +--- +kind: added +title: codeaf app serves persistent workstreams with pages, records and go-aheads +pr: 2270 +surface: [app] +invalidates: + - Workstream methods previously answered unknown_method; they now serve persistent state and announce the workstreams feature. +--- + +Workstreams share collection identity, preserve page history, version plans, +and keep decisions, questions and bounded go-aheads in SQLite. Creating one +opens one bare agent without a first model turn; a repeated client id reuses it. +Factory jobs belong to at most one workstream, and changes notify watching apps. +List/get derive stage and numbers from bounded Factory reads and recorded agent +spend. Unknown readings are omitted; measured zero remains present. Mark done +ends active go-aheads and can archive member chats while keeping the agent record. +Go-aheads and wave auto-start settings are stored; Factory enforcement and +automatic starts are separate work. + +Update, mark done and reopen results include the same available live numbers as +get. Single-line fields reject control characters; prose permits newline and +tab only. Repeating mark done preserves the original done time and report. + +Faults while reading a queue release concurrent readers, cache an unavailable +result and permit retries after the cache window. Update, mark done and reopen +persist their first observed start, return the refreshed row and announce one +combined change. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index ebaa8eb44c..2a7927952d 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -147,6 +147,18 @@ decides which combinations are accepted. | `collections.remove` | `{id, refs}` | `CollectionRemoveResult` | | `collections.rename` | `in.collectionNameParams` | `CollectionResult` | +Collection refs include `factory_job`, with a canonical positive decimal job `id` +and a required `factory` field. The pair addresses the same job as `factory.job`. +Other ref kinds have no `factory`. Collection results carry `workstream: true` +when their id is a workstream. `collections.delete` refuses these collections +with `bad_params` and the exact message `mark a workstream done instead`; +duplicating one makes a plain collection. A job can belong to only one workstream: +adding a job held by another returns `in_other_workstream` with factory, integer +job, workstream_id and workstream_name detail, and changes nothing in the batch. +Membership changes and renames send `workstream.changed` as well as +`collection.changed`. Factory job targets carry the recorded title, state word +and repository; an unavailable Factory produces an `unknown` target and status. + ## competence methods | method | params | result | @@ -1493,10 +1505,34 @@ read because it has said nothing. Engines that serve it announce ## Workstreams (v0 proposal) -The Workstreams plan, WP-0, registers 25 methods and `workstream.changed` with their wire types. -The `workstreams` feature is announced only by an engine that serves them. -This build does not announce it and answers `unknown_method` with -`workstreams: not implemented yet`; the store lands in WP-1. +The Workstreams plan, WP-1, serves 25 methods backed by the collections SQLite +store and announces `workstreams` in `hello.features`. Older engines without +this feature must not be called; their methods may answer `unknown_method`. +Pages, version history, records, plans, settings and go-aheads are engine-owned +SQLite state. Markdown is a page export. Every committed change sends one +`workstream.changed` for its id and changed parts; unchanged writes emit nothing. +List/get may persist and announce the first observed job leaving Backlog. +Creation checks refs and ownership before opening one bare agent conversation, +with no first message or model turn. Repeating a nonempty client_id returns the +first workstream. Origin and agent conversations are collection members. +Mark done ends active go-aheads, and optional chat archiving excludes the agent's +record. Reopen keeps the workstream in Building. Recorded Factory facts and agent +spend feed the numbers; unavailable readings remain absent, while measured zero +is present. Queue reads have a three-second call deadline and a ten-second cache +of at most 32 factories. Distinct factories are read concurrently, up to eight +at a time; at most 20 missing jobs get individual reads per call. Further jobs +remain unknown. A Factory connection that cannot be read stays unknown. +Update, mark_done and reopen results use the same bounded, cached facts and agent +spend as get. They persist the first observed start and return the refreshed row, +combining the observation with the mutation's single `workstream.changed` event. +A fault while reading a queue releases waiting readers and caches an unavailable +result for the normal window before a retry. Repeating mark_done preserves the +original done_at and how_it_went. +Client IDs, page/milestone/wave/draft titles and source labels reject control +characters. Why, how_it_went, record text and draft text allow newline and tab +only; other control characters return `bad_params`. Names use collection rules. +Go-aheads are stored with scope, cap and end bounds; expiry is derived on read. +Factory enforcement and automatic wave starts remain separate capabilities. A workstream is a collection, sharing its id, naming rules and membership methods. Its jobs use `factory_job` refs with `factory`; a job in another workstream is refused as `in_other_workstream`. Versioned page and plan writes refuse stale bases with `version_conflict`. diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go index 579d05e7a6..4f0d750963 100644 --- a/internal/appwire/collections.go +++ b/internal/appwire/collections.go @@ -6,6 +6,7 @@ import ( "strings" "time" "unicode" + "unicode/utf8" ) // WAVE 9: COLLECTIONS (PROTOCOL.md, V75). A collection is a hand-picked list @@ -31,6 +32,7 @@ const ( RefArtifact = "artifact" RefCrew = "crew" RefProcess = "process" + RefFactoryJob = "factory_job" RefStanding = "standing" ) @@ -60,9 +62,10 @@ const ( // Ref is one reference: a task and a process carry their agent as SessionID, // and an artifact is a clean absolute path. type Ref struct { - Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,standing"` + Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,standing,factory_job"` ID string `json:"id"` SessionID string `json:"session_id,omitempty"` + Factory string `json:"factory,omitempty"` } // Member is a reference as a collection holds it, with when it was added; @@ -78,6 +81,7 @@ type Member struct { // about a reference, whether it directly holds it. type Collection struct { ID string `json:"id"` + Workstream bool `json:"workstream,omitempty"` Name string `json:"name"` CreatedAt *time.Time `json:"created_at,omitempty"` Count int `json:"count"` @@ -246,12 +250,19 @@ func withFeature(hello Hello, feature string) Hello { // plainText is non-empty text of at most limit bytes with no control // characters and no surrounding space. func plainText(s string, limit int) bool { - return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0 + return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && singleLineText(s) +} + +func singleLineText(s string) bool { + return strings.IndexFunc(s, unicode.IsControl) < 0 } // CheckRef refuses a malformed reference as bad_params. adding refuses the // legacy standing kind, which can be found and removed and never added. func CheckRef(r Ref, adding bool) error { + if r.Kind != RefFactoryJob && r.Factory != "" { + return Errorf(CodeBadParams, "only a factory_job reference has a factory") + } if !plainText(r.ID, collectionIDMax) { return Errorf(CodeBadParams, "a reference needs an id without surrounding space or control characters") } @@ -263,6 +274,11 @@ func CheckRef(r Ref, adding bool) error { return Errorf(CodeBadParams, "only a task or a process reference has a session_id") } switch r.Kind { + case RefFactoryJob: + n, err := strconv.ParseUint(r.ID, 10, 63) + if err != nil || n == 0 || strconv.FormatUint(n, 10) != r.ID || (!plainText(r.Factory, collectionIDMax) || !utf8.ValidString(r.Factory)) { + return Errorf(CodeBadParams, "a factory_job needs a factory and a positive canonical job number") + } case RefCollection, RefConversation, RefCrew, RefProcess: case RefTask: n, err := strconv.ParseUint(r.ID, 10, 64) diff --git a/internal/appwire/collections_factory_job_test.go b/internal/appwire/collections_factory_job_test.go new file mode 100644 index 0000000000..1a888f545a --- /dev/null +++ b/internal/appwire/collections_factory_job_test.go @@ -0,0 +1,41 @@ +package appwire + +import ( + "encoding/json" + "strings" + "testing" +) + +// Part A (A2): the wire validator and optional fields match store ref identity. +func TestPartACollectionFactoryJobRefs(t *testing.T) { + for _, tc := range []struct { + r Ref + ok bool + }{ + {Ref{Kind: RefFactoryJob, ID: "7", Factory: "f"}, true}, + {Ref{Kind: RefFactoryJob, ID: "7"}, false}, + {Ref{Kind: RefFactoryJob, ID: "0", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "07", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "+7", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: " f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: strings.Repeat("x", 4097)}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: "f", SessionID: "chat"}, false}, + {Ref{Kind: RefConversation, ID: "chat", Factory: "f"}, false}, + } { + for _, adding := range []bool{false, true} { + if err := CheckRef(tc.r, adding); (err == nil) != tc.ok { + t.Errorf("%+v adding %v: %v", tc.r, adding, err) + } + } + } + raw, err := json.Marshal(Member{Ref: Ref{Kind: RefFactoryJob, ID: "7", Factory: "f"}}) + if err != nil || !strings.Contains(string(raw), `"factory":"f"`) { + t.Fatalf("factory: %s %v", raw, err) + } + for _, flag := range []bool{false, true} { + raw, err := json.Marshal(Collection{ID: "c", Name: "Name", Workstream: flag}) + if err != nil || strings.Contains(string(raw), `"workstream":true`) != flag { + t.Fatalf("flag: %s %v", raw, err) + } + } +} diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go index 983ae6b33a..6a7191bb17 100644 --- a/internal/appwire/workstreams.go +++ b/internal/appwire/workstreams.go @@ -15,7 +15,7 @@ const ( CodeVersionConflict = "version_conflict" ) -// ErrWorkstreamsNotYet keeps the registered contract unavailable until WP-1. +// ErrWorkstreamsNotYet is the capability refusal for a shapes-only engine. var ErrWorkstreamsNotYet = Errorf(CodeUnknownMethod, "workstreams: not implemented yet") // WorkstreamMethods follows the contract index order. @@ -507,16 +507,32 @@ type Workstreamer interface { } // workstreamCall preserves the params/result types used by the schema generator. -// Bounds and storage behavior land in WP-1; this door only delegates. -func workstreamCall[P, R any](call func(Workstreamer, P) (R, error)) method { - return typed(func(e Engine, p P) (*R, error) { +// Unsupported engines retain their capability refusal before parameter checks. +func workstreamCall[P interface{ Check() error }, R any](call func(Workstreamer, P) (R, error)) method { + m := typed(func(e Engine, p P) (*R, error) { w, ok := e.(Workstreamer) if !ok { return nil, Errorf(CodeUnknownMethod, "this engine does not serve workstreams") } + if !w.ServesWorkstreams() { + return nil, ErrWorkstreamsNotYet + } + if err := p.Check(); err != nil { + return nil, err + } result, err := call(w, p) return &result, err }) + delegate := m.call + m.call = func(e Engine, raw json.RawMessage) (any, error) { + if w, ok := e.(Workstreamer); ok && w.ServesWorkstreams() { + if err := workstreamRequired(raw, m.params); err != nil { + return nil, err + } + } + return delegate(e, raw) + } + return m } func init() { diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go new file mode 100644 index 0000000000..c549a4312e --- /dev/null +++ b/internal/appwire/workstreams_check.go @@ -0,0 +1,372 @@ +package appwire + +import ( + "bytes" + "encoding/json" + "math" + "reflect" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// workstreamRequired distinguishes a missing required zero-valued field from +// an explicit zero, including plan version 0 and false wake flags. Editor JSON +// stays opaque; unknown additive fields stay ignored by the typed decoder. +func workstreamRequired(raw json.RawMessage, shape reflect.Type) error { + if shape == reflect.TypeFor[json.RawMessage]() || shape == reflect.TypeFor[time.Time]() { + return nil + } + if shape.Kind() == reflect.Pointer { + shape = shape.Elem() + } + switch shape.Kind() { + case reflect.Struct: + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { + return Errorf(CodeBadParams, "workstream params must be an object") + } + for i := 0; i < shape.NumField(); i++ { + field := shape.Field(i) + tag := field.Tag.Get("json") + name, _, _ := strings.Cut(tag, ",") + value, exists := fields[name] + if !exists { + if !strings.Contains(tag, ",omitempty") { + return Errorf(CodeBadParams, "%s is required", name) + } + continue + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return Errorf(CodeBadParams, "%s must not be null", name) + } + if err := workstreamRequired(value, field.Type); err != nil { + return err + } + } + case reflect.Slice: + var values []json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil { + return Errorf(CodeBadParams, "expected an array") + } + for _, value := range values { + if err := workstreamRequired(value, shape.Elem()); err != nil { + return err + } + } + } + return nil +} + +func workstreamID(id string) error { + if !utf8.ValidString(id) || !plainText(id, collectionIDMax) { + return Errorf(CodeBadParams, "a workstream target needs a nonempty id without surrounding space or control characters") + } + return nil +} + +func workstreamOptionalID(id *string) error { + if id == nil { + return nil + } + return workstreamID(*id) +} + +func workstreamText(text string, min, max int, field string) error { + if !utf8.ValidString(text) || len(text) < min || len(text) > max || (min > 0 && strings.TrimSpace(text) == "") { + return Errorf(CodeBadParams, "%s is %d to %d bytes", field, min, max) + } + if strings.IndexFunc(text, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\t' }) >= 0 { + return Errorf(CodeBadParams, "%s allows only newline and tab control characters", field) + } + return nil +} + +func workstreamLine(text string, min, max int, field string) error { + if !singleLineText(text) { + return Errorf(CodeBadParams, "%s must not contain control characters", field) + } + return workstreamText(text, min, max, field) +} + +func workstreamOptionalLine(text *string, min, max int, field string) error { + if text == nil { + return nil + } + return workstreamLine(*text, min, max, field) +} + +func workstreamOptionalText(text *string, min, max int, field string) error { + if text == nil { + return nil + } + return workstreamText(*text, min, max, field) +} + +func workstreamEnum(word string, words ...string) error { + for _, allowed := range words { + if word == allowed { + return nil + } + } + return Errorf(CodeBadParams, "invalid workstream value %q", word) +} + +func workstreamOptionalEnum(word *string, words ...string) error { + if word == nil { + return nil + } + return workstreamEnum(*word, words...) +} + +// workstreamChecks keeps each family of checks ordered and returns the first refusal. +func workstreamChecks(checks ...error) error { + for _, err := range checks { + if err != nil { + return err + } + } + return nil +} + +func workstreamDoc(doc json.RawMessage, required bool) error { + if len(doc) > workstream.DocMax { + return Errorf(CodeTooLarge, "encoded document exceeds 1 MiB") + } + if len(doc) == 0 && !required { + return nil + } + trimmed := bytes.TrimSpace(doc) + if !utf8.Valid(doc) || !json.Valid(doc) || len(trimmed) == 0 || trimmed[0] != '{' { + return Errorf(CodeBadParams, "doc must be an editor JSON object") + } + return nil +} + +func (p WorkstreamRef) Check() error { + if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.SessionID), workstreamOptionalID(p.Factory)); err != nil { + return err + } + if p.Factory != nil && p.Kind != RefFactoryJob { + return Errorf(CodeBadParams, "only a factory_job reference has a factory") + } + if p.SessionID != nil && p.Kind != RefTask && p.Kind != RefProcess { + return Errorf(CodeBadParams, "only a task or process reference has a session_id") + } + return CheckRef(Ref{Kind: p.Kind, ID: p.ID, SessionID: workstreamString(p.SessionID), Factory: workstreamString(p.Factory)}, true) +} + +func workstreamString(p *string) string { + if p == nil { + return "" + } + return *p +} + +func (p WorkstreamSource) Check() error { + if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Label, 0, 200, "source label")); err != nil { + return err + } + switch p.Kind { + case "chat", "page", "url": + case "message": + if p.ChatID == nil { + return Errorf(CodeBadParams, "message source requires chat_id") + } + if err := workstreamID(*p.ChatID); err != nil { + return err + } + case "factory_job": + if err := CheckRef(Ref{Kind: RefFactoryJob, ID: p.ID, Factory: workstreamString(p.Factory)}, true); err != nil { + return err + } + default: + return Errorf(CodeBadParams, "unknown source kind %q", p.Kind) + } + return workstreamChecks(workstreamOptionalID(p.ChatID), workstreamOptionalID(p.Factory)) +} + +func workstreamSource(p *WorkstreamSource) error { + if p == nil { + return nil + } + return p.Check() +} + +func (p WorkstreamListParams) Check() error { return nil } +func (p WorkstreamGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamReopenParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamPlanGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamPageListParams) Check() error { return workstreamID(p.WorkstreamID) } +func (p WorkstreamPageGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamPageDeleteParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamRecordKeepParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamRecordDropParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamGoAheadRevokeParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamGoAheadListParams) Check() error { return workstreamID(p.WorkstreamID) } + +func (p WorkstreamCreateParams) Check() error { + if !utf8.ValidString(p.Name) { + return Errorf(CodeBadParams, "name must be valid UTF-8") + } + if _, err := collectionName(p.Name); err != nil { + return err + } + if err := workstreamChecks(workstreamOptionalText(p.Why, 0, 4000, "why"), workstreamOptionalLine(p.ClientID, 0, 128, "client_id"), workstreamOptionalID(p.OriginChatID), workstreamOptionalID(p.Model)); err != nil { + return err + } + if len(p.Refs) > CollectionRefsMax { + return Errorf(CodeBadParams, "refs takes at most %d references", CollectionRefsMax) + } + for _, r := range p.Refs { + if err := r.Check(); err != nil { + return err + } + } + return nil +} + +func (p WorkstreamUpdateParams) Check() error { + if p.Patch.Name != nil { + if !utf8.ValidString(*p.Patch.Name) { + return Errorf(CodeBadParams, "name must be valid UTF-8") + } + if _, err := collectionName(*p.Patch.Name); err != nil { + return err + } + } + if p.Patch.BudgetUSD != nil && (math.IsNaN(*p.Patch.BudgetUSD) || math.IsInf(*p.Patch.BudgetUSD, 0) || *p.Patch.BudgetUSD < 0) { + return Errorf(CodeBadParams, "budget_usd must be finite and nonnegative") + } + return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.Patch.Why, 0, 4000, "why"), workstreamOptionalEnum(p.Patch.Autonomy, "propose", "small", "off")) +} +func (p WorkstreamMarkDoneParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.HowItWent, 0, 4000, "how_it_went")) +} + +func (p WorkstreamPlanSetParams) Check() error { + if err := workstreamID(p.ID); err != nil { + return err + } + if p.BaseVersion < 0 || p.Milestones == nil || p.Waves == nil { + return Errorf(CodeBadParams, "plan requires nonnegative base_version and milestones and waves arrays") + } + for _, m := range p.Milestones { + if m.ID != "" { + if err := workstreamID(m.ID); err != nil { + return err + } + } + if err := workstreamLine(m.Title, 1, 200, "milestone title"); err != nil { + return err + } + } + last := 0 + for _, w := range p.Waves { + if w.N <= last || w.Items == nil { + return Errorf(CodeBadParams, "waves must be positive, unique, ascending and have items arrays") + } + last = w.N + if err := workstreamChecks(workstreamOptionalID(w.MilestoneID), workstreamOptionalLine(w.Title, 0, 200, "wave title")); err != nil { + return err + } + for _, item := range w.Items { + if item.Job != nil && *item.Job <= 0 { + return Errorf(CodeBadParams, "plan job must be positive") + } + if err := workstreamChecks(workstreamOptionalID(item.Factory), workstreamOptionalID(item.DraftID)); err != nil { + return err + } + switch item.Kind { + case "job": + if item.Job == nil || *item.Job <= 0 || item.Factory == nil { + return Errorf(CodeBadParams, "plan job requires factory and positive job") + } + if err := workstreamID(*item.Factory); err != nil { + return err + } + case "draft": + if item.DraftID == nil || item.Title == nil { + return Errorf(CodeBadParams, "draft requires draft_id and title") + } + if err := workstreamChecks(workstreamID(*item.DraftID), workstreamLine(*item.Title, 1, 200, "draft title")); err != nil { + return err + } + if item.Text != nil { + if err := workstreamText(*item.Text, 0, len(*item.Text), "draft text"); err != nil { + return err + } + } + default: + return Errorf(CodeBadParams, "unknown plan item kind %q", item.Kind) + } + } + } + return nil +} + +func (p WorkstreamPageSaveParams) Check() error { + if p.BaseVersion < 0 { + return Errorf(CodeBadParams, "base_version must be nonnegative") + } + return workstreamChecks(workstreamID(p.ID), workstreamDoc(p.Doc, true)) +} +func (p WorkstreamPageCreateParams) Check() error { + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID), workstreamLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent"), workstreamDoc(p.Doc, false)) +} +func (p WorkstreamPageUpdateParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent")) +} +func (p WorkstreamPageMoveParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID)) +} +func (p WorkstreamPageHistoryParams) Check() error { + _, err := bounded("limit", p.Limit, 1, 200, 50) + return workstreamChecks(workstreamID(p.ID), err) +} +func (p WorkstreamPageRestoreParams) Check() error { + if p.Version < 1 || p.BaseVersion < 0 { + return Errorf(CodeBadParams, "restore requires positive version and nonnegative base_version") + } + return workstreamID(p.ID) +} +func (p WorkstreamRecordListParams) Check() error { + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalEnum(p.Kind, "decision", "question"), workstreamOptionalEnum(p.State, "proposed", "kept", "dropped", "superseded")) +} +func (p WorkstreamRecordProposeParams) Check() error { + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamEnum(p.Kind, "decision", "question"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) +} +func (p WorkstreamRecordSupersedeParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalEnum(p.Kind, "decision", "question"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) +} +func (p WorkstreamGoAheadGrantParams) Check() error { + if err := workstreamID(p.WorkstreamID); err != nil { + return err + } + if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || p.EndsAt.IsZero() { + return Errorf(CodeBadParams, "go-ahead requires cap_usd greater than 0 and at most %d, and ends_at", workspace.WorkstreamGoAheadCapMax) + } + switch p.Scope { + case "job": + if p.Factory == nil || p.Job == nil || *p.Job <= 0 || p.Wave != nil || p.MilestoneID != nil { + return Errorf(CodeBadParams, "job scope requires factory and job only") + } + return workstreamID(*p.Factory) + case "wave": + if p.Wave == nil || *p.Wave <= 0 || p.Factory != nil || p.Job != nil || p.MilestoneID != nil { + return Errorf(CodeBadParams, "wave scope requires wave only") + } + case "milestone": + if p.MilestoneID == nil || p.Factory != nil || p.Job != nil || p.Wave != nil { + return Errorf(CodeBadParams, "milestone scope requires milestone_id only") + } + return workstreamID(*p.MilestoneID) + default: + return Errorf(CodeBadParams, "unknown go-ahead scope %q", p.Scope) + } + return nil +} diff --git a/internal/appwire/workstreams_check_test.go b/internal/appwire/workstreams_check_test.go new file mode 100644 index 0000000000..117f128e14 --- /dev/null +++ b/internal/appwire/workstreams_check_test.go @@ -0,0 +1,137 @@ +package appwire + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +// B1: malformed requests are refused at the real typed dispatch door, before +// the serving fake (which always answers unknown_method) can receive them. +func TestWorkstreamBoundsBeforeDelegation(t *testing.T) { + cases := []struct{ method, params, code string }{ + {"workstream.get", `{}`, CodeBadParams}, + {"workstream.create", `{"name":""}`, CodeBadParams}, + {"workstream.create", `{"name":"x","client_id":"` + strings.Repeat("a", 129) + `"}`, CodeBadParams}, + {"workstream.create", `{"name":"x","why":"` + strings.Repeat("a", 4001) + `"}`, CodeBadParams}, + {"workstream.create", `{"name":"x","refs":[{"kind":"factory_job","id":"01","factory":"f"}]}`, CodeBadParams}, + {"workstream.create", `{"name":"x","refs":[` + strings.Repeat(`{"kind":"conversation","id":"c"},`, 200) + `{"kind":"conversation","id":"c"}]}`, CodeBadParams}, + {"workstream.update", `{"id":"x"}`, CodeBadParams}, + {"workstream.update", `{"id":"x","patch":{"autonomy":"auto"}}`, CodeBadParams}, + {"workstream.update", `{"id":"x","patch":{"wake":{"morning":false}}}`, CodeBadParams}, + {"workstream.mark_done", `{"id":"x","how_it_went":"` + strings.Repeat("a", 4001) + `"}`, CodeBadParams}, + {"workstream.plan_set", `{"id":"x","milestones":[],"waves":[]}`, CodeBadParams}, + {"workstream.plan_set", `{"id":"x","base_version":-1,"milestones":[],"waves":[]}`, CodeBadParams}, + {"workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":0,"auto_start":false,"items":[]}]}`, CodeBadParams}, + {"page.create", `{"workstream_id":"x","title":"x","owner":"other"}`, CodeBadParams}, + {"page.create", `{"workstream_id":"x","title":"x","doc":[]}`, CodeBadParams}, + {"page.update", `{"id":"x","title":""}`, CodeBadParams}, + {"page.update", `{"id":"x","title":"` + strings.Repeat("t", 201) + `"}`, CodeBadParams}, + {"page.save", `{"id":"x","base_version":1}`, CodeBadParams}, + {"page.save", `{"id":"x","base_version":1,"doc":{"text":"` + strings.Repeat("a", 1<<20) + `"}}`, CodeTooLarge}, + {"page.move", `{"id":"x","parent_id":""}`, CodeBadParams}, + {"page.history", `{"id":"x","limit":0}`, CodeBadParams}, + {"page.history", `{"id":"x","limit":201}`, CodeBadParams}, + {"page.restore", `{"id":"x","version":0,"base_version":1}`, CodeBadParams}, + {"record.list", `{"workstream_id":"x","state":"active"}`, CodeBadParams}, + {"record.propose", `{"workstream_id":"x","kind":"decision","text":""}`, CodeBadParams}, + {"record.propose", `{"workstream_id":"x","kind":"decision","text":"` + strings.Repeat("t", 2001) + `"}`, CodeBadParams}, + {"record.propose", `{"workstream_id":"x","kind":"question","text":"x","source":{"kind":"message","id":"m"}}`, CodeBadParams}, + {"record.supersede", `{"id":"x","text":"x","kind":"other"}`, CodeBadParams}, + {"goahead.grant", `{"workstream_id":"x","scope":"wave","wave":1,"ends_at":"2099-01-01T00:00:00Z"}`, CodeBadParams}, + {"goahead.grant", `{"workstream_id":"x","scope":"wave","wave":1,"cap_usd":10001,"ends_at":"2099-01-01T00:00:00Z"}`, CodeBadParams}, + } + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for _, tc := range cases { + t.Run(tc.method+tc.params[:min(len(tc.params), 55)], func(t *testing.T) { + _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) + wire, ok := err.(*Error) + if !ok || wire.Code != tc.code { + t.Fatalf("got %v, want %s", err, tc.code) + } + }) + } +} + +func TestWorkstreamBoundsPreserveExplicitZeroAndFalse(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for method, params := range map[string]string{ + "workstream.plan_set": `{"id":"x","base_version":0,"milestones":[],"waves":[]}`, + "workstream.update": `{"id":"x","patch":{"why":"","budget_usd":0,"wake":{"morning":false,"related_issues":false}}}`, + "page.create": `{"workstream_id":"x","title":"x","read_by_jobs":false}`, + } { + _, err := methods[method].call(fake, json.RawMessage(params)) + if err != ErrWorkstreamsNotYet { + t.Fatalf("%s did not reach the fake: %v", method, err) + } + } +} + +func TestWorkstreamControlCharactersBeforeDelegation(t *testing.T) { + cases := []struct { + name, method, params string + prose bool + }{ + {"name", "workstream.create", `{"name":$text}`, false}, + {"rename", "workstream.update", `{"id":"x","patch":{"name":$text}}`, false}, + {"client_id", "workstream.create", `{"name":"x","client_id":$text}`, false}, + {"page title", "page.create", `{"workstream_id":"x","title":$text}`, false}, + {"updated page title", "page.update", `{"id":"x","title":$text}`, false}, + {"milestone title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[{"id":"","title":$text}],"waves":[]}`, false}, + {"wave title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"title":$text,"auto_start":false,"items":[]}]}`, false}, + {"draft title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":$text}]}]}`, false}, + {"source label", "record.propose", `{"workstream_id":"x","kind":"decision","text":"x","source":{"kind":"chat","id":"c","label":$text}}`, false}, + {"replacement source label", "record.supersede", `{"id":"x","text":"x","source":{"kind":"chat","id":"c","label":$text}}`, false}, + {"why", "workstream.create", `{"name":"x","why":$text}`, true}, + {"updated why", "workstream.update", `{"id":"x","patch":{"why":$text}}`, true}, + {"how_it_went", "workstream.mark_done", `{"id":"x","how_it_went":$text}`, true}, + {"record text", "record.propose", `{"workstream_id":"x","kind":"question","text":$text}`, true}, + {"replacement text", "record.supersede", `{"id":"x","text":$text}`, true}, + {"draft text", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft","text":$text}]}]}`, true}, + } + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for _, tc := range cases { + for control := rune(0); control <= 0x9f; control++ { + if control > 0x1f && control < 0x7f { + continue + } + t.Run(fmt.Sprintf("%s/U+%04X", tc.name, control), func(t *testing.T) { + value, err := json.Marshal("before" + string(control) + "after") + if err != nil { + t.Fatal(err) + } + params := strings.ReplaceAll(tc.params, "$text", string(value)) + _, err = methods[tc.method].call(fake, json.RawMessage(params)) + if tc.prose && (control == '\n' || control == '\t') { + if err != ErrWorkstreamsNotYet { + t.Fatalf("allowed prose did not reach the fake: %v", err) + } + return + } + wire, ok := err.(*Error) + if !ok || wire.Code != CodeBadParams { + t.Fatalf("got %v, want %s before delegation", err, CodeBadParams) + } + }) + } + } +} + +func TestWorkstreamTextFieldsPreserveAllowedContent(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for _, tc := range []struct{ method, params string }{ + {"workstream.create", `{"name":"Launch","client_id":"","why":""}`}, + {"workstream.create", `{"name":"Launch","client_id":" retry ","why":"first\n\tsecond"}`}, + {"page.create", `{"workstream_id":"x","title":" Résumé 界 "}`}, + {"record.propose", `{"workstream_id":"x","kind":"question","text":"first\n\tsecond","source":{"kind":"chat","id":"c","label":" Label "}}`}, + {"workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"title":"","auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":" Draft ","text":""}]}]}`}, + } { + t.Run(tc.method+tc.params, func(t *testing.T) { + _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) + if err != ErrWorkstreamsNotYet { + t.Fatalf("allowed content did not reach the fake: %v", err) + } + }) + } +} diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 0b878f3181..825d277aff 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -3,7 +3,7 @@ ## How do I group chats in logical folders `codeaf collections` is a local command for organizing references to conversations, -tasks and files. A collection is a logical folder with a stable ID +tasks, Factory jobs and files. A collection is a logical folder with a stable ID and name. Several collections can reference the same record. Collections can contain other collections, including one child shared by several parents; circular membership is refused. Renaming a collection preserves its ID. @@ -71,7 +71,8 @@ belong to collections. ## Adding, removing and finding a chat or work reference Use `codeaf collections add `. -The kinds are `collection`, `conversation`, `task`, `process`, `crew` and `artifact`. There is no kind +The kinds are `collection`, `conversation`, `task`, `process`, `crew`, +`factory_job` and `artifact`. There is no kind for an automation. `standing` named a standing item, which codeaf no longer has: a row that already holds one can still be found and removed, and adding a new one is refused with `standing items are gone, so a collection cannot gain one`. @@ -100,6 +101,21 @@ something that was never there is not an error. Automatic organization, inherited instructions and communication between conversations are not implemented by these commands. +## Adding, finding and removing a Factory job + +Use `codeaf collections add factory_job --factory +`. The job number must be a positive decimal without a sign or leading +zeroes. The factory ID is required: the same number in two factories names two +different jobs. Use the same `--factory` with `find` and `remove`. Other kinds +refuse a factory ID. `show` prints `factory_job factory `; +`--json` includes the `factory` field. + +A plain collection can reference a job alongside any other records. A job can +belong to only one workstream collection. Adding a job already in another +workstream answers `that job belongs to another workstream` and leaves every +reference in that add unchanged. These commands retain references without asking +the Factory whether the job is available and never start a job. + ## Deleting or duplicating a collection `codeaf collections delete ` deletes the collection, its @@ -108,3 +124,48 @@ memberships, and every membership naming it in another collection, answering referenced is deleted, moved or stopped. `codeaf collections duplicate ` makes a new collection holding the same references, in the same order; the new name follows the same uniqueness rule as `create`. + +A workstream collection refuses deletion with `mark a workstream done instead`. +Duplicating one makes a plain collection with the same references; it creates no +workstream and claims no jobs. + +## Workstreams in an app client: stages and agent chats + +An app client can create a workstream backed by a collection with the same ID. +It includes one agent conversation, an optional origin chat, and the references +you choose. Creation opens the agent without sending a first message or running +a model. Repeating a nonempty client ID returns the original workstream. +Names follow collection naming rules; the client ID is at most 128 bytes and a +create takes at most 200 explicit references. + +A workstream starts in Shaping. Observing a member job queued, running, needing +you or done changes it to Building, which persists even if jobs return to Backlog. +Only marking the workstream done gives Done; all jobs merged does not. Reopening +returns it to Building. Marking done can archive its member chats while keeping +the agent's record. Collection rename and membership commands use the same store. + +## Workstream pages, records, plans and go-aheads + +Pages, page history, plans, decisions, questions and settings live in SQLite. +Markdown is an export, not a second store. A new workstream has an Overview page; +it can be renamed but cannot move or be deleted. Other pages can form a tree; +deleting a page deletes its descendants. Saving a stale page or plan refuses +without changing it. Restoring a page creates a new version. Page titles take +1–200 bytes and editor documents at most 1 MiB; record text takes 1–2000 bytes. +Client IDs, titles and source labels refuse control characters. Why, how it went, +record text and draft text allow newline and tab, and refuse other control +characters. Malformed app requests return `bad_params`. + +Records can be proposed, kept, dropped and replaced. Keep can undo a drop; +a replaced record cannot change again. Plans store milestones, ordered waves +and job or draft items. Wave auto-start is stored without starting jobs. + +A go-ahead requires a member job, a planned wave or milestone, a positive cap +at most $10,000 and an end in the future at most 90 days away. Expired grants +read as ended. Marking done ends every still-active grant; granting on a done +workstream is refused. These are stored permissions; Factory enforcement is a +separate capability. Workstream numbers use recorded job facts and agent spend. +Updating, marking done and reopening persist the first observed start and return +those numbers using the same reads as getting the workstream. +Unavailable readings stay absent; a measured zero is present. Budgets and autonomy +settings do not themselves start work. diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go new file mode 100644 index 0000000000..f61626e0d5 --- /dev/null +++ b/internal/workspace/schema3_test.go @@ -0,0 +1,212 @@ +package workspace + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// Part A (a): this literal is schema 2, independent of the schema being written. +const schemaTwoDDL = ` +CREATE TABLE collections (seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,created_at INTEGER NOT NULL DEFAULT 0); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT,collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process')), + ref_id TEXT NOT NULL,session_id TEXT NOT NULL,target_collection TEXT REFERENCES collections(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL DEFAULT 0, + CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) OR (kind!='collection' AND target_collection IS NULL)), + UNIQUE(collection_id,kind,ref_id,session_id)); +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_target ON memberships(target_collection); +PRAGMA application_id=1095123788; PRAGMA user_version=2; +INSERT INTO collections(seq,id,name,created_at) VALUES (4,'aaaa','Product',1234),(9,'bbbb','Nested',2345); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) VALUES + (2,'aaaa','conversation','chat','',NULL,4567),(6,'aaaa','task','3','chat',NULL,5678), + (11,'aaaa','collection','bbbb','','bbbb',6789); +` + +func writeSchemaTwo(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "two.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(schemaTwoDDL); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + return path +} + +func TestPartASchemaTwoReadsWithoutWritingAndMigratesAllRows(t *testing.T) { + ctx := context.Background() + path := writeSchemaTwo(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + wantC, wantM := legacyRows(t, s.db) + list, err := s.List(ctx, &Ref{Kind: ConversationKind, ID: "chat"}) + if err != nil || len(list) != 2 || list[0].CreatedAt != 1234 || list[0].LastAddedAt != 6789 || !*list[0].Holds { + t.Fatalf("schema 2 read: %+v, %v", list, err) + } + tree, err := s.Tree(ctx, "aaaa", 0, 20, "") + if err != nil || len(tree.Members) != 3 || tree.Members[0].AddedAt != 6789 { + t.Fatalf("tree: %+v, %v", tree, err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("read changed schema 2") + } + if err := s.Rename(ctx, "aaaa", "Products"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 3 { + t.Fatal("first write did not migrate to 3") + } + gotC, gotM := legacyRows(t, s.db) + wantC[0] = strings.Replace(wantC[0], "Product|", "Products|", 1) + if !reflect.DeepEqual(gotC, wantC) || !reflect.DeepEqual(gotM, wantM) { + t.Fatalf("migration lost rows: %v %v", gotC, gotM) + } + rows, err := s.db.Query("SELECT seq,created_at FROM collections ORDER BY seq") + if err != nil { + t.Fatal(err) + } + var collectionTimes [][2]int64 + for rows.Next() { + var seq, at int64 + if err := rows.Scan(&seq, &at); err != nil { + t.Fatal(err) + } + collectionTimes = append(collectionTimes, [2]int64{seq, at}) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(collectionTimes, [][2]int64{{4, 1234}, {9, 2345}}) { + t.Fatalf("collection times changed: %+v", collectionTimes) + } + rows, err = s.db.Query("SELECT seq,added_at FROM memberships ORDER BY seq") + if err != nil { + t.Fatal(err) + } + var memberTimes [][2]int64 + for rows.Next() { + var seq, at int64 + if err := rows.Scan(&seq, &at); err != nil { + t.Fatal(err) + } + memberTimes = append(memberTimes, [2]int64{seq, at}) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(memberTimes, [][2]int64{{2, 4567}, {6, 5678}, {11, 6789}}) { + t.Fatalf("membership times changed: %+v", memberTimes) + } + for _, name := range []string{"workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} { + var n int + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n); err != nil || n != 1 { + t.Fatalf("missing %s: %v", name, err) + } + } + if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "17", Factory: "factory-a"}); err != nil { + t.Fatal(err) + } +} + +func TestPartASchemaTwoMigrationRollsBackWithFailedWrite(t *testing.T) { + path := writeSchemaTwo(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + if err := s.Rename(context.Background(), "aaaa", "Nested"); err != ErrNameTaken { + t.Fatalf("want name conflict: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) || userVersion(t, s) != 2 { + t.Fatal("failed first write changed file") + } +} + +func TestPartAFactoryJobRefValidation(t *testing.T) { + for _, tc := range []struct { + ref Ref + ok bool + }{ + {Ref{Kind: FactoryJobKind, ID: "17", Factory: "f"}, true}, + {Ref{Kind: FactoryJobKind, ID: "0", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "017", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "+17", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17", Factory: " f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17", Factory: "f", SessionID: "chat"}, false}, + {Ref{Kind: ConversationKind, ID: "chat", Factory: "f"}, false}, + } { + if err := tc.ref.Validate(); (err == nil) != tc.ok { + t.Errorf("%+v: %v", tc.ref, err) + } + } +} + +// Part A (a): deleted sequence numbers remain retired across both migrations, +// preserving the monotonic keys used by existing listing and member cursors. +func TestPartAMigrationPreservesSequenceHighWatermarks(t *testing.T) { + for _, tc := range []struct { + name string + fixture func(*testing.T) string + }{ + {"schema 1", writeLegacyStore}, {"schema 2", writeSchemaTwo}, + } { + t.Run(tc.name, func(t *testing.T) { + s := openTestStore(t, tc.fixture(t)) + ctx := context.Background() + if _, err := s.db.Exec(`INSERT INTO collections(seq,id,name) VALUES (100,'gone','Gone'); +DELETE FROM collections WHERE id='gone'; +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id) VALUES (200,'aaaa','conversation','gone',''); +DELETE FROM memberships WHERE ref_id='gone';`); err != nil { + t.Fatal(err) + } + c, err := s.Create(ctx, "After") + if err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, Ref{Kind: FactoryJobKind, ID: "1", Factory: "f"}); err != nil { + t.Fatal(err) + } + var collectionSeq, memberSeq int64 + if err := s.db.QueryRow("SELECT seq FROM collections WHERE id=?", c.ID).Scan(&collectionSeq); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT seq FROM memberships WHERE collection_id=?", c.ID).Scan(&memberSeq); err != nil { + t.Fatal(err) + } + if collectionSeq <= 100 || memberSeq <= 200 { + t.Fatalf("reused retired sequences: collection %d member %d", collectionSeq, memberSeq) + } + }) + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index 6319953420..6f2a5c33bc 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -20,13 +20,10 @@ import ( sqlite3 "modernc.org/sqlite/lib" ) -// schemaVersion is the schema this build writes. Schema 2 added when a -// collection was made and when each membership was added, the crew and -// process kinds, and deletes that cascade. legacySchemaVersion is the one -// older schema this build still reads as it is and migrates on its first -// write; anything else claiming to be ours is refused, never misread. +// schemaVersion is the schema this build writes. Schemas 1 and 2 remain +// readable without migration; the first successful write upgrades them to 3. const ( - schemaVersion = 2 + schemaVersion = 3 legacySchemaVersion = 1 ) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. @@ -43,6 +40,7 @@ const ( schemaBlank schemaState = iota schemaReady schemaLegacy + schemaTwo schemaRefused ) @@ -51,13 +49,15 @@ func (s schemaState) version() int { switch s { case schemaReady: return schemaVersion + case schemaTwo: + return 2 case schemaLegacy: return legacySchemaVersion } return 0 } -// Store owns collection metadata only. Transcripts, task checkpoints, standing +// Store owns collection and workstream metadata. Transcripts, task checkpoints, standing // items and artifacts retain their existing owners and persistence formats. // // THE SCHEMA IS BUILT BY THE FIRST WRITE THAT NEEDS IT, never by Open. Opening @@ -66,7 +66,7 @@ func (s schemaState) version() int { // lock to do it — which is what put a plain read behind whichever peer happened // to be adding a membership. `version` is that question already answered (0 // while the file is blank), and `schemaMu` keeps two goroutines on one handle -// from both answering it. The same law covers migration: a schema-1 file is +// from both answering it. The same law covers migration: a schema-1 or schema-2 file is // read as it stands and rebuilt only by the first write. type Store struct { db *sql.DB @@ -174,10 +174,16 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := q.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return schemaBlank, err } - if app == applicationID && (version == schemaVersion || version == legacySchemaVersion) { + if app == applicationID && (version == schemaVersion || version == 2 || version == legacySchemaVersion) { state := schemaReady collections := "SELECT seq,id,name,created_at FROM collections LIMIT 0" memberships := "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at FROM memberships LIMIT 0" + if version == 2 { + state = schemaTwo + } + if version == schemaVersion { + memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships LIMIT 0" + } if version == legacySchemaVersion { state = schemaLegacy collections = "SELECT seq,id,name FROM collections LIMIT 0" @@ -191,6 +197,13 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if _, err := q.ExecContext(ctx, memberships); err != nil { return state, err } + if version == schemaVersion { + for _, query := range workstreamSchemaReads { + if _, err := q.ExecContext(ctx, query); err != nil { + return state, err + } + } + } return state, nil } if app != 0 || version != 0 { @@ -205,7 +218,7 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { return schemaBlank, nil } -// schemaTables is schema 2's two tables under the names given, so the same +// schemaTables builds the collection tables under the names given, so the same // words build a fresh store and the copies a migration fills. // // EVERY DELETE CASCADES: a deleted collection takes its own memberships and @@ -222,24 +235,26 @@ CREATE TABLE %[1]s ( CREATE TABLE %[2]s ( seq INTEGER PRIMARY KEY AUTOINCREMENT, collection_id TEXT NOT NULL REFERENCES %[1]s(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process')), + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process','factory_job')), ref_id TEXT NOT NULL, session_id TEXT NOT NULL, target_collection TEXT REFERENCES %[1]s(id) ON DELETE CASCADE, added_at INTEGER NOT NULL DEFAULT 0, + factory TEXT NOT NULL DEFAULT '', + CHECK ((kind='factory_job' AND length(factory)>0) OR (kind!='factory_job' AND factory='')), CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) OR (kind!='collection' AND target_collection IS NULL)), - UNIQUE(collection_id,kind,ref_id,session_id) + UNIQUE(collection_id,kind,ref_id,session_id,factory) ); `, collections, memberships) } const schemaIndexes = ` -CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id,factory); CREATE INDEX memberships_target ON memberships(target_collection); ` -// migrateLegacy rebuilds a schema-1 file as schema 2 inside the caller's +// migrateLegacy rebuilds a schema-1 or schema-2 file as schema 3 inside the caller's // immediate transaction: either every row arrives in the new tables, in the // same order under the same sequence numbers, or the file is left exactly as // it was. A row from schema 1 says nothing of when it was made, so its @@ -248,64 +263,77 @@ CREATE INDEX memberships_target ON memberships(target_collection); // THE TABLES ARE COPIED, NOT ALTERED, because SQLite cannot change a CHECK or // a foreign key in place. The old memberships go first so dropping the old // collections deletes nothing anything still points at, and renaming the copy -// carries the foreign keys that name it along. -func migrateLegacy(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, schemaTables("collections_v2", "memberships_v2")+` -INSERT INTO collections_v2(seq,id,name,created_at) SELECT seq,id,name,0 FROM collections ORDER BY seq; -INSERT INTO memberships_v2(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) - SELECT seq,collection_id,kind,ref_id,session_id,target_collection,0 FROM memberships ORDER BY seq; +// carries the foreign keys that name it along. Sequence high-water marks also +// survive deleted rows so migration cannot reuse a sequence named by an older +// page cursor. +func migrateLegacy(ctx context.Context, tx *sql.Tx, version int) error { + created, added := "0", "0" + if version >= 2 { + created, added = "created_at", "added_at" + } + _, err := tx.ExecContext(ctx, schemaTables("collections_v3", "memberships_v3")+fmt.Sprintf(` +INSERT INTO collections_v3(seq,id,name,created_at) SELECT seq,id,name,%s FROM collections ORDER BY seq; +INSERT INTO memberships_v3(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) + SELECT seq,collection_id,kind,ref_id,session_id,target_collection,%s FROM memberships ORDER BY seq; +UPDATE sqlite_sequence SET seq=max(seq,coalesce((SELECT seq FROM sqlite_sequence WHERE name='collections'),0)) + WHERE name='collections_v3'; +INSERT INTO sqlite_sequence(name,seq) SELECT 'collections_v3',seq FROM sqlite_sequence + WHERE name='collections' AND NOT EXISTS(SELECT 1 FROM sqlite_sequence WHERE name='collections_v3'); +UPDATE sqlite_sequence SET seq=max(seq,coalesce((SELECT seq FROM sqlite_sequence WHERE name='memberships'),0)) + WHERE name='memberships_v3'; +INSERT INTO sqlite_sequence(name,seq) SELECT 'memberships_v3',seq FROM sqlite_sequence + WHERE name='memberships' AND NOT EXISTS(SELECT 1 FROM sqlite_sequence WHERE name='memberships_v3'); DROP TABLE memberships; DROP TABLE collections; -ALTER TABLE collections_v2 RENAME TO collections; -ALTER TABLE memberships_v2 RENAME TO memberships; -`+schemaIndexes) +ALTER TABLE collections_v3 RENAME TO collections; +ALTER TABLE memberships_v3 RENAME TO memberships; +`, created, added)+schemaIndexes+workstreamSchema) return err } -// ensureSchema serializes initialization on one handle and then takes SQLite's -// immediate transaction so separate processes also agree which one builds it, -// or which one migrates a schema-1 file to this one. -func (s *Store) ensureSchema(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return storeError(err) - } - s.schemaMu.Lock() - defer s.schemaMu.Unlock() - if s.version == schemaVersion { - return nil +// beginWrite ensures schema and data changes share one immediate transaction. +// A failed first write rolls back the migration as well as the requested change. +func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { + if !create { + version, err := s.readyForRead(ctx) + if err != nil { + return nil, err + } + if version == 0 { + return nil, ErrNotFound + } } tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return storeError(err) + return nil, storeError(err) } - defer tx.Rollback() state, err := inspectSchema(ctx, tx) - if err != nil { - return storeError(err) - } - switch state { - case schemaReady: - if err := tx.Commit(); err != nil { - return storeError(err) + if err == nil { + switch state { + case schemaReady: + case schemaLegacy, schemaTwo: + err = migrateLegacy(ctx, tx, state.version()) + case schemaBlank: + if !create { + err = ErrNotFound + } else { + _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes+workstreamSchema) + } } - s.version = schemaVersion - return nil - case schemaLegacy: - err = migrateLegacy(ctx, tx) - default: - _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes) } - if err != nil { - return storeError(err) - } - if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)); err != nil { - return storeError(err) + if err == nil && state != schemaReady { + _, err = tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)) } - if err := tx.Commit(); err != nil { - return storeError(err) + if err != nil { + _ = tx.Rollback() + return nil, storeError(err) } - s.version = schemaVersion - return nil + return tx, nil +} + +// ensureSchema is used by bulk fixtures that initialize without adding rows. +func (s *Store) ensureSchema(ctx context.Context) error { + return s.Write(ctx, func(*sql.Tx) error { return nil }) } // readyForRead answers the schema a read goes through, and 0 for a blank @@ -329,26 +357,10 @@ func (s *Store) readyForRead(ctx context.Context) (int, error) { return s.version, nil } -// readyForWrite is readyForRead for a change to a collection that must -// already exist: false on a blank file, which such a change cannot find -// anything in and must leave blank, and a schema-1 file migrated first. -func (s *Store) readyForWrite(ctx context.Context) (bool, error) { - version, err := s.readyForRead(ctx) - if err != nil || version == 0 { - return false, err - } - if version != schemaVersion { - if err := s.ensureSchema(ctx); err != nil { - return false, err - } - } - return true, nil -} - // columns are the expressions a read uses for created_at and added_at: the // columns themselves at schema 2, and the unknown 0 on a schema-1 file. func columns(version int) (created, added string) { - if version >= schemaVersion { + if version >= 2 { return "c.created_at", "m.added_at" } return "0", "0" @@ -409,19 +421,13 @@ func (s *Store) Create(ctx context.Context, name string) (Collection, error) { if err != nil { return Collection{}, storeError(err) } - if err := s.ensureSchema(ctx); err != nil { - return Collection{}, storeError(err) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, true) if err != nil { return Collection{}, storeError(err) } defer tx.Rollback() - if err := nameTaken(ctx, tx, name, ""); err != nil { - return Collection{}, storeError(err) - } c := Collection{ID: id, Name: name} - if _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", c.ID, c.Name, s.millis()); err != nil { + if err := CreateIn(ctx, tx, id, name, s.millis()); err != nil { return Collection{}, storeError(err) } if err := tx.Commit(); err != nil { @@ -434,25 +440,15 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { if err := ValidateName(name); err != nil { return storeError(err) } - ready, err := s.readyForWrite(ctx) - if err != nil { - return storeError(err) - } - if !ready { - return storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return storeError(err) } defer tx.Rollback() - if err := requireCollection(ctx, tx, id); err != nil { - return storeError(err) - } - if err := nameTaken(ctx, tx, name, id); err != nil { + if err := RenameIn(ctx, tx, id, name); err != nil { return storeError(err) } - if _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id); err != nil { + if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { return storeError(err) } return storeError(tx.Commit()) @@ -463,14 +459,7 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { // first, because each of them changed too. The records any of it referenced // are untouched. func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { - ready, err := s.readyForWrite(ctx) - if err != nil { - return nil, storeError(err) - } - if !ready { - return nil, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, storeError(err) } @@ -478,6 +467,13 @@ func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { if err := requireCollection(ctx, tx, id); err != nil { return nil, storeError(err) } + var isWorkstream bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstreams WHERE id=?)", id).Scan(&isWorkstream); err != nil { + return nil, storeError(err) + } + if isWorkstream { + return nil, ErrWorkstreamDelete + } rows, err := tx.QueryContext(ctx, `SELECT c.id FROM collections c WHERE c.id!=? AND EXISTS( SELECT 1 FROM memberships m WHERE m.collection_id=c.id AND m.kind='collection' AND m.ref_id=?) ORDER BY c.seq`, id, id) if err != nil { @@ -495,6 +491,11 @@ func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { if err := rows.Close(); err != nil { return nil, storeError(err) } + for _, parent := range parents { + if err := touchWorkstream(ctx, tx, parent, s.millis()); err != nil { + return nil, storeError(err) + } + } if _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", id); err != nil { return nil, storeError(err) } @@ -511,18 +512,11 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, err if err := ValidateName(name); err != nil { return Collection{}, storeError(err) } - ready, err := s.readyForWrite(ctx) - if err != nil { - return Collection{}, storeError(err) - } - if !ready { - return Collection{}, storeError(ErrNotFound) - } copyID, err := newID() if err != nil { return Collection{}, storeError(err) } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return Collection{}, storeError(err) } @@ -536,8 +530,8 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, err if _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", copyID, name, s.millis()); err != nil { return Collection{}, storeError(err) } - if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at) - SELECT ?,kind,ref_id,session_id,target_collection,added_at FROM memberships WHERE collection_id=? ORDER BY seq`, copyID, id); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at,factory) + SELECT ?,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships WHERE collection_id=? ORDER BY seq`, copyID, id); err != nil { return Collection{}, storeError(err) } if err := tx.Commit(); err != nil { @@ -547,27 +541,30 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, err } func (s *Store) Collections(ctx context.Context) ([]Collection, error) { - version, err := s.readyForRead(ctx) - if err != nil { - return nil, storeError(err) - } - if version == 0 { - return make([]Collection, 0), nil - } - rows, err := s.db.QueryContext(ctx, "SELECT id,name FROM collections ORDER BY seq") + out := make([]Collection, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT c.id,c.name,"+workstreamColumn(version)+" FROM collections c ORDER BY seq") + if err != nil { + return err + } + defer rows.Close() + out, err = readCollections(rows) + return err + }) if err != nil { - return nil, storeError(err) + return nil, err } - defer rows.Close() - result, err := readCollections(rows) - return result, storeError(err) + return out, nil } func readCollections(rows *sql.Rows) ([]Collection, error) { result := make([]Collection, 0) for rows.Next() { var c Collection - if err := rows.Scan(&c.ID, &c.Name); err != nil { + if err := rows.Scan(&c.ID, &c.Name, &c.Workstream); err != nil { return nil, err } result = append(result, c) @@ -610,22 +607,36 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb return nil, nil, storeError(fmt.Errorf("%w: added_at cannot be negative", ErrInvalid)) } } - ready, err := s.readyForWrite(ctx) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, nil, storeError(err) } - if !ready { - return nil, nil, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + defer tx.Rollback() + added, already, err := AddRefsIn(ctx, tx, id, refs, s.millis()) if err != nil { return nil, nil, storeError(err) } - defer tx.Rollback() + if err := tx.Commit(); err != nil { + return nil, nil, storeError(err) + } + return added, already, nil +} + +// AddRefsIn applies collection rules inside a transaction obtained from Write. +// It is shared with workstream creation so memberships and metadata commit together. +func AddRefsIn(ctx context.Context, tx *sql.Tx, id string, refs []Addition, now int64) ([]Member, []Ref, error) { + for _, ref := range refs { + if err := ref.Validate(); err != nil { + return nil, nil, err + } + if ref.AddedAt < 0 { + return nil, nil, ErrInvalid + } + } if err := requireCollection(ctx, tx, id); err != nil { return nil, nil, storeError(err) } - now := s.millis() + added, already := make([]Member, 0, len(refs)), make([]Ref, 0) for _, ref := range refs { var target any @@ -635,12 +646,15 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb } target = ref.ID } + if err := checkWorkstreamJob(ctx, tx, id, ref.Ref); err != nil { + return nil, nil, err + } at := ref.AddedAt if at == 0 { at = now } - result, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at) - VALUES (?,?,?,?,?,?) ON CONFLICT(collection_id,kind,ref_id,session_id) DO NOTHING`, id, ref.Kind, ref.ID, ref.SessionID, target, at) + result, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at,factory) + VALUES (?,?,?,?,?,?,?) ON CONFLICT(collection_id,kind,ref_id,session_id,factory) DO NOTHING`, id, ref.Kind, ref.ID, ref.SessionID, target, at, ref.Factory) if err != nil { return nil, nil, storeError(err) } @@ -654,8 +668,10 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb added = append(added, Member{Ref: ref.Ref, AddedAt: at}) } } - if err := tx.Commit(); err != nil { - return nil, nil, storeError(err) + if len(added) > 0 { + if err := touchWorkstream(ctx, tx, id, now); err != nil { + return nil, nil, err + } } return added, already, nil } @@ -701,14 +717,7 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo if err := to.Validate(); err != nil { return Member{}, false, storeError(err) } - ready, err := s.readyForWrite(ctx) - if err != nil { - return Member{}, false, storeError(err) - } - if !ready { - return Member{}, false, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return Member{}, false, storeError(err) } @@ -717,8 +726,8 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo return Member{}, false, storeError(err) } membership := func(ref Ref) (seq, at int64, err error) { - err = tx.QueryRowContext(ctx, "SELECT seq,added_at FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=?", - id, ref.Kind, ref.ID, ref.SessionID).Scan(&seq, &at) + err = tx.QueryRowContext(ctx, "SELECT seq,added_at FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? AND factory=?", + id, ref.Kind, ref.ID, ref.SessionID, ref.Factory).Scan(&seq, &at) return seq, at, err } seq, at, err := membership(from) @@ -735,6 +744,9 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo if _, err := tx.ExecContext(ctx, "DELETE FROM memberships WHERE seq=?", seq); err != nil { return Member{}, false, storeError(err) } + if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { + return Member{}, false, err + } if err := tx.Commit(); err != nil { return Member{}, false, storeError(err) } @@ -742,6 +754,9 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo } else if !errors.Is(err, sql.ErrNoRows) { return Member{}, false, storeError(err) } + if err := checkWorkstreamJob(ctx, tx, id, to); err != nil { + return Member{}, false, err + } var target any if to.Kind == CollectionKind { if err := nestable(ctx, tx, id, to.ID); err != nil { @@ -749,10 +764,13 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo } target = to.ID } - if _, err := tx.ExecContext(ctx, "UPDATE memberships SET kind=?,ref_id=?,session_id=?,target_collection=? WHERE seq=?", - to.Kind, to.ID, to.SessionID, target, seq); err != nil { + if _, err := tx.ExecContext(ctx, "UPDATE memberships SET kind=?,ref_id=?,session_id=?,target_collection=?,factory=? WHERE seq=?", + to.Kind, to.ID, to.SessionID, target, to.Factory, seq); err != nil { return Member{}, false, storeError(err) } + if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { + return Member{}, false, err + } if err := tx.Commit(); err != nil { return Member{}, false, storeError(err) } @@ -774,14 +792,7 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member return nil, storeError(err) } } - ready, err := s.readyForWrite(ctx) - if err != nil { - return nil, storeError(err) - } - if !ready { - return nil, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, storeError(err) } @@ -792,8 +803,8 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member removed := make([]Member, 0, len(refs)) for _, ref := range refs { var at int64 - err := tx.QueryRowContext(ctx, "DELETE FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? RETURNING added_at", - id, ref.Kind, ref.ID, ref.SessionID).Scan(&at) + err := tx.QueryRowContext(ctx, "DELETE FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? AND factory=? RETURNING added_at", + id, ref.Kind, ref.ID, ref.SessionID, ref.Factory).Scan(&at) if errors.Is(err, sql.ErrNoRows) { continue } @@ -802,6 +813,11 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member } removed = append(removed, Member{Ref: ref, AddedAt: at}) } + if len(removed) > 0 { + if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { + return nil, err + } + } if err := tx.Commit(); err != nil { return nil, storeError(err) } @@ -809,55 +825,56 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member } func (s *Store) Members(ctx context.Context, id string) ([]Ref, error) { - version, err := s.readyForRead(ctx) - if err != nil { - return nil, storeError(err) - } - if version == 0 { - return nil, storeError(ErrNotFound) - } - var exists bool - if err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM collections WHERE id=?)", id).Scan(&exists); err != nil { - return nil, storeError(err) - } - if !exists { - return nil, storeError(ErrNotFound) - } - rows, err := s.db.QueryContext(ctx, "SELECT kind,ref_id,session_id FROM memberships WHERE collection_id=? ORDER BY seq", id) - if err != nil { - return nil, storeError(err) - } - defer rows.Close() - result := make([]Ref, 0) - for rows.Next() { - var ref Ref - if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID); err != nil { - return nil, storeError(err) + out := make([]Ref, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return ErrNotFound + } + if err := requireCollection(ctx, tx, id); err != nil { + return err } - result = append(result, ref) + rows, err := tx.QueryContext(ctx, "SELECT kind,ref_id,session_id,"+factoryColumn(version)+" FROM memberships WHERE collection_id=? ORDER BY seq", id) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var ref Ref + if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID, &ref.Factory); err != nil { + return err + } + out = append(out, ref) + } + return rows.Err() + }) + if err != nil { + return nil, err } - return result, storeError(rows.Err()) + return out, nil } func (s *Store) CollectionsFor(ctx context.Context, ref Ref) ([]Collection, error) { if err := ref.Validate(); err != nil { return nil, storeError(err) } - version, err := s.readyForRead(ctx) - if err != nil { - return nil, storeError(err) - } - if version == 0 { - return make([]Collection, 0), nil - } - rows, err := s.db.QueryContext(ctx, `SELECT c.id,c.name FROM collections c JOIN memberships m ON m.collection_id=c.id - WHERE m.kind=? AND m.ref_id=? AND m.session_id=? ORDER BY c.seq`, ref.Kind, ref.ID, ref.SessionID) + out := make([]Collection, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return nil + } + rows, err := tx.QueryContext(ctx, `SELECT c.id,c.name,`+workstreamColumn(version)+` FROM collections c JOIN memberships m ON m.collection_id=c.id + WHERE m.kind=? AND m.ref_id=? AND m.session_id=? AND `+factoryColumn(version)+`=? ORDER BY c.seq`, ref.Kind, ref.ID, ref.SessionID, ref.Factory) + if err != nil { + return err + } + defer rows.Close() + out, err = readCollections(rows) + return err + }) if err != nil { - return nil, storeError(err) + return nil, err } - defer rows.Close() - result, err := readCollections(rows) - return result, storeError(err) + return out, nil } // storeError replaces SQLite's lock account with the stable sentence exposed by diff --git a/internal/workspace/views.go b/internal/workspace/views.go index 1f235ad2c8..3c8e78b4a3 100644 --- a/internal/workspace/views.go +++ b/internal/workspace/views.go @@ -136,18 +136,14 @@ func (s *Store) ListPage(ctx context.Context, holds *Ref, after string, limit in } from = c.S } - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Page{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Page{Collections: make([]Summary, 0)}, nil } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Page{}, storeError(err) - } - defer tx.Rollback() var page Page if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM collections").Scan(&page.Total); err != nil { return Page{}, storeError(err) @@ -183,18 +179,14 @@ func (s *Store) List(ctx context.Context, holds *Ref) ([]Summary, error) { // Describe is one collection as List says it. func (s *Store) Describe(ctx context.Context, id string) (Summary, error) { - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Summary{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Summary{}, storeError(ErrNotFound) } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Summary{}, storeError(err) - } - defer tx.Rollback() out, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Summary{}, storeError(err) @@ -212,8 +204,8 @@ func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only st held := "0" args := []any{} if holds != nil { - held = "EXISTS(SELECT 1 FROM memberships h WHERE h.collection_id=c.id AND h.kind=? AND h.ref_id=? AND h.session_id=?)" - args = append(args, holds.Kind, holds.ID, holds.SessionID) + held = "EXISTS(SELECT 1 FROM memberships h WHERE h.collection_id=c.id AND h.kind=? AND h.ref_id=? AND h.session_id=? AND " + factoryColumn(version) + "=?)" + args = append(args, holds.Kind, holds.ID, holds.SessionID, holds.Factory) } var where []string if only != "" { @@ -232,8 +224,8 @@ func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only st rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT c.seq,c.id,c.name,%s, (SELECT count(*) FROM memberships m WHERE m.collection_id=c.id), (SELECT coalesce(max(%s),0) FROM memberships m WHERE m.collection_id=c.id), - %s -FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, clause), args...) + %s, %s +FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, workstreamColumn(version), clause), args...) if err != nil { return nil, err } @@ -242,7 +234,7 @@ FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, clause), ar for rows.Next() { var c Summary var h bool - if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h); err != nil { + if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h, &c.Workstream); err != nil { return nil, err } if holds != nil { @@ -320,18 +312,14 @@ func (s *Store) Tree(ctx context.Context, id string, depth, limit int, after str } start = &levelAfter{added: c.A, seq: c.S} } - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Tree{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Tree{}, storeError(ErrNotFound) } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Tree{}, storeError(err) - } - defer tx.Rollback() top, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Tree{}, storeError(err) @@ -415,14 +403,14 @@ func levelOf(ctx context.Context, tx *sql.Tx, version int, id string, limit int, // A schema-1 file knows no moments, so its order is insertion alone; a // constant in ORDER BY would be read as a column number. order := "m.seq DESC" - if version >= schemaVersion { + if version >= 2 { order = "m.added_at DESC, m.seq DESC" } args = append(args, limit+1) - rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT m.seq,m.kind,m.ref_id,m.session_id,%s,coalesce(c.name,''),c.id IS NOT NULL, + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT m.seq,m.kind,m.ref_id,m.session_id,%s,%s,coalesce(c.name,''),c.id IS NOT NULL, CASE WHEN m.kind='collection' THEN (SELECT count(*) FROM memberships x WHERE x.collection_id=m.ref_id) ELSE 0 END FROM memberships m LEFT JOIN collections c ON m.kind='collection' AND c.id=m.ref_id -WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, from, order), args...) +WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, factoryColumn(version), from, order), args...) if err != nil { return nil, false, err } @@ -434,7 +422,7 @@ WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, from, order), args...) break } var n Node - if err := rows.Scan(&n.seq, &n.Kind, &n.ID, &n.SessionID, &n.AddedAt, &n.Name, &n.Found, &n.Count); err != nil { + if err := rows.Scan(&n.seq, &n.Kind, &n.ID, &n.SessionID, &n.AddedAt, &n.Factory, &n.Name, &n.Found, &n.Count); err != nil { return nil, false, err } nodes = append(nodes, n) @@ -467,18 +455,14 @@ type Expansion struct { // always say what it is not showing. func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, error) { depth = max(1, min(depth, ExpandDepthMax)) - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Expansion{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Expansion{}, storeError(ErrNotFound) } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Expansion{}, storeError(err) - } - defer tx.Rollback() top, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Expansion{}, storeError(err) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 647c004f46..4e2dde954d 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -22,8 +22,9 @@ const ( // CrewKind names a crew by its id; ProcessKind names a process a // conversation started, by its id, and like a task carries that // conversation as its session. Both arrived with schema 2. - CrewKind Kind = "crew" - ProcessKind Kind = "process" + CrewKind Kind = "crew" + ProcessKind Kind = "process" + FactoryJobKind Kind = "factory_job" // StandingKind is an older build's reference to a standing item. Standing // items are gone (automations replaced them), so the collections command // refuses a new one, but a row that already names one is still a row: it @@ -57,11 +58,13 @@ type Ref struct { Kind Kind `json:"kind"` ID string `json:"id"` SessionID string `json:"session_id,omitempty"` + Factory string `json:"factory,omitempty"` } type Collection struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + Workstream bool `json:"workstream,omitempty"` } // Member is a reference as one collection holds it, with when it was added in @@ -71,12 +74,17 @@ type Member struct { AddedAt int64 `json:"added_at,omitempty"` } -// key is a reference's identity, the three columns the uniqueness rule reads. -func (r Ref) key() string { return string(r.Kind) + "\x00" + r.ID + "\x00" + r.SessionID } +// key includes the factory because job numbers are local to one factory. +func (r Ref) key() string { + return string(r.Kind) + "\x00" + r.ID + "\x00" + r.SessionID + "\x00" + r.Factory +} // Validate checks the reference shape without asking an execution owner to // open it. A temporarily unavailable record must remain discoverable here. func (r Ref) Validate() error { + if r.Kind != FactoryJobKind && r.Factory != "" { + return fmt.Errorf("%w: only a factory_job reference has a factory", ErrInvalid) + } if !validText(r.ID, 4096) { return fmt.Errorf("%w: record id is empty, too long or contains control characters", ErrInvalid) } @@ -84,6 +92,11 @@ func (r Ref) Validate() error { return fmt.Errorf("%w: only a task or a process reference has a session id", ErrInvalid) } switch r.Kind { + case FactoryJobKind: + n, err := strconv.ParseUint(r.ID, 10, 63) + if err != nil || n == 0 || strconv.FormatUint(n, 10) != r.ID || !validText(r.Factory, 4096) { + return fmt.Errorf("%w: a factory_job needs a factory and a positive canonical job number", ErrInvalid) + } case CollectionKind, ConversationKind, StandingKind, CrewKind: case ProcessKind: if !validText(r.SessionID, 1024) { diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go new file mode 100644 index 0000000000..46de35b2f5 --- /dev/null +++ b/internal/workspace/workstreams.go @@ -0,0 +1,233 @@ +package workspace + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "time" +) + +// These bounds are shared by SQLite constraints and the domain validator. +const ( + WorkstreamGoAheadCapMax = 10000 + WorkstreamGoAheadDurationMax = 90 * 24 * time.Hour +) + +var ErrWorkstreamDelete = errors.New("mark a workstream done instead") + +// WorkstreamTaken identifies the existing owner so adapters can explain an +// atomic refusal without another read that could race a rename. +type WorkstreamTaken struct { + Factory string + Job int + WorkstreamID string + WorkstreamName string +} + +func (e *WorkstreamTaken) Error() string { return "that job belongs to another workstream" } + +// Write runs fn in the store's immediate transaction, including any schema +// upgrade. Returning an error rolls back everything. The callback must use tx +// rather than reenter the Store, which owns a single database connection. +func (s *Store) Write(ctx context.Context, fn func(tx *sql.Tx) error) error { + tx, err := s.beginWrite(ctx, true) + if err != nil { + return storeError(err) + } + defer tx.Rollback() + if err := fn(tx); err != nil { + return storeError(err) + } + return storeError(tx.Commit()) +} + +// Read runs fn against a consistent read-only snapshot. Version 0 means blank; +// workstream readers must answer empty/not found for every version below 3. +// Reads never initialize or migrate the database, and share Write's busy mapping. +func (s *Store) Read(ctx context.Context, fn func(tx *sql.Tx, version int) error) error { + tx, version, err := s.beginRead(ctx) + if err != nil { + return err + } + defer tx.Rollback() + return storeError(fn(tx, version)) +} + +// beginRead keeps the schema decision and projected rows in one snapshot. A +// peer may migrate between separate transactions, adding fields an old projection +// cannot supply (notably factory on a new job ref). +func (s *Store) beginRead(ctx context.Context) (*sql.Tx, int, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, 0, storeError(err) + } + state, err := inspectSchema(ctx, tx) + if err != nil { + _ = tx.Rollback() + return nil, 0, storeError(err) + } + return tx, state.version(), nil +} + +// CreateIn preserves collection naming rules within a larger atomic write. +func CreateIn(ctx context.Context, tx *sql.Tx, id, name string, at int64) error { + if err := ValidateName(name); err != nil { + return err + } + if err := nameTaken(ctx, tx, name, ""); err != nil { + return err + } + _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", id, name, at) + return err +} + +// RenameIn preserves Unicode name uniqueness within a larger atomic write. +func RenameIn(ctx context.Context, tx *sql.Tx, id, name string) error { + if err := ValidateName(name); err != nil { + return err + } + if err := requireCollection(ctx, tx, id); err != nil { + return err + } + if err := nameTaken(ctx, tx, name, id); err != nil { + return err + } + _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id) + return err +} + +func touchWorkstream(ctx context.Context, tx *sql.Tx, id string, at int64) error { + _, err := tx.ExecContext(ctx, "UPDATE workstreams SET updated_at=max(updated_at+1,?) WHERE id=?", at, id) + return err +} + +func checkWorkstreamJob(ctx context.Context, tx *sql.Tx, id string, ref Ref) error { + if ref.Kind != FactoryJobKind { + return nil + } + var taken WorkstreamTaken + var isWorkstream bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstreams WHERE id=?)", id).Scan(&isWorkstream); err != nil { + return err + } + if !isWorkstream { + return nil + } + err := tx.QueryRowContext(ctx, `SELECT j.workstream_id,c.name FROM workstream_jobs j JOIN collections c ON c.id=j.workstream_id + WHERE j.factory=? AND j.job=? AND j.workstream_id!=?`, ref.Factory, ref.ID, id).Scan(&taken.WorkstreamID, &taken.WorkstreamName) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + n, _ := strconv.ParseInt(ref.ID, 10, 64) + taken.Factory, taken.Job = ref.Factory, int(n) + return &taken +} + +func factoryColumn(version int) string { + if version >= 3 { + return "factory" + } + return "''" +} + +func workstreamColumn(version int) string { + if version >= 3 { + return "EXISTS(SELECT 1 FROM workstreams w WHERE w.id=c.id)" + } + return "0" +} + +// Claims are maintained by triggers so even SQL writes through Write cannot +// accidentally bypass the unique job owner. Plain collections have no claims. +var workstreamSchema = fmt.Sprintf(` +CREATE TABLE workstreams ( + id TEXT PRIMARY KEY REFERENCES collections(id) ON DELETE CASCADE, + why TEXT NOT NULL DEFAULT '', origin_chat_id TEXT NOT NULL DEFAULT '', agent_id TEXT NOT NULL, + autonomy TEXT NOT NULL DEFAULT 'propose' CHECK(autonomy IN ('propose','small','off')), + wake_morning INTEGER NOT NULL DEFAULT 0, wake_related_issues INTEGER NOT NULL DEFAULT 0, + budget_usd REAL CHECK(budget_usd IS NULL OR budget_usd>0), + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, started_at INTEGER, done_at INTEGER, + how_it_went TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX workstreams_client ON workstreams(client_id) WHERE client_id!=''; +CREATE TABLE workstream_jobs ( + member_seq INTEGER PRIMARY KEY REFERENCES memberships(seq) ON DELETE CASCADE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + factory TEXT NOT NULL, job TEXT NOT NULL +); +CREATE UNIQUE INDEX workstream_job_owner ON workstream_jobs(factory,job); +CREATE TRIGGER workstream_job_insert AFTER INSERT ON memberships + WHEN NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id) + BEGIN INSERT INTO workstream_jobs VALUES(NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id); END; +CREATE TRIGGER workstream_job_update AFTER UPDATE ON memberships + BEGIN + DELETE FROM workstream_jobs WHERE member_seq=OLD.seq; + INSERT INTO workstream_jobs SELECT NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id + WHERE NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id); + END; +CREATE TRIGGER workstream_job_promote AFTER INSERT ON workstreams + BEGIN INSERT INTO workstream_jobs SELECT seq,collection_id,factory,ref_id FROM memberships + WHERE collection_id=NEW.id AND kind='factory_job'; END; +CREATE TABLE workstream_pages ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES workstream_pages(id) ON DELETE CASCADE, + title TEXT NOT NULL, owner TEXT NOT NULL CHECK(owner IN ('you','agent')), + read_by_jobs INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL CHECK(position>=0), + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), updated_from TEXT, + doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)) +); +CREATE INDEX workstream_page_tree ON workstream_pages(workstream_id,parent_id,position); +CREATE UNIQUE INDEX workstream_overview ON workstream_pages(workstream_id) WHERE overview=1; +CREATE TABLE workstream_page_versions ( + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + version INTEGER NOT NULL, saved_at INTEGER NOT NULL, saved_by TEXT NOT NULL CHECK(saved_by IN ('you','agent')), + source TEXT, title TEXT NOT NULL, doc TEXT NOT NULL, bytes INTEGER NOT NULL, + PRIMARY KEY(page_id,version) +); +CREATE TABLE workstream_records ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('decision','question')), text TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped','superseded')), + actor TEXT NOT NULL CHECK(actor IN ('you','agent')), source TEXT, created_at INTEGER NOT NULL, + decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id) +); +CREATE INDEX workstream_record_list ON workstream_records(workstream_id,created_at); +CREATE TABLE workstream_goaheads ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK(scope IN ('job','wave','milestone')), + factory TEXT, job INTEGER, wave INTEGER, milestone_id TEXT, + cap_usd REAL NOT NULL CHECK(cap_usd>0 AND cap_usd<=%d), ends_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK(state IN ('active','ended')), + end_reason TEXT CHECK(end_reason IN ('expired','revoked','workstream_done')), + granted_at INTEGER NOT NULL, ended_at INTEGER, + CHECK((scope='job' AND factory IS NOT NULL AND length(factory)>0 AND job IS NOT NULL AND job>0 AND wave IS NULL AND milestone_id IS NULL) + OR (scope='wave' AND wave IS NOT NULL AND wave>0 AND factory IS NULL AND job IS NULL AND milestone_id IS NULL) + OR (scope='milestone' AND milestone_id IS NOT NULL AND length(milestone_id)>0 AND factory IS NULL AND job IS NULL AND wave IS NULL)), + CHECK(ends_at>granted_at AND ends_at<=granted_at+%d) +); +CREATE INDEX workstream_goahead_list ON workstream_goaheads(workstream_id,state); +CREATE TABLE workstream_plans ( + workstream_id TEXT PRIMARY KEY REFERENCES workstreams(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), + milestones TEXT NOT NULL, waves TEXT NOT NULL +); +`, WorkstreamGoAheadCapMax, WorkstreamGoAheadDurationMax.Milliseconds()) + +// Opening a schema-3 file verifies the tables needed by both domain reads and +// collection ownership checks. A partial schema is refused rather than repaired. +var workstreamSchemaReads = []string{ + "SELECT id,why,origin_chat_id,agent_id,autonomy,wake_morning,wake_related_issues,budget_usd,created_at,updated_at,started_at,done_at,how_it_went,client_id FROM workstreams LIMIT 0", + "SELECT member_seq,workstream_id,factory,job FROM workstream_jobs LIMIT 0", + "SELECT id,workstream_id,parent_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview FROM workstream_pages LIMIT 0", + "SELECT page_id,version,saved_at,saved_by,source,title,doc,bytes FROM workstream_page_versions LIMIT 0", + "SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes,superseded_by FROM workstream_records LIMIT 0", + "SELECT id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,end_reason,granted_at,ended_at FROM workstream_goaheads LIMIT 0", + "SELECT workstream_id,version,updated_at,updated_by,milestones,waves FROM workstream_plans LIMIT 0", +} diff --git a/internal/workspace/workstreams_test.go b/internal/workspace/workstreams_test.go new file mode 100644 index 0000000000..685c262755 --- /dev/null +++ b/internal/workspace/workstreams_test.go @@ -0,0 +1,162 @@ +package workspace + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" +) + +func seedWorkstream(t *testing.T, s *Store, id, name string) { + t.Helper() + if err := s.Write(context.Background(), func(tx *sql.Tx) error { + if err := CreateIn(context.Background(), tx, id, name, 100); err != nil { + return err + } + _, err := tx.Exec("INSERT INTO workstreams(id,agent_id,created_at,updated_at) VALUES (?,?,100,100)", id, "agent-"+id) + return err + }); err != nil { + t.Fatal(err) + } +} + +// Part A (b): the collection store, including relink and raw SQL's unique +// constraint, enforces job ownership; plain collections make no job claim. +func TestPartAWorkstreamMembershipOwnershipIsEnforcedInSQLite(t *testing.T) { + s := openTestStore(t, filepath.Join(t.TempDir(), "claims.db")) + ctx := context.Background() + seedWorkstream(t, s, "a", "First") + seedWorkstream(t, s, "b", "Second") + job := Ref{Kind: FactoryJobKind, ID: "7", Factory: "f"} + fixedClock(s, 200) + if err := s.Add(ctx, "a", job); err != nil { + t.Fatal(err) + } + fixedClock(s, 300) + _, _, err := s.AddRefs(ctx, "b", []Addition{{Ref: Ref{Kind: ConversationKind, ID: "extra"}}, {Ref: job}}) + var taken *WorkstreamTaken + if !errors.As(err, &taken) || taken.WorkstreamID != "a" || taken.WorkstreamName != "First" || taken.Job != 7 || taken.Factory != "f" { + t.Fatalf("taken: %+v %v", taken, err) + } + for id, want := range map[string]int64{"a": 200, "b": 100} { + var at int64 + if err := s.db.QueryRow("SELECT updated_at FROM workstreams WHERE id=?", id).Scan(&at); err != nil || at != want { + t.Fatalf("timestamp %s: %d %v", id, at, err) + } + } + if members, err := s.Members(ctx, "b"); err != nil || len(members) != 0 { + t.Fatalf("partial add: %+v %v", members, err) + } + if err := s.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('b','factory_job','7','','f')") + return err + }); err == nil { + t.Fatal("SQL bypassed unique job owner") + } + plain := createTestCollection(t, s, "Plain") + if err := s.Add(ctx, plain.ID, job); err != nil { + t.Fatal(err) + } + differentFactory := Ref{Kind: FactoryJobKind, ID: "7", Factory: "other"} + if err := s.Add(ctx, "b", differentFactory); err != nil { + t.Fatal(err) + } + if _, _, err := s.Relink(ctx, "b", differentFactory, job); !errors.As(err, &taken) { + t.Fatalf("relink took another owner's job: %v", err) + } + if err := s.Remove(ctx, "a", job); err != nil { + t.Fatal(err) + } + if _, _, err := s.Relink(ctx, "b", differentFactory, job); err != nil { + t.Fatal(err) + } + a, err := s.Describe(ctx, "a") + if err != nil || !a.Workstream { + t.Fatalf("workstream flag: %+v %v", a, err) + } + copy, err := s.Duplicate(ctx, "b", "Copy") + if err != nil { + t.Fatal(err) + } + copySummary, err := s.Describe(ctx, copy.ID) + if err != nil || copySummary.Workstream { + t.Fatalf("plain duplicate: %+v %v", copySummary, err) + } + if _, err := s.Delete(ctx, "a"); !errors.Is(err, ErrWorkstreamDelete) { + t.Fatalf("delete: %v", err) + } +} + +// Part A (a): schema 1 upgrades straight to 3, preserving legacy timestamps as +// unknown; schema 3 checks the new ref's factory on direct SQL writes too. +func TestPartASchemaOneMigratesStraightToThree(t *testing.T) { + s := openTestStore(t, writeLegacyStore(t)) + ctx := context.Background() + if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "11", Factory: "f"}); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 3 { + t.Fatal("did not migrate directly to schema 3") + } + var count int + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 7 { + t.Fatalf("workstream tables: %d %v", count, err) + } + var created, added int64 + if err := s.db.QueryRow("SELECT created_at FROM collections WHERE id='aaaa'").Scan(&created); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT added_at FROM memberships WHERE kind='task'").Scan(&added); err != nil { + t.Fatal(err) + } + if created != 0 || added != 0 { + t.Fatal("invented legacy timestamps") + } + for _, q := range []string{ + "INSERT INTO memberships(collection_id,kind,ref_id,session_id) VALUES ('aaaa','factory_job','12','')", + "INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('aaaa','conversation','other','','f')", + } { + if _, err := s.db.Exec(q); err == nil { + t.Fatalf("accepted invalid factory: %s", q) + } + } +} + +func TestPartAFactoryIsPartOfEveryCollectionReadIdentity(t *testing.T) { + s := openTestStore(t, filepath.Join(t.TempDir(), "identity.db")) + ctx := context.Background() + c := createTestCollection(t, s, "Jobs") + a, b := Ref{Kind: FactoryJobKind, ID: "1", Factory: "a"}, Ref{Kind: FactoryJobKind, ID: "1", Factory: "b"} + if err := s.Add(ctx, c.ID, a); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, b); err != nil { + t.Fatal(err) + } + for _, r := range []Ref{a, b} { + parents, err := s.CollectionsFor(ctx, r) + if err != nil || len(parents) != 1 { + t.Fatalf("find %+v: %+v %v", r, parents, err) + } + } + expansion, err := s.Expand(ctx, c.ID, 1) + if err != nil || len(expansion.Refs) != 2 { + t.Fatalf("expansion: %+v %v", expansion, err) + } + if err := s.Remove(ctx, c.ID, a); err != nil { + t.Fatal(err) + } + members, err := s.Members(ctx, c.ID) + if err != nil || len(members) != 1 || members[0] != b { + t.Fatalf("remove: %+v %v", members, err) + } + list, err := s.List(ctx, &a) + if err != nil || len(list) != 1 || *list[0].Holds { + t.Fatalf("holds: %+v %v", list, err) + } + parents, err := s.CollectionsFor(ctx, a) + if err != nil || len(parents) != 0 { + t.Fatalf("removed find: %+v %v", parents, err) + } +} diff --git a/internal/workstream/goaheads.go b/internal/workstream/goaheads.go new file mode 100644 index 0000000000..c21531abd1 --- /dev/null +++ b/internal/workstream/goaheads.go @@ -0,0 +1,203 @@ +package workstream + +import ( + "context" + "database/sql" + "math" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const goaheadSelect = `SELECT id,workstream_id,scope,coalesce(factory,''),coalesce(job,0),coalesce(wave,0),coalesce(milestone_id,''),cap_usd,ends_at,state,coalesce(end_reason,''),granted_at,ended_at FROM workstream_goaheads WHERE id=?` + +func goaheadIn(ctx context.Context, tx *sql.Tx, id string) (GoAhead, error) { + var g GoAhead + var ends, granted int64 + var ended sql.NullInt64 + err := tx.QueryRowContext(ctx, goaheadSelect, id).Scan(&g.ID, &g.WorkstreamID, &g.Scope, &g.Factory, &g.Job, &g.Wave, &g.MilestoneID, &g.CapUSD, &ends, &g.State, &g.EndReason, &granted, &ended) + if err != nil { + return g, found(err) + } + g.EndsAt, g.GrantedAt, g.EndedAt = instant(ends), instant(granted), optionalInstant(ended) + return g, nil +} + +// Expiry is derived on read, so listing never writes or emits a change. +func expire(g GoAhead, now time.Time) GoAhead { + if g.State == "active" && !g.EndsAt.After(now) { + g.State = "ended" + g.EndReason = "expired" + g.EndedAt = g.EndsAt + } + return g +} +func (s *Store) GoAheads(ctx context.Context, id string, ended bool) ([]GoAhead, error) { + out := []GoAhead{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, "SELECT id FROM workstream_goaheads WHERE workstream_id=? ORDER BY granted_at DESC,rowid DESC", id) + if err != nil { + return err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + now := s.now() + for _, id := range ids { + g, err := goaheadIn(ctx, tx, id) + if err != nil { + return err + } + g = expire(g, now) + if ended || g.State == "active" { + out = append(out, g) + } + } + return nil + }) + return out, err +} +func (s *Store) GrantGoAhead(ctx context.Context, p GoAheadGrant) (GoAhead, Change, error) { + // Store millisecond precision in the validation too: an end within the same + // stored millisecond as the grant is not a future authorization. + now := s.now() + ends := p.EndsAt + if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || ends.IsZero() || !ends.After(now) || ends.After(now.Add(workspace.WorkstreamGoAheadDurationMax)) || ends.UnixMilli() <= now.UnixMilli() { + return GoAhead{}, Change{}, invalid("go-ahead needs a cap greater than 0 and at most 10000, and a future end within 90 days") + } + switch p.Scope { + case "job": + if p.Job <= 0 || p.Wave != 0 || p.MilestoneID != "" { + return GoAhead{}, Change{}, invalid("job scope needs only factory and job") + } + if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: strconv.Itoa(p.Job), Factory: p.Factory}).Validate(); err != nil { + return GoAhead{}, Change{}, err + } + case "wave": + if p.Wave <= 0 || p.Factory != "" || p.Job != 0 || p.MilestoneID != "" { + return GoAhead{}, Change{}, invalid("wave scope needs only wave") + } + case "milestone": + if p.MilestoneID == "" || p.Factory != "" || p.Job != 0 || p.Wave != 0 { + return GoAhead{}, Change{}, invalid("milestone scope needs only milestone_id") + } + default: + return GoAhead{}, Change{}, invalid("go-ahead needs a scope") + } + var out GoAhead + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, p.WorkstreamID) + if err != nil { + return err + } + if !w.DoneAt.IsZero() { + return ErrConflict + } + // Recheck after waiting for the writer, so an expired grant cannot sneak in. + at := s.now() + if p.EndsAt.UnixMilli() <= at.UnixMilli() || p.EndsAt.After(at.Add(workspace.WorkstreamGoAheadDurationMax)) { + return invalid("go-ahead end must be future and within 90 days") + } + valid := false + switch p.Scope { + case "job": + for _, j := range w.Jobs() { + if j.Factory == p.Factory && j.Number == p.Job { + valid = true + break + } + } + case "wave", "milestone": + plan, err := planIn(ctx, tx, p.WorkstreamID) + if err != nil { + return err + } + if p.Scope == "wave" { + for _, v := range plan.Waves { + if v.N == p.Wave { + valid = true + break + } + } + } else { + for _, v := range plan.Milestones { + if v.ID == p.MilestoneID { + valid = true + break + } + } + } + } + if !valid { + return invalid("scope target is not a member or does not exist in the plan") + } + id, err := s.newID() + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_goaheads(id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,granted_at) VALUES (?,?,?,?,?,?,?,?,?,'active',?)`, id, p.WorkstreamID, p.Scope, nullString(p.Factory), nullInt(p.Job), nullInt(p.Wave), nullString(p.MilestoneID), p.CapUSD, p.EndsAt.UnixMilli(), at.UnixMilli()); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at.UnixMilli()); err != nil { + return err + } + out, err = goaheadIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartGoAheads) + return err + }) + if err != nil { + return GoAhead{}, Change{}, err + } + return out, c, nil +} +func (s *Store) RevokeGoAhead(ctx context.Context, id string) (GoAhead, Change, error) { + var out GoAhead + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + g, err := goaheadIn(ctx, tx, id) + if err != nil { + return err + } + now := s.now() + g = expire(g, now) + if g.State == "ended" { + out = g + return nil + } + at := now.UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_goaheads SET state='ended',end_reason='revoked',ended_at=? WHERE id=?", at, id); err != nil { + return err + } + if err := touch(ctx, tx, g.WorkstreamID, at); err != nil { + return err + } + out, err = goaheadIn(ctx, tx, id) + c = changed(g.WorkstreamID, PartGoAheads) + return err + }) + if err != nil { + return GoAhead{}, Change{}, err + } + return out, c, nil +} diff --git a/internal/workstream/legacy_test.go b/internal/workstream/legacy_test.go new file mode 100644 index 0000000000..ade79780cd --- /dev/null +++ b/internal/workstream/legacy_test.go @@ -0,0 +1,76 @@ +package workstream + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// Part A (a): domain reads through older schemas are empty, never migrations. +func TestPartALegacyWorkstreamReadsDoNotWrite(t *testing.T) { + for _, version := range []int{1, 2} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + created, added := "", "" + if version == 2 { + created = ", created_at INTEGER NOT NULL DEFAULT 0" + added = ", added_at INTEGER NOT NULL DEFAULT 0" + } + _, err = db.Exec(fmt.Sprintf(`CREATE TABLE collections(seq INTEGER PRIMARY KEY,id TEXT UNIQUE,name TEXT%s); +CREATE TABLE memberships(seq INTEGER PRIMARY KEY,collection_id TEXT,kind TEXT,ref_id TEXT,session_id TEXT,target_collection TEXT%s); +PRAGMA application_id=1095123788; PRAGMA user_version=%d; +INSERT INTO collections(seq,id,name) VALUES (1,'collection','Existing');`, created, added, version)) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + w, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + defer w.Close() + s := New(w, Options{}) + ctx := context.Background() + all, err := s.List(ctx) + if err != nil || all == nil || len(all) != 0 { + t.Fatalf("legacy list: %+v %v", all, err) + } + for name, read := range map[string]func() error{ + "get": func() error { _, err := s.Get(ctx, "collection"); return err }, + "plan": func() error { _, err := s.Plan(ctx, "collection"); return err }, + "pages": func() error { _, err := s.Pages(ctx, "collection"); return err }, + "page": func() error { _, err := s.Page(ctx, "missing", true); return err }, + "history": func() error { _, err := s.PageHistory(ctx, "missing", 50); return err }, + "records": func() error { _, err := s.Records(ctx, "collection", "", ""); return err }, + "goaheads": func() error { _, err := s.GoAheads(ctx, "collection", true); return err }, + } { + if err := read(); !errors.Is(err, ErrNotFound) { + t.Fatalf("legacy %s: %v", name, err) + } + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("domain read changed legacy file") + } + }) + } +} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go new file mode 100644 index 0000000000..cc3a238bb2 --- /dev/null +++ b/internal/workstream/pages.go @@ -0,0 +1,514 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +const pageSelect = `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview FROM workstream_pages WHERE id=?` + +func pageIn(ctx context.Context, tx *sql.Tx, id string) (Page, bool, error) { + var p Page + var at int64 + var source sql.NullString + var overview bool + var doc string + err := tx.QueryRowContext(ctx, pageSelect, id).Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &doc, &overview) + if err != nil { + return p, false, found(err) + } + p.UpdatedAt = instant(at) + p.Doc = json.RawMessage(doc) + p.UpdatedFrom, err = readSource(source) + return p, overview, err +} +func pageNodesIn(ctx context.Context, tx *sql.Tx, id string) ([]PageNode, error) { + rows, err := tx.QueryContext(ctx, `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from FROM workstream_pages WHERE workstream_id=? ORDER BY position,id`, id) + if err != nil { + return nil, err + } + defer rows.Close() + siblings := map[string][]PageNode{} + for rows.Next() { + var p PageNode + var at int64 + var source sql.NullString + if err := rows.Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source); err != nil { + return nil, err + } + p.UpdatedAt = instant(at) + p.UpdatedFrom, err = readSource(source) + if err != nil { + return nil, err + } + siblings[p.ParentID] = append(siblings[p.ParentID], p) + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]PageNode, 0) + seen := map[string]bool{} + // An explicit stack keeps exports safe for a very deep tree. + var stack []PageNode + push := func(nodes []PageNode) { + for i := len(nodes) - 1; i >= 0; i-- { + stack = append(stack, nodes[i]) + } + } + push(siblings[""]) + for len(stack) > 0 { + last := len(stack) - 1 + p := stack[last] + stack = stack[:last] + if seen[p.ID] { + continue + } + seen[p.ID] = true + out = append(out, p) + push(siblings[p.ID]) + } + return out, nil +} +func (s *Store) Pages(ctx context.Context, id string) ([]PageNode, error) { + out := make([]PageNode, 0) + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + var err error + out, err = pageNodesIn(ctx, tx, id) + return err + }) + return out, err +} +func (s *Store) Page(ctx context.Context, id string, markdown bool) (Page, error) { + var p Page + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + var err error + p, _, err = pageIn(ctx, tx, id) + return err + }) + if err == nil && markdown { + var md string + md, err = pagedoc.Markdown(p.Doc) + p.Markdown = &md + } + return p, err +} +func checkDoc(doc json.RawMessage) error { + if len(doc) > DocMax { + return ErrTooLarge + } + raw := strings.TrimSpace(string(doc)) + if len(raw) == 0 || raw[0] != '{' || !json.Valid(doc) || !utf8.Valid(doc) { + return invalid("doc must be a JSON object") + } + return nil +} +func saveVersion(ctx context.Context, tx *sql.Tx, id string) error { + _, err := tx.ExecContext(ctx, `INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,source,title,doc,bytes) + SELECT id,version,updated_at,updated_by,updated_from,title,doc,length(CAST(doc AS BLOB)) FROM workstream_pages WHERE id=?`, id) + return err +} +func (s *Store) SavePage(ctx context.Context, id string, base int, doc json.RawMessage, who Attribution) (Page, Change, error) { + if err := checkDoc(doc); err != nil { + return Page{}, Change{}, err + } + return s.savePage(ctx, id, base, doc, 0, who) +} +func (s *Store) RestorePage(ctx context.Context, id string, version, base int, who Attribution) (Page, Change, error) { + if version < 1 { + return Page{}, Change{}, invalid("version must be positive") + } + return s.savePage(ctx, id, base, nil, version, who) +} +func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawMessage, restore int, who Attribution) (Page, Change, error) { + if base < 0 { + return Page{}, Change{}, invalid("base_version must be non-negative") + } + by, err := actor(who.By) + if err != nil { + return Page{}, Change{}, err + } + source, err := sourceJSON(who.From) + if err != nil { + return Page{}, Change{}, err + } + var out Page + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + p, _, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if p.Version != base { + return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} + } + title := p.Title + if restore > 0 { + var raw string + err := tx.QueryRowContext(ctx, "SELECT title,doc FROM workstream_page_versions WHERE page_id=? AND version=?", id, restore).Scan(&title, &raw) + if err != nil { + return found(err) + } + doc = json.RawMessage(raw) + } + if err := checkDoc(doc); err != nil { + return err + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,doc=?,version=version+1,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, string(doc), at, by, source, id); err != nil { + return err + } + if err := saveVersion(ctx, tx, id); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + out, _, err = pageIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartPages) + return err + }) + if err != nil { + return Page{}, Change{}, err + } + return out, c, nil +} + +// placePage validates every target before changing sibling positions. It packs +// positions after a move so a later insert never relies on gaps or old offsets. +func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, last bool, at int64, by string, source any) (bool, error) { + if parent != "" { + seen := map[string]bool{} + for id := parent; id != ""; { + if id == p.ID || seen[id] { + return false, ErrCycle + } + seen[id] = true + var workstreamID, next string + err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=?", id).Scan(&workstreamID, &next) + if err != nil { + return false, found(err) + } + if workstreamID != p.WorkstreamID { + return false, invalid("parent is in another workstream") + } + id = next + } + } + if after != "" { + var workstreamID, siblingParent string + err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=?", after).Scan(&workstreamID, &siblingParent) + if err != nil { + return false, found(err) + } + if workstreamID != p.WorkstreamID || siblingParent != parent || after == p.ID { + return false, invalid("after_id must be another sibling in this workstream") + } + } + nodes, err := pageNodesIn(ctx, tx, p.WorkstreamID) + if err != nil { + return false, err + } + siblings := make([]PageNode, 0) + old := make([]PageNode, 0) + for _, n := range nodes { + if n.ID == p.ID { + continue + } + if n.ParentID == parent { + siblings = append(siblings, n) + } + if p.ParentID != parent && n.ParentID == p.ParentID { + old = append(old, n) + } + } + index := 0 + if last { + index = len(siblings) + } + if after != "" { + for i, n := range siblings { + if n.ID == after { + index = i + 1 + break + } + } + } + // Root position zero belongs to the immutable Overview identity, even after a rename. + if parent == "" && index == 0 && len(siblings) > 0 { + index = 1 + } + if p.ParentID == parent && p.Position == index { + return false, nil + } + siblings = append(siblings, PageNode{}) + copy(siblings[index+1:], siblings[index:]) + siblings[index] = p.PageNode + for i, n := range siblings { + if n.ID == p.ID { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET parent_id=?,position=?,updated_at=?,updated_by=?,updated_from=? WHERE id=?", nullString(parent), i, at, by, source, p.ID); err != nil { + return false, err + } + } else if n.Position != i { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { + return false, err + } + } + } + for i, n := range old { + if n.Position != i { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { + return false, err + } + } + } + return true, nil +} +func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, error) { + if err := validTitle(p.Title); err != nil { + return Page{}, Change{}, err + } + owner, err := actor(p.Owner) + if err != nil { + return Page{}, Change{}, err + } + by, err := actor(p.By) + if err != nil { + return Page{}, Change{}, err + } + source, err := sourceJSON(p.From) + if err != nil { + return Page{}, Change{}, err + } + if p.Doc == nil { + p.Doc = json.RawMessage(emptyDoc) + } + if err := checkDoc(p.Doc); err != nil { + return Page{}, Change{}, err + } + var out Page + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + if _, err := getIn(ctx, tx, p.WorkstreamID); err != nil { + return err + } + id, err := s.newID() + if err != nil { + return err + } + at := s.now().UnixMilli() + // A temporary root position distinct from every packed position makes the + // common placement helper handle creation and movement identically. + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc) VALUES (?,?,?,?,?,2147483647,1,?,?,?,?)`, id, p.WorkstreamID, p.Title, owner, p.ReadByJobs, at, by, source, string(p.Doc)); err != nil { + return err + } + page, _, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if _, err := placePage(ctx, tx, page, p.ParentID, p.AfterID, p.AfterID == "", at, by, source); err != nil { + return err + } + if err := saveVersion(ctx, tx, id); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + out, _, err = pageIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartPages) + return err + }) + if err != nil { + return Page{}, Change{}, err + } + return out, c, nil +} +func (s *Store) UpdatePage(ctx context.Context, id string, p PagePatch) (PageNode, Change, error) { + if p.Title != nil { + if err := validTitle(*p.Title); err != nil { + return PageNode{}, Change{}, err + } + } + if p.Owner != nil { + if *p.Owner != "you" && *p.Owner != "agent" { + return PageNode{}, Change{}, invalid("owner must be you or agent") + } + } + by, err := actor(p.By) + if err != nil { + return PageNode{}, Change{}, err + } + source, err := sourceJSON(p.From) + if err != nil { + return PageNode{}, Change{}, err + } + var out PageNode + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + page, _, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + title, owner, read := page.Title, page.Owner, page.ReadByJobs + if p.Title != nil { + title = *p.Title + } + if p.Owner != nil { + owner = *p.Owner + } + if p.ReadByJobs != nil { + read = *p.ReadByJobs + } + if title == page.Title && owner == page.Owner && read == page.ReadByJobs { + out = page.PageNode + return nil + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,owner=?,read_by_jobs=?,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, owner, read, at, by, source, id); err != nil { + return err + } + if err := touch(ctx, tx, page.WorkstreamID, at); err != nil { + return err + } + page, _, err = pageIn(ctx, tx, id) + out = page.PageNode + c = changed(page.WorkstreamID, PartPages) + return err + }) + if err != nil { + return PageNode{}, Change{}, err + } + return out, c, nil +} +func (s *Store) MovePage(ctx context.Context, id, parent, after string) (PageNode, Change, error) { + var out PageNode + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + p, overview, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if overview { + return invalid("Overview cannot be moved") + } + at := s.now().UnixMilli() + moved, err := placePage(ctx, tx, p, parent, after, false, at, "you", nil) + if err != nil { + return err + } + if moved { + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + c = changed(p.WorkstreamID, PartPages) + } + p, _, err = pageIn(ctx, tx, id) + out = p.PageNode + return err + }) + if err != nil { + return PageNode{}, Change{}, err + } + return out, c, nil +} +func (s *Store) DeletePage(ctx context.Context, id string) ([]string, Change, error) { + deleted := []string{} + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + p, overview, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if overview { + return invalid("Overview cannot be deleted") + } + nodes, err := pageNodesIn(ctx, tx, p.WorkstreamID) + if err != nil { + return err + } + gone := map[string]bool{id: true} + for _, n := range nodes { + if gone[n.ID] || gone[n.ParentID] { + gone[n.ID] = true + deleted = append(deleted, n.ID) + } + } + // Delete leaves before parents to avoid SQLite's trigger depth limit on a + // deep subtree; the cascade remains a backstop for ordinary SQL deletions. + for i := len(deleted) - 1; i >= 0; i-- { + if _, err := tx.ExecContext(ctx, "DELETE FROM workstream_pages WHERE id=?", deleted[i]); err != nil { + return err + } + } + i := 0 + for _, n := range nodes { + if !gone[n.ID] && n.ParentID == p.ParentID { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { + return err + } + i++ + } + } + if err := touch(ctx, tx, p.WorkstreamID, s.now().UnixMilli()); err != nil { + return err + } + c = changed(p.WorkstreamID, PartPages) + return nil + }) + if err != nil { + return nil, Change{}, err + } + return deleted, c, nil +} +func (s *Store) PageHistory(ctx context.Context, id string, limit int) ([]PageVersion, error) { + if limit == 0 { + limit = 50 + } + if limit < 1 || limit > 200 { + return nil, invalid("history limit must be 1–200") + } + out := []PageVersion{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, _, err := pageIn(ctx, tx, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, "SELECT version,saved_at,saved_by,source,title,bytes FROM workstream_page_versions WHERE page_id=? ORDER BY version DESC LIMIT ?", id, limit) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var p PageVersion + var at int64 + var source sql.NullString + if err := rows.Scan(&p.Version, &at, &p.SavedBy, &source, &p.Title, &p.Bytes); err != nil { + return err + } + p.SavedAt = instant(at) + p.From, err = readSource(source) + if err != nil { + return err + } + out = append(out, p) + } + return rows.Err() + }) + return out, err +} diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go new file mode 100644 index 0000000000..595ac51678 --- /dev/null +++ b/internal/workstream/pages_test.go @@ -0,0 +1,217 @@ +package workstream + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +// Part A (f): optimistic saves refuse stale versions without changing history; +// restoration copies an old title and document into a new immutable version. +func TestPartAPageSaveHistoryAndRestore(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Pages") + nodes, err := s.Pages(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + first, err := s.Page(ctx, nodes[0].ID, false) + if err != nil { + t.Fatal(err) + } + doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Saved"}]}]}`) + *now = now.Add(time.Hour) + saved, _, err := s.SavePage(ctx, first.ID, first.Version, doc, Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}) + if err != nil || saved.Version != 2 { + t.Fatalf("save: %+v %v", saved, err) + } + _, c, err := s.SavePage(ctx, first.ID, 1, json.RawMessage(emptyDoc), Attribution{}) + var stale *VersionConflict + if !errors.As(err, &stale) || stale.ID != first.ID || stale.Version != 2 || stale.UpdatedBy != "agent" || !stale.UpdatedAt.Equal(*now) || len(c.Parts) != 0 { + t.Fatalf("stale: %+v %v", stale, err) + } + current, _ := s.Page(ctx, first.ID, false) + history, _ := s.PageHistory(ctx, first.ID, 50) + if !reflect.DeepEqual(current, saved) || len(history) != 2 || history[0].Version != 2 || history[1].Version != 1 { + t.Fatalf("stale changed page or history: %+v %+v", current, history) + } + title := "Renamed" + _, _, err = s.UpdatePage(ctx, first.ID, PagePatch{Title: &title}) + if err != nil { + t.Fatal(err) + } + restored, _, err := s.RestorePage(ctx, first.ID, 1, 2, Attribution{}) + if err != nil || restored.Version != 3 || restored.Title != "Overview" || string(restored.Doc) != emptyDoc { + t.Fatalf("restore: %+v %v", restored, err) + } + history, _ = s.PageHistory(ctx, first.ID, 50) + if len(history) != 3 || history[0].Version != 3 || history[1].SavedBy != "agent" { + t.Fatalf("history: %+v", history) + } + _, _, err = s.SavePage(ctx, first.ID, 3, json.RawMessage(`{"x":"`+strings.Repeat("x", DocMax)+`"}`), Attribution{}) + if !errors.Is(err, ErrTooLarge) { + t.Fatalf("size: %v", err) + } +} + +// Part A (f): placement defaults differ for create and move; Overview is fixed, +// parent cycles and cross-workstream moves refuse, and delete removes descendants. +func TestPartAPageTreePlacementCyclesAndDelete(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Tree") + nodes, _ := s.Pages(ctx, w.ID) + overview := nodes[0] + a, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "A"}) + if err != nil { + t.Fatal(err) + } + b, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "B"}) + if err != nil { + t.Fatal(err) + } + child, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, ParentID: a.ID, Title: "Child"}) + if err != nil { + t.Fatal(err) + } + if a.Position != 1 || b.Position != 2 || child.Position != 0 { + t.Fatalf("positions: %+v %+v %+v", a, b, child) + } + if _, _, err := s.MovePage(ctx, a.ID, child.ID, ""); !errors.Is(err, ErrCycle) { + t.Fatalf("descendant: %v", err) + } + if _, _, err := s.MovePage(ctx, a.ID, a.ID, ""); !errors.Is(err, ErrCycle) { + t.Fatalf("self: %v", err) + } + if _, _, err := s.MovePage(ctx, overview.ID, a.ID, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("Overview move: %v", err) + } + moved, _, err := s.MovePage(ctx, b.ID, "", "") + if err != nil || moved.Position != 1 { + t.Fatalf("root move: %+v %v", moved, err) + } + nodes, _ = s.Pages(ctx, w.ID) + ids := []string{} + for _, n := range nodes { + ids = append(ids, n.ID) + } + if !reflect.DeepEqual(ids, []string{overview.ID, b.ID, a.ID, child.ID}) { + t.Fatalf("depth-first: %v", ids) + } + other := createStream(t, s, "OtherTree") + otherPages, _ := s.Pages(ctx, other.ID) + if _, _, err := s.MovePage(ctx, a.ID, otherPages[0].ID, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("cross-workstream: %v", err) + } + if _, _, err := s.DeletePage(ctx, overview.ID); !errors.Is(err, ErrInvalid) { + t.Fatalf("Overview delete: %v", err) + } + deleted, _, err := s.DeletePage(ctx, a.ID) + if err != nil || !reflect.DeepEqual(deleted, []string{a.ID, child.ID}) { + t.Fatalf("delete: %v %v", deleted, err) + } + if _, err := s.Page(ctx, child.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("child remains: %v", err) + } +} + +func TestPartAPlanMembershipVersionsAndDone(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Plan", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "8", Factory: "f"}) + p, err := s.Plan(ctx, w.ID) + if err != nil || p.Version != 0 || p.Milestones == nil || p.Waves == nil { + t.Fatalf("initial: %+v %v", p, err) + } + waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 8}, {Kind: "draft", DraftID: "draft", Title: "Next"}}}} + p, _, err = s.SetPlan(ctx, w.ID, 0, []Milestone{{Title: "Ship"}}, waves, Attribution{By: "agent"}) + if err != nil || p.Version != 1 || p.Milestones[0].ID == "" { + t.Fatalf("set: %+v %v", p, err) + } + _, _, err = s.SetPlan(ctx, w.ID, 0, nil, nil, Attribution{}) + var stale *VersionConflict + if !errors.As(err, &stale) || stale.Version != 1 || stale.UpdatedBy != "agent" { + t.Fatalf("stale: %v", err) + } + for _, bad := range [][]Wave{ + {{N: 0}}, {{N: 2}, {N: 1}}, {{N: 1}, {N: 1}}, {{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 99}}}}, + } { + if _, _, err := s.SetPlan(ctx, w.ID, 1, nil, bad, Attribution{}); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid waves %+v: %v", bad, err) + } + } + _, _, err = s.MarkDone(ctx, w.ID, "") + if err != nil { + t.Fatal(err) + } + if _, _, err := s.SetPlan(ctx, w.ID, 1, nil, nil, Attribution{}); !errors.Is(err, ErrConflict) { + t.Fatalf("done: %v", err) + } +} + +// page.get's markdown is the shared page renderer's output (pagedoc), so the +// engine has one renderer and it matches the desktop app's. +func TestPartAMarkdownExportIsThePageRenderer(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Markdown") + pages, err := s.Pages(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + doc := json.RawMessage(`{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},{"type":"paragraph","content":[{"type":"text","text":"Body"}]}]}`) + if _, _, err := s.SavePage(ctx, pages[0].ID, 1, doc, Attribution{}); err != nil { + t.Fatal(err) + } + p, err := s.Page(ctx, pages[0].ID, true) + if err != nil { + t.Fatal(err) + } + want, err := pagedoc.Markdown(doc) + if err != nil { + t.Fatal(err) + } + if p.Markdown == nil || *p.Markdown != want || !strings.Contains(want, "Title") { + t.Fatalf("markdown %v, want %q", p.Markdown, want) + } +} + +func TestPartAOverviewIdentitySurvivesRenaming(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Identity") + pages, err := s.Pages(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + title := "Introduction" + overview, _, err := s.UpdatePage(ctx, pages[0].ID, PagePatch{Title: &title}) + if err != nil { + t.Fatal(err) + } + other, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Overview"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MovePage(ctx, overview.ID, other.ID, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("renamed Overview move: %v", err) + } + if _, _, err := s.DeletePage(ctx, overview.ID); !errors.Is(err, ErrInvalid) { + t.Fatalf("renamed Overview delete: %v", err) + } + if _, _, err := s.MovePage(ctx, other.ID, "", ""); err != nil { + t.Fatalf("ordinary page named Overview: %v", err) + } + pages, err = s.Pages(ctx, w.ID) + if err != nil || pages[0].ID != overview.ID || pages[0].Position != 0 { + t.Fatalf("Overview order: %+v %v", pages, err) + } +} diff --git a/internal/workstream/plan.go b/internal/workstream/plan.go new file mode 100644 index 0000000000..b3b0443243 --- /dev/null +++ b/internal/workstream/plan.go @@ -0,0 +1,156 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" +) + +func planIn(ctx context.Context, tx *sql.Tx, id string) (Plan, error) { + p := Plan{WorkstreamID: id, Milestones: []Milestone{}, Waves: []Wave{}, UpdatedBy: "you"} + var at int64 + var milestones, waves string + err := tx.QueryRowContext(ctx, "SELECT version,updated_at,updated_by,milestones,waves FROM workstream_plans WHERE workstream_id=?", id).Scan(&p.Version, &at, &p.UpdatedBy, &milestones, &waves) + if errors.Is(err, sql.ErrNoRows) { + return p, nil + } + if err != nil { + return p, err + } + p.UpdatedAt = instant(at) + if err := json.Unmarshal([]byte(milestones), &p.Milestones); err != nil { + return p, err + } + err = json.Unmarshal([]byte(waves), &p.Waves) + return p, err +} +func (s *Store) Plan(ctx context.Context, id string) (Plan, error) { + var p Plan + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + var err error + p, err = planIn(ctx, tx, id) + return err + }) + return p, err +} +func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []Milestone, waves []Wave, who Attribution) (Plan, Change, error) { + by, err := actor(who.By) + if err != nil { + return Plan{}, Change{}, err + } + if base < 0 { + return Plan{}, Change{}, invalid("base_version must be non-negative") + } + // Copy slices because assigned ids and normalized lists belong to the saved plan. + milestones = append([]Milestone{}, milestones...) + waves = append([]Wave{}, waves...) + for i := range waves { + waves[i].Items = append([]PlanItem{}, waves[i].Items...) + } + var out Plan + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if !w.DoneAt.IsZero() { + return ErrConflict + } + p, err := planIn(ctx, tx, id) + if err != nil { + return err + } + if base != p.Version { + at := p.UpdatedAt + if at.IsZero() { + at = w.UpdatedAt + } + return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: at, UpdatedBy: p.UpdatedBy} + } + ids := map[string]bool{} + for i := range milestones { + m := &milestones[i] + if err := validTitle(m.Title); err != nil { + return err + } + if m.ID == "" { + m.ID, err = s.newID() + if err != nil { + return err + } + } + if ids[m.ID] { + return invalid("milestone ids must be unique") + } + ids[m.ID] = true + } + jobs := map[Job]bool{} + for _, j := range w.Jobs() { + jobs[j] = true + } + last := 0 + for _, wave := range waves { + if wave.N <= last { + return invalid("waves must be positive, unique and ascending") + } + last = wave.N + if !lineWithin(wave.Title, 200) { + return invalid("wave title must be at most 200 bytes without control characters") + } + if wave.MilestoneID != "" && !ids[wave.MilestoneID] { + return invalid("wave milestone does not exist") + } + for _, item := range wave.Items { + switch item.Kind { + case "job": + if item.Job <= 0 || !jobs[Job{Factory: item.Factory, Number: item.Job}] { + return invalid("plan job must be a member") + } + case "draft": + if item.DraftID == "" || item.Title == "" { + return invalid("draft needs draft_id and title") + } + if err := validTitle(item.Title); err != nil { + return err + } + if !textWithin(item.Text, len(item.Text)) { + return invalid("draft text allows only newline and tab control characters") + } + default: + return invalid("unknown plan item kind") + } + } + } + m, err := json.Marshal(milestones) + if err != nil { + return err + } + v, err := json.Marshal(waves) + if err != nil { + return err + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES (?,?,?,?,?,?) + ON CONFLICT(workstream_id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,updated_by=excluded.updated_by,milestones=excluded.milestones,waves=excluded.waves`, id, p.Version+1, at, by, string(m), string(v)); err != nil { + return err + } + if err := touch(ctx, tx, id, at); err != nil { + return err + } + out, err = planIn(ctx, tx, id) + c = changed(id, PartPlan) + return err + }) + if err != nil { + return Plan{}, Change{}, err + } + return out, c, nil +} diff --git a/internal/workstream/records.go b/internal/workstream/records.go new file mode 100644 index 0000000000..6b47ae28eb --- /dev/null +++ b/internal/workstream/records.go @@ -0,0 +1,223 @@ +package workstream + +import ( + "context" + "database/sql" + "strings" +) + +const recordSelect = `SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,coalesce(supersedes,''),coalesce(superseded_by,'') FROM workstream_records` + +func recordIn(ctx context.Context, tx *sql.Tx, id string) (Record, error) { + var r Record + var source sql.NullString + var at int64 + var decided sql.NullInt64 + err := tx.QueryRowContext(ctx, recordSelect+" WHERE id=?", id).Scan(&r.ID, &r.WorkstreamID, &r.Kind, &r.Text, &r.State, &r.By, &source, &at, &decided, &r.Supersedes, &r.SupersededBy) + if err != nil { + return r, found(err) + } + r.CreatedAt, r.DecidedAt = instant(at), optionalInstant(decided) + r.Source, err = readSource(source) + return r, err +} +func recordText(kind, text string) error { + if kind != "decision" && kind != "question" { + return invalid("record kind must be decision or question") + } + if strings.TrimSpace(text) == "" || !textWithin(text, 2000) { + return invalid("record text must be 1–2000 bytes") + } + return nil +} +func (s *Store) Records(ctx context.Context, id, kind, state string) ([]Record, error) { + if kind != "" && kind != "decision" && kind != "question" { + return nil, invalid("unknown record kind") + } + if state != "" && state != "proposed" && state != "kept" && state != "dropped" && state != "superseded" { + return nil, invalid("unknown record state") + } + out := []Record{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT id FROM workstream_records WHERE workstream_id=? AND (?='' OR kind=?) AND (?='' OR state=?) ORDER BY created_at DESC,rowid DESC`, id, kind, kind, state, state) + if err != nil { + return err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, id := range ids { + r, err := recordIn(ctx, tx, id) + if err != nil { + return err + } + out = append(out, r) + } + return nil + }) + return out, err +} +func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Change, error) { + if err := recordText(p.Kind, p.Text); err != nil { + return Record{}, Change{}, err + } + if p.By == "" { + if p.Kept { + p.By = "you" + } else { + p.By = "agent" + } + } + by, err := actor(p.By) + if err != nil { + return Record{}, Change{}, err + } + source, err := sourceJSON(p.Source) + if err != nil { + return Record{}, Change{}, err + } + var out Record + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + if _, err := getIn(ctx, tx, p.WorkstreamID); err != nil { + return err + } + id, err := s.newID() + if err != nil { + return err + } + at := s.now().UnixMilli() + state := "proposed" + var decided any + if p.Kept { + state = "kept" + decided = at + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at) VALUES (?,?,?,?,?,?,?,?,?)`, id, p.WorkstreamID, p.Kind, p.Text, state, by, source, at, decided); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + out, err = recordIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartRecords) + return err + }) + if err != nil { + return Record{}, Change{}, err + } + return out, c, nil +} +func (s *Store) KeepRecord(ctx context.Context, id string) (Record, Change, error) { + return s.setRecordState(ctx, id, "kept") +} +func (s *Store) DropRecord(ctx context.Context, id string) (Record, Change, error) { + return s.setRecordState(ctx, id, "dropped") +} +func (s *Store) setRecordState(ctx context.Context, id, state string) (Record, Change, error) { + var out Record + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + r, err := recordIn(ctx, tx, id) + if err != nil { + return err + } + if r.State == "superseded" { + return ErrConflict + } + if r.State == state { + out = r + return nil + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state=?,decided_at=? WHERE id=?", state, at, id); err != nil { + return err + } + if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { + return err + } + out, err = recordIn(ctx, tx, id) + c = changed(r.WorkstreamID, PartRecords) + return err + }) + if err != nil { + return Record{}, Change{}, err + } + return out, c, nil +} +func (s *Store) SupersedeRecord(ctx context.Context, id string, p RecordReplacement) (Record, Record, Change, error) { + by := p.By + if by == "" { + by = "agent" + } + by, err := actor(by) + if err != nil { + return Record{}, Record{}, Change{}, err + } + source, err := sourceJSON(p.Source) + if err != nil { + return Record{}, Record{}, Change{}, err + } + var out, old Record + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + r, err := recordIn(ctx, tx, id) + if err != nil { + return err + } + if r.State == "superseded" { + return ErrConflict + } + kind := p.Kind + if kind == "" { + kind = r.Kind + } + if err := recordText(kind, p.Text); err != nil { + return err + } + replacement, err := s.newID() + if err != nil { + return err + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes) VALUES (?,?,?,?,'kept',?,?,?,?,?)`, replacement, r.WorkstreamID, kind, p.Text, by, source, at, at, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state='superseded',decided_at=?,superseded_by=? WHERE id=?", at, replacement, id); err != nil { + return err + } + if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { + return err + } + out, err = recordIn(ctx, tx, replacement) + if err != nil { + return err + } + old, err = recordIn(ctx, tx, id) + c = changed(r.WorkstreamID, PartRecords) + return err + }) + if err != nil { + return Record{}, Record{}, Change{}, err + } + return out, old, c, nil +} diff --git a/internal/workstream/records_goaheads_test.go b/internal/workstream/records_goaheads_test.go new file mode 100644 index 0000000000..3641a63594 --- /dev/null +++ b/internal/workstream/records_goaheads_test.go @@ -0,0 +1,240 @@ +package workstream + +import ( + "context" + "errors" + "math" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// Part A (g): keep/drop support undo; supersede creates a kept replacement with +// both links, and every attempt to change the old record conflicts. +func TestPartARecordLifecycle(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Records") + r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Count retries?"}) + if err != nil || r.State != "proposed" || r.By != "agent" { + t.Fatalf("propose: %+v %v", r, err) + } + r, _, err = s.KeepRecord(ctx, r.ID) + if err != nil || r.State != "kept" || r.DecidedAt.IsZero() { + t.Fatalf("keep: %+v %v", r, err) + } + r, _, err = s.DropRecord(ctx, r.ID) + if err != nil || r.State != "dropped" { + t.Fatalf("drop: %+v %v", r, err) + } + r, _, err = s.KeepRecord(ctx, r.ID) + if err != nil || r.State != "kept" { + t.Fatalf("undo: %+v %v", r, err) + } + replacement, old, _, err := s.SupersedeRecord(ctx, r.ID, RecordReplacement{Kind: "decision", Text: "Retries do not count"}) + if err != nil || replacement.State != "kept" || replacement.Supersedes != r.ID || old.State != "superseded" || old.SupersededBy != replacement.ID { + t.Fatalf("supersede: %+v %+v %v", replacement, old, err) + } + if _, _, err := s.KeepRecord(ctx, r.ID); !errors.Is(err, ErrConflict) { + t.Fatalf("keep superseded: %v", err) + } + if _, _, err := s.DropRecord(ctx, r.ID); !errors.Is(err, ErrConflict) { + t.Fatalf("drop superseded: %v", err) + } + if _, _, _, err := s.SupersedeRecord(ctx, r.ID, RecordReplacement{Text: "Again"}); !errors.Is(err, ErrConflict) { + t.Fatalf("supersede superseded: %v", err) + } + kept, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "My decision", Kept: true}) + if err != nil || kept.By != "you" || kept.State != "kept" || kept.DecidedAt.IsZero() { + t.Fatalf("kept: %+v %v", kept, err) + } + all, err := s.Records(ctx, w.ID, "", "") + if err != nil || len(all) != 3 { + t.Fatalf("records: %+v %v", all, err) + } + proposed, err := s.Records(ctx, w.ID, "question", "proposed") + if err != nil || len(proposed) != 0 { + t.Fatalf("filter: %+v %v", proposed, err) + } +} + +// Part A (h): each malformed grant is refused and stores nothing. +func TestPartAGoAheadBoundsAndTargets(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Bounds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) + good := GoAheadGrant{WorkstreamID: w.ID, Scope: "job", Factory: "f", Job: 4, CapUSD: 1, EndsAt: now.Add(time.Hour)} + for _, tc := range []struct { + name string + change func(*GoAheadGrant) + }{ + {"scope", func(p *GoAheadGrant) { p.Scope = "" }}, + {"cap absent", func(p *GoAheadGrant) { p.CapUSD = 0 }}, + {"negative cap", func(p *GoAheadGrant) { p.CapUSD = -1 }}, + {"large cap", func(p *GoAheadGrant) { p.CapUSD = 10000.01 }}, + {"nan cap", func(p *GoAheadGrant) { p.CapUSD = math.NaN() }}, + {"infinite cap", func(p *GoAheadGrant) { p.CapUSD = math.Inf(1) }}, + {"end absent", func(p *GoAheadGrant) { p.EndsAt = time.Time{} }}, + {"end past", func(p *GoAheadGrant) { p.EndsAt = now.Add(-time.Second) }}, + {"end now", func(p *GoAheadGrant) { p.EndsAt = *now }}, + {"end too far", func(p *GoAheadGrant) { p.EndsAt = now.Add(90*24*time.Hour + time.Millisecond) }}, + {"factory", func(p *GoAheadGrant) { p.Factory = "" }}, + {"job", func(p *GoAheadGrant) { p.Job = 0 }}, + {"not member", func(p *GoAheadGrant) { p.Job = 5 }}, + {"wave missing", func(p *GoAheadGrant) { p.Scope = "wave"; p.Factory = ""; p.Job = 0; p.Wave = 1 }}, + {"milestone missing", func(p *GoAheadGrant) { p.Scope = "milestone"; p.Factory = ""; p.Job = 0; p.MilestoneID = "missing" }}, + } { + t.Run(tc.name, func(t *testing.T) { + isolated, _, _ := testStore(t) + stream := createStream(t, isolated, "Bounds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) + p := good + p.WorkstreamID = stream.ID + tc.change(&p) + _, c, err := isolated.GrantGoAhead(ctx, p) + if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { + t.Fatalf("grant: %+v %v", c, err) + } + all, err := isolated.GoAheads(ctx, stream.ID, true) + if err != nil || len(all) != 0 { + t.Fatalf("stored invalid grant: %+v %v", all, err) + } + }) + } + good.CapUSD = 10000 + good.EndsAt = now.Add(90 * 24 * time.Hour) + if _, _, err := s.GrantGoAhead(ctx, good); err != nil { + t.Fatalf("inclusive limits: %v", err) + } +} + +// Part A (e): MarkDone ends every scope together, is idempotent, and preserves +// prior revocations. Expiry is derived by reads without mutating storage. +func TestPartAMarkDoneEndsGoAheadsAndExpiry(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "GoAheads", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) + plan, _, err := s.SetPlan(ctx, w.ID, 0, []Milestone{{ID: "ship", Title: "Ship"}}, []Wave{{N: 1}}, Attribution{}) + if err != nil || plan.Version != 1 { + t.Fatal(err) + } + for _, p := range []GoAheadGrant{ + {Scope: "job", Factory: "f", Job: 4}, {Scope: "wave", Wave: 1}, {Scope: "milestone", MilestoneID: "ship"}, + } { + p.WorkstreamID = w.ID + p.CapUSD = 20 + p.EndsAt = now.Add(time.Hour) + if _, _, err := s.GrantGoAhead(ctx, p); err != nil { + t.Fatal(err) + } + } + all, _ := s.GoAheads(ctx, w.ID, true) + if _, _, err := s.RevokeGoAhead(ctx, all[0].ID); err != nil { + t.Fatal(err) + } + _, c, err := s.MarkDone(ctx, w.ID, "Shipped") + if err != nil || len(c.Parts) != 2 { + t.Fatalf("done: %+v %v", c, err) + } + active, _ := s.GoAheads(ctx, w.ID, false) + if len(active) != 0 { + t.Fatalf("active: %+v", active) + } + all, _ = s.GoAheads(ctx, w.ID, true) + ended := 0 + for _, g := range all { + if g.State != "ended" || g.EndedAt.IsZero() { + t.Fatalf("not ended: %+v", g) + } + if g.EndReason == "workstream_done" { + ended++ + } + } + if ended != 2 { + t.Fatalf("ended %d active scopes", ended) + } + _, c, err = s.MarkDone(ctx, w.ID, "Changed") + if err != nil || len(c.Parts) != 0 { + t.Fatalf("repeated done: %+v %v", c, err) + } + if _, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: w.ID, Scope: "wave", Wave: 1, CapUSD: 2, EndsAt: now.Add(time.Hour)}); !errors.Is(err, ErrConflict) { + t.Fatalf("grant on done: %v", err) + } + fresh := createStream(t, s, "Expiry", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "9", Factory: "f"}) + g, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: fresh.ID, Scope: "job", Factory: "f", Job: 9, CapUSD: 2, EndsAt: now.Add(time.Minute)}) + if err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + all, err = s.GoAheads(ctx, fresh.ID, true) + if err != nil || len(all) != 1 || all[0].EndReason != "expired" || !all[0].EndedAt.Equal(g.EndsAt) { + t.Fatalf("expiry: %+v %v", all, err) + } + active, _ = s.GoAheads(ctx, fresh.ID, false) + if len(active) != 0 { + t.Fatal("expired shown active") + } + g, c, err = s.RevokeGoAhead(ctx, g.ID) + if err != nil || g.EndReason != "expired" || len(c.Parts) != 0 { + t.Fatalf("expired revoke: %+v %+v %v", g, c, err) + } + if _, _, err := s.MarkDone(ctx, fresh.ID, ""); err != nil { + t.Fatal(err) + } + all, err = s.GoAheads(ctx, fresh.ID, true) + if err != nil || len(all) != 1 || all[0].EndReason != "expired" { + t.Fatalf("done changed expiry: %+v %v", all, err) + } +} + +// Part A (j): unknown readings stay absent and measured zero stays present. +func TestPartANumbersUnknownAndMeasuredZero(t *testing.T) { + jobs := []Job{{Factory: "f", Number: 1}, {Factory: "f", Number: 2}} + zero := 0.0 + no := false + yes := true + n := CountNumbers(jobs, nil, &zero, 3) + if n.Total != 2 || n.Merged != nil || n.NeedsYou != nil || n.SpendUSD != nil { + t.Fatalf("unknown: %+v", n) + } + facts := map[Job]JobFacts{jobs[0]: {State: "backlog", Merged: &no, CostUSD: &zero}, jobs[1]: {State: "backlog", Merged: &no, CostUSD: &zero}} + n = CountNumbers(jobs, facts, &zero, 0) + if n.Merged == nil || *n.Merged != 0 || n.NeedsYou == nil || *n.NeedsYou != 0 || n.SpendUSD == nil || *n.SpendUSD != 0 { + t.Fatalf("zero: %+v", n) + } + cost := 2.5 + agent := 1.0 + facts[jobs[0]] = JobFacts{State: "needs_you", Merged: &yes, CostUSD: &cost} + n = CountNumbers(jobs, facts, &agent, 3) + if *n.Merged != 1 || *n.NeedsYou != 4 || *n.SpendUSD != 3.5 { + t.Fatalf("numbers: %+v", n) + } + n = CountNumbers(jobs, facts, nil, 0) + if n.SpendUSD != nil || n.Merged == nil || n.NeedsYou == nil { + t.Fatalf("unknown agent: %+v", n) + } +} + +func TestPartANumbersWithPartiallyUnknownFacts(t *testing.T) { + jobs := []Job{{Factory: "f", Number: 1}, {Factory: "f", Number: 2}} + zero := 0.0 + no := false + known := JobFacts{State: "done", Merged: &no, CostUSD: &zero} + for _, facts := range []map[Job]JobFacts{ + {jobs[0]: known}, + {jobs[0]: known, jobs[1]: {State: "unknown", Merged: &no, CostUSD: &zero}}, + } { + n := CountNumbers(jobs, facts, &zero, 0) + if n.Total != 2 || n.Merged != nil || n.NeedsYou != nil || n.SpendUSD != nil { + t.Fatalf("partial unknown: %+v", n) + } + } + n := CountNumbers(jobs, map[Job]JobFacts{jobs[0]: known, jobs[1]: {State: "done", Merged: &no}}, &zero, 0) + if n.Merged == nil || n.NeedsYou == nil || n.SpendUSD != nil { + t.Fatalf("unknown cost: %+v", n) + } + n = CountNumbers(nil, nil, &zero, 2) + if n.Total != 0 || n.Merged == nil || *n.Merged != 0 || n.NeedsYou == nil || *n.NeedsYou != 2 || n.SpendUSD == nil || *n.SpendUSD != 0 { + t.Fatalf("no jobs: %+v", n) + } +} diff --git a/internal/workstream/store.go b/internal/workstream/store.go new file mode 100644 index 0000000000..1d7ecebf7b --- /dev/null +++ b/internal/workstream/store.go @@ -0,0 +1,535 @@ +package workstream + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +type Options struct { + Now func() time.Time + NewID func() (string, error) +} +type Store struct { + workspace *workspace.Store + now func() time.Time + newID func() (string, error) +} + +func New(w *workspace.Store, opts Options) *Store { + if opts.Now == nil { + opts.Now = time.Now + } + if opts.NewID == nil { + opts.NewID = func() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil + } + } + return &Store{workspace: w, now: opts.Now, newID: opts.NewID} +} + +func instant(ms int64) time.Time { return time.UnixMilli(ms).UTC() } +func optionalInstant(ms sql.NullInt64) time.Time { + if !ms.Valid { + return time.Time{} + } + return instant(ms.Int64) +} +func nullString(s string) any { + if s == "" { + return nil + } + return s +} +func nullInt(n int) any { + if n == 0 { + return nil + } + return n +} +func invalid(message string) error { return fmt.Errorf("%w: %s", ErrInvalid, message) } +func textWithin(s string, limit int) bool { + return len(s) <= limit && utf8.ValidString(s) && + strings.IndexFunc(s, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\t' }) < 0 +} +func lineWithin(s string, limit int) bool { + return len(s) <= limit && utf8.ValidString(s) && strings.IndexFunc(s, unicode.IsControl) < 0 +} +func actor(by string) (string, error) { + if by == "" { + by = "you" + } + if by != "you" && by != "agent" { + return "", invalid("actor must be you or agent") + } + return by, nil +} +func validTitle(title string) error { + if strings.TrimSpace(title) == "" || !lineWithin(title, 200) { + return invalid("title must be 1–200 bytes") + } + return nil +} +func sourceJSON(source *Source) (any, error) { + if source == nil { + return nil, nil + } + if source.ID == "" || !textWithin(source.ID, 4096) || !lineWithin(source.Label, 200) { + return nil, invalid("source needs an id and a label of at most 200 bytes") + } + switch source.Kind { + case "chat", "page", "url": + case "message": + if source.ChatID == "" { + return nil, invalid("message source needs chat_id") + } + case "factory_job": + if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: source.ID, Factory: source.Factory}).Validate(); err != nil { + return nil, err + } + default: + return nil, invalid("unknown source kind") + } + b, err := json.Marshal(source) + return string(b), err +} +func readSource(raw sql.NullString) (*Source, error) { + if !raw.Valid { + return nil, nil + } + var s Source + err := json.Unmarshal([]byte(raw.String), &s) + return &s, err +} +func found(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err +} +func touch(ctx context.Context, tx *sql.Tx, id string, at int64) error { + _, err := tx.ExecContext(ctx, "UPDATE workstreams SET updated_at=max(updated_at+1,?) WHERE id=?", at, id) + return err +} +func changed(id string, part ChangedPart) Change { + c := Change{ID: id, Parts: []ChangedPart{PartWorkstream}} + if part != PartWorkstream { + c.Parts = append(c.Parts, part) + } + return c +} + +const workstreamSelect = `SELECT w.id,c.name,w.why,w.origin_chat_id,w.agent_id,w.autonomy,w.wake_morning,w.wake_related_issues, + w.budget_usd,w.created_at,w.updated_at,w.started_at,w.done_at,w.how_it_went,w.client_id, + (SELECT count(*) FROM workstream_records r WHERE r.workstream_id=w.id AND r.state='proposed') + FROM workstreams w JOIN collections c ON c.id=w.id WHERE w.id=?` + +func getIn(ctx context.Context, tx *sql.Tx, id string) (Workstream, error) { + var w Workstream + var created, updated int64 + var start, done sql.NullInt64 + var budget sql.NullFloat64 + err := tx.QueryRowContext(ctx, workstreamSelect, id).Scan(&w.ID, &w.Name, &w.Why, &w.OriginChatID, &w.AgentID, &w.Autonomy, &w.Wake.Morning, &w.Wake.RelatedIssues, &budget, &created, &updated, &start, &done, &w.HowItWent, &w.ClientID, &w.Proposed) + if err != nil { + return w, found(err) + } + w.CreatedAt, w.UpdatedAt, w.StartedAt, w.DoneAt = instant(created), instant(updated), optionalInstant(start), optionalInstant(done) + if budget.Valid { + w.BudgetUSD = &budget.Float64 + } + w.Stage = Stage(w.DoneAt, w.StartedAt, nil) + rows, err := tx.QueryContext(ctx, "SELECT kind,ref_id,session_id,factory FROM memberships WHERE collection_id=? ORDER BY seq", id) + if err != nil { + return w, err + } + defer rows.Close() + w.Members = make([]workspace.Ref, 0) + for rows.Next() { + var ref workspace.Ref + if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID, &ref.Factory); err != nil { + return w, err + } + w.Members = append(w.Members, ref) + if ref.Kind == workspace.FactoryJobKind { + w.Total++ + } + } + return w, rows.Err() +} + +func (s *Store) Get(ctx context.Context, id string) (Workstream, error) { + var out Workstream + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return ErrNotFound + } + var err error + out, err = getIn(ctx, tx, id) + return err + }) + return out, err +} + +// ByClientID lets an adapter recognize a retry before creating its agent. +// Create checks it again inside the writer transaction to settle races. +func (s *Store) ByClientID(ctx context.Context, clientID string) (Workstream, error) { + var out Workstream + if clientID == "" { + return out, ErrNotFound + } + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + var id string + if err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", clientID).Scan(&id); err != nil { + return found(err) + } + var err error + out, err = getIn(ctx, tx, id) + return err + }) + return out, err +} + +func (s *Store) List(ctx context.Context) ([]Workstream, error) { + out := make([]Workstream, 0) + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT id FROM workstreams ORDER BY updated_at DESC,id") + if err != nil { + return err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, id := range ids { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + out = append(out, w) + } + return nil + }) + return out, err +} + +func (s *Store) Create(ctx context.Context, p Create) (Workstream, Change, error) { + p.Name = strings.TrimSpace(p.Name) + if err := workspace.ValidateName(p.Name); err != nil { + return Workstream{}, Change{}, err + } + if !textWithin(p.Why, 4000) || !lineWithin(p.ClientID, 128) || len(p.Refs) > 200 { + return Workstream{}, Change{}, invalid("create exceeds workstream bounds") + } + refs := append([]workspace.Ref(nil), p.Refs...) + refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.AgentID}) + if p.OriginChatID != "" { + refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.OriginChatID}) + } + additions := make([]workspace.Addition, 0, len(refs)) + for _, ref := range refs { + if err := ref.Validate(); err != nil { + return Workstream{}, Change{}, err + } + additions = append(additions, workspace.Addition{Ref: ref}) + } + var out Workstream + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + if p.ClientID != "" { + var id string + err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", p.ClientID).Scan(&id) + if err == nil { + out, err = getIn(ctx, tx, id) + return err + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + } + id, err := s.newID() + if err != nil { + return err + } + at := s.now().UnixMilli() + if err := workspace.CreateIn(ctx, tx, id, p.Name, at); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstreams(id,why,origin_chat_id,agent_id,created_at,updated_at,client_id) VALUES (?,?,?,?,?,?,?)`, id, p.Why, p.OriginChatID, p.AgentID, at, at, p.ClientID); err != nil { + return err + } + if _, _, err := workspace.AddRefsIn(ctx, tx, id, additions, at); err != nil { + return err + } + pageID, err := s.newID() + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc,overview) VALUES (?,?,'Overview','you',0,1,?,'you',?,1)`, pageID, id, at, emptyDoc); err != nil { + return err + } + if err := saveVersion(ctx, tx, pageID); err != nil { + return err + } + out, err = getIn(ctx, tx, id) + c = Change{ID: id, Parts: []ChangedPart{PartWorkstream, PartPages, PartJobs}} + return err + }) + if err != nil { + return Workstream{}, Change{}, err + } + return out, c, nil +} + +func (s *Store) Update(ctx context.Context, id string, p Patch) (Workstream, Change, error) { + if p.Name != nil { + name := strings.TrimSpace(*p.Name) + p.Name = &name + if err := workspace.ValidateName(name); err != nil { + return Workstream{}, Change{}, err + } + } + if p.Why != nil && !textWithin(*p.Why, 4000) { + return Workstream{}, Change{}, invalid("why exceeds 4000 bytes") + } + if p.Autonomy != nil && *p.Autonomy != "propose" && *p.Autonomy != "small" && *p.Autonomy != "off" { + return Workstream{}, Change{}, invalid("unknown autonomy") + } + if p.BudgetUSD != nil && (math.IsNaN(*p.BudgetUSD) || math.IsInf(*p.BudgetUSD, 0) || *p.BudgetUSD < 0) { + return Workstream{}, Change{}, invalid("budget must be finite and non-negative") + } + var out Workstream + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + before := w + if p.Name != nil { + w.Name = *p.Name + } + if p.Why != nil { + w.Why = *p.Why + } + if p.Autonomy != nil { + w.Autonomy = *p.Autonomy + } + if p.Wake != nil { + w.Wake = *p.Wake + } + if p.BudgetUSD != nil { + w.BudgetUSD = p.BudgetUSD + if *p.BudgetUSD == 0 { + w.BudgetUSD = nil + } + } + sameBudget := (w.BudgetUSD == nil && before.BudgetUSD == nil) || (w.BudgetUSD != nil && before.BudgetUSD != nil && *w.BudgetUSD == *before.BudgetUSD) + if w.Name == before.Name && w.Why == before.Why && w.Autonomy == before.Autonomy && w.Wake == before.Wake && sameBudget { + out = w + return nil + } + if w.Name != before.Name { + if err := workspace.RenameIn(ctx, tx, id, w.Name); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE workstreams SET why=?,autonomy=?,wake_morning=?,wake_related_issues=?,budget_usd=?,updated_at=max(updated_at+1,?) WHERE id=?`, w.Why, w.Autonomy, w.Wake.Morning, w.Wake.RelatedIssues, w.BudgetUSD, s.now().UnixMilli(), id); err != nil { + return err + } + out, err = getIn(ctx, tx, id) + c = changed(id, PartWorkstream) + return err + }) + if err != nil { + return Workstream{}, Change{}, err + } + return out, c, nil +} + +// Stage follows the wire rule; dropped and unknown states do not start a workstream. +func Stage(done, started time.Time, states []string) string { + if !done.IsZero() { + return "done" + } + if !started.IsZero() { + return "building" + } + for _, state := range states { + switch state { + case "queued", "running", "needs_you", "done": + return "building" + } + } + return "shaping" +} + +// Jobs returns direct factory job members in collection order. +func (w Workstream) Jobs() []Job { + out := make([]Job, 0, w.Total) + for _, ref := range w.Members { + if ref.Kind == workspace.FactoryJobKind { + n, _ := strconv.Atoi(ref.ID) + out = append(out, Job{Factory: ref.Factory, Number: n}) + } + } + return out +} +func (s *Store) ObserveJobStates(ctx context.Context, id string, states map[Job]string) (Change, error) { + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if !w.StartedAt.IsZero() { + return nil + } + memberStates := make([]string, 0, w.Total) + for _, job := range w.Jobs() { + memberStates = append(memberStates, states[job]) + } + if Stage(time.Time{}, time.Time{}, memberStates) != "building" { + return nil + } + at := s.now().UnixMilli() + _, err = tx.ExecContext(ctx, "UPDATE workstreams SET started_at=?,updated_at=max(updated_at+1,?) WHERE id=?", at, at, id) + if err == nil { + c = changed(id, PartWorkstream) + } + return err + }) + if err != nil { + return Change{}, err + } + return c, nil +} +func (s *Store) MarkDone(ctx context.Context, id, how string) (Workstream, Change, error) { + if !textWithin(how, 4000) { + return Workstream{}, Change{}, invalid("how_it_went exceeds 4000 bytes") + } + return s.setDone(ctx, id, how, true) +} +func (s *Store) Reopen(ctx context.Context, id string) (Workstream, Change, error) { + return s.setDone(ctx, id, "", false) +} +func (s *Store) setDone(ctx context.Context, id, how string, done bool) (Workstream, Change, error) { + var out Workstream + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if done == !w.DoneAt.IsZero() { + out = w + return nil + } + at := s.now().UnixMilli() + if done { + if _, err := tx.ExecContext(ctx, "UPDATE workstreams SET done_at=?,how_it_went=?,updated_at=max(updated_at+1,?) WHERE id=?", at, how, at, id); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `UPDATE workstream_goaheads SET state='ended', + end_reason=CASE WHEN ends_at<=? THEN 'expired' ELSE 'workstream_done' END, + ended_at=CASE WHEN ends_at<=? THEN ends_at ELSE ? END + WHERE workstream_id=? AND state='active'`, at, at, at, id) + if err != nil { + return err + } + c = changed(id, PartWorkstream) + n, err := result.RowsAffected() + if err != nil { + return err + } + if n > 0 { + c.Parts = append(c.Parts, PartGoAheads) + } + } else { + if _, err := tx.ExecContext(ctx, "UPDATE workstreams SET done_at=NULL,started_at=coalesce(started_at,?),updated_at=max(updated_at+1,?) WHERE id=?", at, at, id); err != nil { + return err + } + c = changed(id, PartWorkstream) + } + out, err = getIn(ctx, tx, id) + return err + }) + if err != nil { + return Workstream{}, Change{}, err + } + return out, c, nil +} + +// CountNumbers retains measured zeroes and withholds any total needing unknown +// facts. Job state must be known even for spend, as required by the contract. +func CountNumbers(jobs []Job, facts map[Job]JobFacts, agentSpend *float64, proposed int) Numbers { + out := Numbers{Total: len(jobs)} + merged, needs, spend := 0, proposed, 0.0 + mergedKnown, statesKnown, costKnown := true, true, true + for _, job := range jobs { + f, ok := facts[job] + known := ok && (f.State == "backlog" || f.State == "queued" || f.State == "running" || f.State == "needs_you" || f.State == "done" || f.State == "dropped") + if !known { + statesKnown = false + } + if !known || f.Merged == nil { + mergedKnown = false + } else if *f.Merged { + merged++ + } + if f.State == "needs_you" { + needs++ + } + if !known || f.CostUSD == nil || math.IsNaN(*f.CostUSD) || math.IsInf(*f.CostUSD, 0) || *f.CostUSD < 0 { + costKnown = false + } else { + spend += *f.CostUSD + } + } + if mergedKnown { + out.Merged = &merged + } + if statesKnown { + out.NeedsYou = &needs + } + if costKnown && agentSpend != nil && !math.IsNaN(*agentSpend) && !math.IsInf(*agentSpend, 0) && *agentSpend >= 0 { + spend += *agentSpend + if !math.IsInf(spend, 0) { + out.SpendUSD = &spend + } + } + return out +} diff --git a/internal/workstream/store_test.go b/internal/workstream/store_test.go new file mode 100644 index 0000000000..3995666199 --- /dev/null +++ b/internal/workstream/store_test.go @@ -0,0 +1,215 @@ +package workstream + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +func testStore(t *testing.T) (*Store, *workspace.Store, *time.Time) { + t.Helper() + w, err := workspace.Open(filepath.Join(t.TempDir(), "collections.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := w.Close(); err != nil { + t.Error(err) + } + }) + now := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC) + n := 0 + s := New(w, Options{Now: func() time.Time { return now }, NewID: func() (string, error) { n++; return fmt.Sprintf("id-%d", n), nil }}) + return s, w, &now +} + +func createStream(t *testing.T, s *Store, name string, refs ...workspace.Ref) Workstream { + t.Helper() + row, _, err := s.Create(context.Background(), Create{Name: name, AgentID: "agent-" + name, Refs: refs}) + if err != nil { + t.Fatal(err) + } + return row +} + +// Part A create: storage creates exactly one collection, row and Overview; +// client retries and refused job ownership leave no partial creation. +func TestPartACreateIsAtomicAndIdempotent(t *testing.T) { + s, w, _ := testStore(t) + ctx := context.Background() + args := Create{Name: "Billing", AgentID: "agent", OriginChatID: "origin", ClientID: "retry", Refs: []workspace.Ref{{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}}} + first, c, err := s.Create(ctx, args) + if err != nil || len(c.Parts) == 0 { + t.Fatalf("create: %+v %v", c, err) + } + indexed, err := s.ByClientID(ctx, "retry") + if err != nil || indexed.ID != first.ID { + t.Fatalf("client lookup: %+v %v", indexed, err) + } + if _, err := s.ByClientID(ctx, ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("empty client id: %v", err) + } + args.Name = "Other" + args.AgentID = "other" + again, c, err := s.Create(ctx, args) + if err != nil || again.ID != first.ID || len(c.Parts) != 0 { + t.Fatalf("retry: %+v %+v %v", again, c, err) + } + all, err := s.List(ctx) + if err != nil || len(all) != 1 { + t.Fatalf("rows: %v %v", all, err) + } + collections, err := w.Collections(ctx) + if err != nil || len(collections) != 1 || !collections[0].Workstream { + t.Fatalf("collections: %v %v", collections, err) + } + pages, err := s.Pages(ctx, first.ID) + if err != nil || len(pages) != 1 || pages[0].Title != "Overview" || pages[0].Owner != "you" || pages[0].Version != 1 || pages[0].Position != 0 { + t.Fatalf("pages: %v %v", pages, err) + } + p, err := s.Page(ctx, pages[0].ID, true) + if err != nil || string(p.Doc) != `{"type":"doc","content":[]}` || p.Markdown == nil || *p.Markdown != "" { + t.Fatalf("overview: %+v %v", p, err) + } + members, err := w.Members(ctx, first.ID) + if err != nil || len(members) != 3 { + t.Fatalf("members: %v %v", members, err) + } + _, _, err = s.Create(ctx, Create{Name: "Conflict", AgentID: "third", Refs: args.Refs}) + var taken *workspace.WorkstreamTaken + if !errors.As(err, &taken) || taken.WorkstreamID != first.ID || taken.WorkstreamName != "Billing" { + t.Fatalf("taken: %v", err) + } + all, _ = s.List(ctx) + collections, _ = w.Collections(ctx) + if len(all) != 1 || len(collections) != 1 { + t.Fatal("failed create left state") + } +} + +// Part A (b): a batch add rolls back every ref and both workstream timestamps. +func TestPartAJobOwnershipRefusalAndPlainDuplicate(t *testing.T) { + s, w, now := testStore(t) + ctx := context.Background() + job := workspace.Ref{Kind: workspace.FactoryJobKind, ID: "17", Factory: "factory"} + a := createStream(t, s, "First", job) + b := createStream(t, s, "Second") + before, _ := w.Members(ctx, b.ID) + *now = now.Add(time.Hour) + _, _, err := w.AddRefs(ctx, b.ID, []workspace.Addition{{Ref: workspace.Ref{Kind: workspace.ConversationKind, ID: "extra"}}, {Ref: job}}) + var taken *workspace.WorkstreamTaken + if !errors.As(err, &taken) || taken.Factory != "factory" || taken.Job != 17 || taken.WorkstreamID != a.ID || taken.WorkstreamName != "First" { + t.Fatalf("taken: %+v %v", taken, err) + } + after, _ := w.Members(ctx, b.ID) + a2, _ := s.Get(ctx, a.ID) + b2, _ := s.Get(ctx, b.ID) + if !reflect.DeepEqual(before, after) || !a.UpdatedAt.Equal(a2.UpdatedAt) || !b.UpdatedAt.Equal(b2.UpdatedAt) { + t.Fatal("refused add changed state") + } + plain, err := w.Create(ctx, "Plain") + if err != nil { + t.Fatal(err) + } + if err := w.Add(ctx, plain.ID, job); err != nil { + t.Fatal(err) + } + duplicate, err := w.Duplicate(ctx, a.ID, "Copy") + if err != nil || duplicate.Workstream { + t.Fatalf("duplicate: %+v %v", duplicate, err) + } + if _, err := s.Get(ctx, duplicate.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("duplicate became workstream: %v", err) + } + if _, err := w.Delete(ctx, a.ID); !errors.Is(err, workspace.ErrWorkstreamDelete) { + t.Fatalf("delete: %v", err) + } + if err := w.Remove(ctx, a.ID, job); err != nil { + t.Fatal(err) + } + if err := w.Add(ctx, b.ID, job); err != nil { + t.Fatal(err) + } + b2, _ = s.Get(ctx, b.ID) + if !b2.UpdatedAt.After(b.UpdatedAt) { + t.Fatal("membership did not update timestamp") + } +} + +// Part A (d): stage persists the first departure, all merged is not Done, +// and reopening an unstarted workstream gives Building even with Backlog. +func TestPartAStageAndObservation(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + job := workspace.Ref{Kind: workspace.FactoryJobKind, ID: "1", Factory: "f"} + row := createStream(t, s, "Stage", job) + for _, state := range []string{"", "backlog", "dropped", "unknown"} { + if got := Stage(time.Time{}, time.Time{}, []string{state}); got != "shaping" { + t.Fatalf("%s: %s", state, got) + } + } + for _, state := range []string{"queued", "running", "needs_you", "done"} { + if got := Stage(time.Time{}, time.Time{}, []string{state}); got != "building" { + t.Fatalf("%s: %s", state, got) + } + } + c, err := s.ObserveJobStates(ctx, row.ID, map[Job]string{{Factory: "f", Number: 1}: "queued"}) + if err != nil || len(c.Parts) == 0 { + t.Fatalf("observe: %+v %v", c, err) + } + got, _ := s.Get(ctx, row.ID) + if got.StartedAt.IsZero() || got.Stage != "building" { + t.Fatalf("started: %+v", got) + } + c, err = s.ObserveJobStates(ctx, row.ID, map[Job]string{{Factory: "f", Number: 1}: "backlog"}) + if err != nil || len(c.Parts) != 0 { + t.Fatal("observation reset start") + } + done, _, err := s.MarkDone(ctx, row.ID, "") + if err != nil || done.Stage != "done" { + t.Fatalf("done: %+v %v", done, err) + } + open, _, err := s.Reopen(ctx, row.ID) + if err != nil || open.Stage != "building" { + t.Fatalf("reopen: %+v %v", open, err) + } + fresh := createStream(t, s, "Unstarted") + _, _, err = s.MarkDone(ctx, fresh.ID, "") + if err != nil { + t.Fatal(err) + } + open, _, err = s.Reopen(ctx, fresh.ID) + if err != nil || open.StartedAt.IsZero() || open.Stage != "building" { + t.Fatalf("unstarted reopen: %+v %v", open, err) + } +} + +func TestPartAUpdateClearsSettingsAndKeepsNameRules(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + row := createStream(t, s, "Settings") + why := "Because" + budget := 20.0 + small := "small" + row, _, err := s.Update(ctx, row.ID, Patch{Why: &why, BudgetUSD: &budget, Autonomy: &small, Wake: &Wake{Morning: true}}) + if err != nil || row.BudgetUSD == nil || *row.BudgetUSD != 20 || !row.Wake.Morning { + t.Fatalf("update: %+v %v", row, err) + } + why = "" + budget = 0 + row, _, err = s.Update(ctx, row.ID, Patch{Why: &why, BudgetUSD: &budget}) + if err != nil || row.Why != "" || row.BudgetUSD != nil { + t.Fatalf("clear: %+v %v", row, err) + } + createStream(t, s, "Taken") + name := "taken" + if _, _, err := s.Update(ctx, row.ID, Patch{Name: &name}); !errors.Is(err, workspace.ErrNameTaken) { + t.Fatalf("name: %v", err) + } +} diff --git a/internal/workstream/workstream.go b/internal/workstream/workstream.go new file mode 100644 index 0000000000..ce31042bb1 --- /dev/null +++ b/internal/workstream/workstream.go @@ -0,0 +1,233 @@ +// Package workstream owns workstream rules and durable state. Adapters supply +// job observations and recorded spend; execution owners stay outside this package. +package workstream + +import ( + "encoding/json" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const ( + DocMax = 1 << 20 + emptyDoc = `{"type":"doc","content":[]}` +) + +var ( + ErrInvalid = workspace.ErrInvalid + ErrNotFound = errors.New("workstream target not found") + ErrConflict = errors.New("workstream conflict") + ErrCycle = errors.New("page move would form a cycle") + ErrTooLarge = errors.New("encoded document exceeds 1 MiB") +) + +type ChangedPart string + +const ( + PartWorkstream ChangedPart = "workstream" + PartPlan ChangedPart = "plan" + PartPages ChangedPart = "pages" + PartRecords ChangedPart = "records" + PartGoAheads ChangedPart = "goaheads" + PartJobs ChangedPart = "jobs" +) + +// Change is empty for an unchanged write. Adapters emit it only after success. +type Change struct { + ID string + Parts []ChangedPart +} + +type VersionConflict struct { + ID string + Version int + UpdatedAt time.Time + UpdatedBy string +} + +func (e *VersionConflict) Error() string { return "the version changed since it was read" } + +type Wake struct{ Morning, RelatedIssues bool } +type Workstream struct { + ID string + Name string + Why string + OriginChatID string + AgentID string + Autonomy string + HowItWent string + ClientID string + Wake Wake + BudgetUSD *float64 + CreatedAt time.Time + UpdatedAt time.Time + StartedAt time.Time + DoneAt time.Time + Stage string + Members []workspace.Ref + Proposed int + Total int +} +type Create struct { + Name string + Why string + OriginChatID string + AgentID string + ClientID string + Refs []workspace.Ref +} +type Patch struct { + Name, Why, Autonomy *string + Wake *Wake + BudgetUSD *float64 +} + +type Job struct { + Factory string + Number int +} + +// JobFacts is absent from the facts map until a job was successfully read. +// Individual facts may remain unknown even when its state is known. +type JobFacts struct { + State string + Merged *bool + CostUSD *float64 +} +type Numbers struct { + Total int + Merged, NeedsYou *int + SpendUSD *float64 +} + +type Source struct { + Kind string `json:"kind"` + ID string `json:"id"` + ChatID string `json:"chat_id,omitempty"` + Factory string `json:"factory,omitempty"` + Label string `json:"label,omitempty"` +} + +// Attribution permits later agent tools to use the same domain operations. +// A zero attribution means the person; proposal authors default to agent. +type Attribution struct { + By string + From *Source +} + +type Milestone struct { + ID string `json:"id"` + Title string `json:"title"` +} +type PlanItem struct { + Kind string `json:"kind"` + Factory string `json:"factory,omitempty"` + Job int `json:"job,omitempty"` + DraftID string `json:"draft_id,omitempty"` + Title string `json:"title,omitempty"` + Repo string `json:"repo,omitempty"` + Text string `json:"text,omitempty"` +} +type Wave struct { + N int `json:"n"` + Title string `json:"title,omitempty"` + MilestoneID string `json:"milestone_id,omitempty"` + AutoStart bool `json:"auto_start"` + Items []PlanItem `json:"items"` +} +type Plan struct { + WorkstreamID string + Version int + UpdatedAt time.Time + UpdatedBy string + Milestones []Milestone + Waves []Wave +} + +type PageNode struct { + ID string + WorkstreamID string + ParentID string + Title string + Owner string + ReadByJobs bool + Position, Version int + UpdatedAt time.Time + UpdatedBy string + UpdatedFrom *Source +} +type Page struct { + PageNode + Doc json.RawMessage + Markdown *string +} +type PageVersion struct { + Version int + SavedAt time.Time + SavedBy string + From *Source + Title string + Bytes int +} +type PageCreate struct { + WorkstreamID string + ParentID string + AfterID string + Title string + Owner string + ReadByJobs bool + Doc json.RawMessage + Attribution +} +type PagePatch struct { + Title, Owner *string + ReadByJobs *bool + Attribution +} + +type Record struct { + ID string + WorkstreamID string + Kind string + Text string + State string + By string + Source *Source + CreatedAt, DecidedAt time.Time + Supersedes, SupersededBy string +} +type RecordPropose struct { + WorkstreamID, Kind, Text string + Source *Source + Kept bool + By string +} +type RecordReplacement struct { + Kind, Text string + Source *Source + By string +} + +type GoAhead struct { + ID string + WorkstreamID string + Scope string + Factory string + MilestoneID string + Job, Wave int + CapUSD float64 + EndsAt time.Time + State, EndReason string + GrantedAt, EndedAt time.Time +} +type GoAheadGrant struct { + WorkstreamID string + Scope string + Factory string + MilestoneID string + Job, Wave int + CapUSD float64 + EndsAt time.Time +}