diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index d3ad7b5bad..718e985d7b 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -201,6 +201,7 @@ func runApp(args []string) error { // ONE SUPERVISOR FOR EVERY CONVERSATION THE APP BUILDS (appservices.go). services := openAppServices() appServiceDoor.Store(services) + appWorkstreamAgents.Store(backend) backend.attachServices(services) guard.Go("app/crews-start", func() { backend.crews() }) // THE APP IS AN OPEN WINDOW for as long as it runs, so the clock that runs @@ -2981,7 +2982,10 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if held { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - b.spendMoved(func(spend appwire.Spend) { b.moneyAfter(a, live, spend.TodayUSD) }) + b.spendMoved(func(spend appwire.Spend) { + b.moneyAfter(a, live, spend.TodayUSD) + b.workstreamAgentTurnSettled(id) + }) } // ── entry ids ─────────────────────────────────────────────────────────────── diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 4be96ab2d6..b504b922fe 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -44,6 +44,8 @@ type appWorkstreams struct { cacheMu sync.Mutex cache map[string]workstreamQueueRead flights map[string]chan struct{} + briefMu sync.Mutex + briefs map[string]session.WorkstreamBrief } type workstreamQueueRead struct { @@ -98,6 +100,8 @@ func appWorkstreamError(err error) error { return appwire.Errorf(appwire.CodeCycle, "%s", err) case errors.Is(err, workstream.ErrTooLarge): return appwire.Errorf(appwire.CodeTooLarge, "%s", err) + case errors.Is(err, workstream.ErrNotAgentPage): + return appwire.Errorf(appwire.CodeBadParams, "%s", err) } return appCollectionError(err) } @@ -175,6 +179,7 @@ func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.Wor } jobs := []workstream.Job{} for _, w := range rows { + b.workstreamRememberBrief(w) jobs = append(jobs, w.Jobs()...) } facts := b.workstreamFacts(jobs) @@ -185,6 +190,7 @@ func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.Wor if err != nil { return appwire.WorkstreamListResult{}, err } + b.workstreamRememberBrief(w) 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}) } @@ -197,6 +203,7 @@ func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.Works if err != nil { return appwire.WorkstreamGetResult{}, workstream.Change{}, err } + b.workstreamRememberBrief(w) 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 @@ -206,8 +213,8 @@ func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.Works // 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"} + // Engine assembly attaches the prompt and tools through appWorkstreamAgentDoor. + return appwire.CreateAsk{Model: workstreamValue(p.Model), CreatedBy: workstreamCreatedBy} } // workstreamPreflight checks store-owned refusals without writing or migrating. @@ -287,32 +294,16 @@ func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire if existing != nil && workstreamValue(p.ClientID) != "" { w, err := existing.ByClientID(context.Background(), *p.ClientID) if err == nil { + b.workstreamRememberBrief(w) 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 { + if err := b.workstreamCreatePreflight(p); 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 @@ -321,43 +312,74 @@ func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire if err != nil { return zero, err } + w, change, err := b.workstreamCreateWithAgent(p, agent) + if err != nil { + return zero, appWorkstreamError(err) + } + b.workstreamRememberBrief(w) + 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) workstreamCreatePreflight(p appwire.WorkstreamCreateParams) error { + if p.OriginChatID != nil { + if _, err := b.find(*p.OriginChatID); err != nil { + return err + } + } + store, err := b.collectionStore(false) + if err != nil { + return err + } + if store != nil { + return appWorkstreamError(workstreamPreflight(store, p)) + } + for _, ref := range p.Refs { + if ref.Kind == appwire.RefCollection { + return appCollectionError(workspace.ErrNotFound) + } + } + return nil +} + +func (b *appBackend) workstreamCreateWithAgent(p appwire.WorkstreamCreateParams, agent appwire.Agent) (workstream.Workstream, workstream.Change, error) { 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) + 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 cleanup := b.workstreamCloseUnusedAgent(agent.ID, err); cleanup != nil { + return workstream.Workstream{}, workstream.Change{}, cleanup } } - if err != nil { - return zero, appWorkstreamError(err) + return w, change, err +} + +func (b *appBackend) workstreamCloseUnusedAgent(id string, cause error) error { + closeAgent := b.workstreams.closeAgent + if closeAgent == nil { + closeAgent = func(id string) error { return b.MoveToClosed([]string{id}) } } - b.workstreamChanged(change) - if change.ID != "" { - b.collectionChanged(w.ID, false) + if cleanup := closeAgent(id); cleanup != nil { + var wire *appwire.Error + if cause != nil && errors.As(appWorkstreamError(cause), &wire) { + copy := *wire + copy.Message += "; the unused workstream agent could not be closed" + return © + } + return appwire.Errorf(appwire.CodeEngine, "the unused workstream agent could not be closed: %s", appUnaskedReason("the conversation", cleanup)) } - return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil + return nil } func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { @@ -370,6 +392,7 @@ func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire if err != nil { return appwire.WorkstreamUpdateResult{}, c, err } + b.workstreamRememberBrief(w) if c.ID != "" && p.Patch.Name != nil { b.collectionChanged(w.ID, false) } @@ -385,6 +408,7 @@ func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (app if err != nil { return appwire.WorkstreamMarkDoneResult{}, c, err } + b.workstreamRememberBrief(w) 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 @@ -422,6 +446,7 @@ func (b *appBackend) WorkstreamReopen(p appwire.WorkstreamReopenParams) (appwire if err != nil { return appwire.WorkstreamReopenResult{}, c, err } + b.workstreamRememberBrief(w) 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 diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go new file mode 100644 index 0000000000..9fa75788a6 --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent.go @@ -0,0 +1,357 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + "sync/atomic" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +const workstreamCreatedBy = "workstream" + +var appWorkstreamAgents atomic.Pointer[appBackend] + +type workstreamAgentDoor struct { + backend *appBackend + agentID string +} + +var _ session.WorkstreamDoor = (*workstreamAgentDoor)(nil) + +func appWorkstreamAgentDoor(sessionFile string) session.WorkstreamDoor { + if b := appWorkstreamAgents.Load(); b != nil { + return b.workstreamAgentDoor(sessionFile) + } + return nil +} + +func (b *appBackend) workstreamAgentDoor(sessionFile string) session.WorkstreamDoor { + id := appAgentID(sessionFile) + if b.createdBy(id) != workstreamCreatedBy { + return nil + } + return &workstreamAgentDoor{backend: b, agentID: id} +} + +func (d *workstreamAgentDoor) workstreamOwner(ctx context.Context, writing bool) (*workstream.Store, workstream.Workstream, error) { + s, err := d.backend.workstreamStore(false) + if err != nil { + return nil, workstream.Workstream{}, err + } + if s == nil { + return nil, workstream.Workstream{}, session.ErrWorkstreamMissing + } + w, err := s.ByAgent(ctx, d.agentID) + if err == nil { + d.backend.workstreamRememberBrief(w) + } + if errors.Is(err, workstream.ErrNotFound) { + err = session.ErrWorkstreamMissing + } + if err == nil && writing && !w.DoneAt.IsZero() { + err = session.ErrWorkstreamDone + } + return s, w, err +} + +func (b *appBackend) workstreamRememberBrief(w workstream.Workstream) { + state := &b.workstreams + state.briefMu.Lock() + defer state.briefMu.Unlock() + if state.briefs == nil { + state.briefs = make(map[string]session.WorkstreamBrief) + } + state.briefs[w.AgentID] = session.WorkstreamBrief{Name: w.Name, Why: w.Why} +} + +func (d *workstreamAgentDoor) Brief() (session.WorkstreamBrief, bool) { + state := &d.backend.workstreams + // Prompt rendering holds the session lock, so even cache contention falls back. + if !state.briefMu.TryLock() { + return session.WorkstreamBrief{}, false + } + defer state.briefMu.Unlock() + brief, ok := state.briefs[d.agentID] + return brief, ok +} + +func (d *workstreamAgentDoor) Read(ctx context.Context, ask session.WorkstreamReadAsk) (json.RawMessage, error) { + s, w, err := d.workstreamOwner(ctx, false) + if err != nil { + return nil, err + } + switch ask.What { + case "workstream": + out, err := d.backend.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil { + return nil, err + } + return json.Marshal(out.Workstream) + case "pages": + return workstreamAgentReadPages(ctx, s, w.ID) + case "page": + p, err := workstreamAgentPage(ctx, s, w.ID, ask.PageID, true) + if err != nil { + return nil, err + } + return json.Marshal(struct { + appwire.WorkstreamPageNode + Markdown *string `json:"markdown"` + }{appWorkstreamPageNode(p.PageNode), p.Markdown}) + case "records": + return workstreamAgentReadRecords(ctx, s, w.ID, ask) + case "plan": + p, err := s.Plan(ctx, w.ID) + if err != nil { + return nil, err + } + return json.Marshal(appwire.WorkstreamPlanGetResult{Plan: appWorkstreamPlan(p)}) + case "goaheads": + return workstreamAgentReadGoAheads(ctx, s, w.ID) + } + return nil, errors.New("what must name a workstream part") +} + +func workstreamAgentReadPages(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { + pages, err := s.Pages(ctx, id) + if err != nil { + return nil, err + } + out := appwire.WorkstreamPageListResult{Pages: []appwire.WorkstreamPageNode{}} + for _, p := range pages { + out.Pages = append(out.Pages, appWorkstreamPageNode(p)) + } + return json.Marshal(out) +} + +func workstreamAgentReadRecords(ctx context.Context, s *workstream.Store, id string, ask session.WorkstreamReadAsk) (json.RawMessage, error) { + records, err := s.Records(ctx, id, ask.Kind, ask.State) + if err != nil { + return nil, err + } + out := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} + for _, r := range records { + out.Records = append(out.Records, appWorkstreamRecord(r)) + } + return json.Marshal(out) +} + +func workstreamAgentReadGoAheads(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { + grants, err := s.GoAheads(ctx, id, true) + if err != nil { + return nil, err + } + out := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} + for _, g := range grants { + out.GoAheads = append(out.GoAheads, appWorkstreamGoAhead(g)) + } + return json.Marshal(out) +} + +func workstreamAgentPage(ctx context.Context, s *workstream.Store, id, pageID string, markdown bool) (workstream.Page, error) { + p, err := s.Page(ctx, pageID, markdown) + if err == nil && p.WorkstreamID != id { + err = workstream.ErrNotFound + } + return p, err +} + +func (d *workstreamAgentDoor) Propose(ctx context.Context, p session.WorkstreamProposal) (session.WorkstreamProposed, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return session.WorkstreamProposed{}, err + } + if p.GoAhead != nil { + p.Kind = "decision" + p.Text, err = d.workstreamGoAheadText(ctx, s, w, *p.GoAhead) + if err != nil { + return session.WorkstreamProposed{}, err + } + } + source, err := d.workstreamSource(ctx, s, w, p.Source) + if err != nil { + return session.WorkstreamProposed{}, err + } + r, change, err := s.ProposeRecord(ctx, workstream.RecordPropose{WorkstreamID: w.ID, Kind: p.Kind, Text: p.Text, Source: source, By: "agent", Kept: false}) + d.backend.workstreamChanged(change) + return session.WorkstreamProposed{ID: r.ID}, workstreamAgentError(err) +} + +func (d *workstreamAgentDoor) workstreamSource(ctx context.Context, s *workstream.Store, w workstream.Workstream, asked *workstream.Source) (*workstream.Source, error) { + if asked == nil { + return &workstream.Source{Kind: "chat", ID: d.agentID}, nil + } + source := *asked + if source.ChatID != "" || source.Kind != "factory_job" && source.Factory != "" { + return nil, errors.New("The source has fields for another kind.") + } + switch source.Kind { + case "factory_job": + n, err := strconv.Atoi(source.ID) + if err != nil || strconv.Itoa(n) != source.ID || !workstreamAgentMember(w, source.Factory, n) { + return nil, errors.New("The source job must be a member of this workstream, with its factory and decimal job number.") + } + case "page": + if _, err := workstreamAgentPage(ctx, s, w.ID, source.ID, false); err != nil { + return nil, err + } + case "url": + if err := workstreamAgentSourceURL(source.ID); err != nil { + return nil, err + } + default: + return nil, errors.New("source.kind must be factory_job, page or url") + } + return &source, nil +} + +func workstreamAgentSourceURL(id string) error { + u, err := url.Parse(id) + if err != nil || u.Host == "" || u.Scheme != "http" && u.Scheme != "https" { + return errors.New("A URL source must be an http(s):// URL.") + } + return nil +} + +func workstreamAgentMember(w workstream.Workstream, factory string, number int) bool { + if factory == "" || number <= 0 { + return false + } + for _, job := range w.Jobs() { + if job.Factory == factory && job.Number == number { + return true + } + } + return false +} + +func (d *workstreamAgentDoor) Plan(ctx context.Context) (session.WorkstreamPlanState, error) { + s, w, err := d.workstreamOwner(ctx, false) + if err != nil { + return session.WorkstreamPlanState{}, err + } + p, err := s.Plan(ctx, w.ID) + return workstreamAgentPlan(p), err +} + +func workstreamAgentPlan(p workstream.Plan) session.WorkstreamPlanState { + return session.WorkstreamPlanState{Version: p.Version, Milestones: p.Milestones, Waves: p.Waves} +} + +func (d *workstreamAgentDoor) SetPlan(ctx context.Context, base int, p session.WorkstreamPlanState) (session.WorkstreamPlanState, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return session.WorkstreamPlanState{}, err + } + saved, change, err := s.SetPlan(ctx, w.ID, base, p.Milestones, p.Waves, workstream.Attribution{By: "agent"}) + d.backend.workstreamChanged(change) + return workstreamAgentPlan(saved), workstreamAgentError(err) +} + +func (d *workstreamAgentDoor) workstreamGoAheadText(ctx context.Context, s *workstream.Store, w workstream.Workstream, ask session.WorkstreamGoAheadAsk) (string, error) { + if err := session.WorkstreamValidateGoAhead(ask, d.backend.now()); err != nil { + return "", err + } + scope, err := workstreamAgentGoAheadScope(ctx, s, w, ask) + if err != nil { + return "", err + } + return fmt.Sprintf("%s%s, up to $%.2f, until %s. %s", session.WorkstreamGoAheadProposalPrefix, scope, ask.CapUSD, ask.EndsOn, ask.Why), nil +} + +func workstreamAgentGoAheadScope(ctx context.Context, s *workstream.Store, w workstream.Workstream, ask session.WorkstreamGoAheadAsk) (string, error) { + if ask.Scope == "job" { + if !workstreamAgentMember(w, ask.Factory, ask.Job) { + return "", errors.New("The proposed go-ahead job must be a member of this workstream.") + } + return fmt.Sprintf("job %s#%d", ask.Factory, ask.Job), nil + } + p, err := s.Plan(ctx, w.ID) + if err != nil { + return "", err + } + if ask.Scope == "wave" { + for _, wave := range p.Waves { + if wave.N == ask.Wave { + return fmt.Sprintf("wave %d", wave.N), nil + } + } + return "", errors.New("The proposed go-ahead wave must exist in the plan.") + } + for _, milestone := range p.Milestones { + if milestone.ID == ask.MilestoneID { + return fmt.Sprintf("milestone \"%s\" (%s)", milestone.Title, milestone.ID), nil + } + } + return "", errors.New("The proposed go-ahead milestone must exist in the plan.") +} + +func (d *workstreamAgentDoor) WritePage(ctx context.Context, ask session.WorkstreamPageWrite) (session.WorkstreamPageWritten, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return session.WorkstreamPageWritten{}, err + } + source, err := d.workstreamSource(ctx, s, w, ask.Source) + if err != nil { + return session.WorkstreamPageWritten{}, err + } + source.Label = ask.UpdatedFrom + doc := pagedoc.FromMarkdown(ask.Markdown) + if len(doc) > workstream.DocMax { + return session.WorkstreamPageWritten{}, session.ErrWorkstreamTooLarge + } + p, change, err := workstreamAgentWritePage(ctx, s, w.ID, ask, doc, workstream.Attribution{By: "agent", From: source}) + d.backend.workstreamChanged(change) + return session.WorkstreamPageWritten{ID: p.ID, Title: p.Title, Version: p.Version}, workstreamAgentError(err) +} + +func workstreamAgentWritePage(ctx context.Context, s *workstream.Store, id string, ask session.WorkstreamPageWrite, doc json.RawMessage, who workstream.Attribution) (workstream.Page, workstream.Change, error) { + if ask.PageID == "" { + return s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: id, ParentID: ask.ParentID, Title: ask.Title, Owner: "agent", ReadByJobs: false, Doc: doc, Attribution: who}) + } + p, err := workstreamAgentPage(ctx, s, id, ask.PageID, false) + if err != nil { + return workstream.Page{}, workstream.Change{}, err + } + return s.SavePage(ctx, p.ID, p.Version, doc, who) +} + +func workstreamAgentError(err error) error { + var stale *workstream.VersionConflict + switch { + case errors.As(err, &stale): + return session.ErrWorkstreamVersion + case errors.Is(err, workstream.ErrNotAgentPage): + return session.ErrWorkstreamNotAgentPage + case errors.Is(err, workstream.ErrTooLarge): + return session.ErrWorkstreamTooLarge + case errors.Is(err, workstream.ErrDone): + return session.ErrWorkstreamDone + case errors.Is(err, workstream.ErrConflict): + return session.ErrWorkstreamDone + } + return err +} + +func (b *appBackend) workstreamAgentTurnSettled(id string) { + if b.createdBy(id) != workstreamCreatedBy { + return + } + s, err := b.workstreamStore(false) + if err != nil || s == nil { + return + } + w, err := s.ByAgent(context.Background(), id) + if err == nil { + b.workstreamRememberBrief(w) + b.workstreamChanged(workstream.Change{ID: w.ID, Parts: []workstream.ChangedPart{workstream.PartWorkstream}}) + } +} diff --git a/cmd/codeaf/appworkstreams_agent_brief_test.go b/cmd/codeaf/appworkstreams_agent_brief_test.go new file mode 100644 index 0000000000..929411bdbf --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent_brief_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func TestWorkstreamBriefNeverWaitsForTheStore(t *testing.T) { + b := &appBackend{} + door := &workstreamAgentDoor{backend: b, agentID: "unheld"} + b.collectionsMu.Lock() + defer b.collectionsMu.Unlock() + finished := make(chan bool, 1) + guard.Go("app/workstream-brief-test", func() { + brief, ok := door.Brief() + finished <- !ok && brief == (session.WorkstreamBrief{}) + }) + select { + case fallback := <-finished: + if !fallback { + t.Fatal("a missing snapshot must use the prompt fallback") + } + case <-time.After(3 * time.Second): + t.Fatal("the brief waited for the store") + } +} + +func TestWorkstreamBriefNeverWaitsForItsCache(t *testing.T) { + b := &appBackend{} + door := &workstreamAgentDoor{backend: b, agentID: "held"} + b.workstreamRememberBrief(workstream.Workstream{AgentID: "held", Name: "Cached", Why: "A reason"}) + b.workstreams.briefMu.Lock() + defer b.workstreams.briefMu.Unlock() + finished := make(chan bool, 1) + guard.Go("app/workstream-brief-cache-test", func() { + _, ok := door.Brief() + finished <- ok + }) + select { + case ok := <-finished: + if ok { + t.Fatal("a busy snapshot must use the fallback") + } + case <-time.After(3 * time.Second): + t.Fatal("the brief waited for its cache") + } +} + +func TestWorkstreamBriefTracksCreateAndRename(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + created, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Original", Why: workstreamPtr("Explain usage")}) + if err != nil { + t.Fatal(err) + } + door := &workstreamAgentDoor{backend: b, agentID: created.Workstream.AgentID} + workstreamCheckBrief(t, door, "Original", "Explain usage") + _, err = b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: created.Workstream.ID, Patch: appwire.WorkstreamPatch{Name: workstreamPtr("Renamed"), Why: workstreamPtr("Review usage")}}) + if err != nil { + t.Fatal(err) + } + workstreamCheckBrief(t, door, "Renamed", "Review usage") +} + +func TestWorkstreamBriefRefreshesWhereTheAppResolvesItsOwner(t *testing.T) { + cases := []struct { + name string + read func(*appBackend, string, *workstreamAgentDoor) error + }{ + {"get", func(b *appBackend, id string, _ *workstreamAgentDoor) error { + _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: id}) + return err + }}, + {"list", func(b *appBackend, _ string, _ *workstreamAgentDoor) error { + _, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + return err + }}, + {"done", func(b *appBackend, id string, _ *workstreamAgentDoor) error { + _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: id}) + return err + }}, + {"reopen", func(b *appBackend, id string, _ *workstreamAgentDoor) error { + _, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: id}) + return err + }}, + {"door", func(_ *appBackend, _ string, d *workstreamAgentDoor) error { + _, err := d.Plan(context.Background()) + return err + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b, _, w, _ := workstreamAgentFixture(t) + s, err := b.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + _, _, err = s.Update(context.Background(), w.ID, workstream.Patch{Name: workstreamPtr("Refreshed"), Why: workstreamPtr("Fresh reason")}) + if err != nil { + t.Fatal(err) + } + door := &workstreamAgentDoor{backend: b, agentID: w.AgentID} + if err := tc.read(b, w.ID, door); err != nil { + t.Fatal(err) + } + workstreamCheckBrief(t, door, "Refreshed", "Fresh reason") + }) + } +} + +func workstreamCheckBrief(t *testing.T, door *workstreamAgentDoor, name, why string) { + t.Helper() + brief, ok := door.Brief() + if !ok || brief.Name != name || brief.Why != why { + t.Fatalf("brief: %+v %v; want %q / %q", brief, ok, name, why) + } +} diff --git a/cmd/codeaf/appworkstreams_agent_done_test.go b/cmd/codeaf/appworkstreams_agent_done_test.go new file mode 100644 index 0000000000..51512fbe34 --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent_done_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func TestWorkstreamDoneCommittedAfterDoorResolutionRefusesTheWrite(t *testing.T) { + for _, operation := range []string{"record", "create", "save"} { + t.Run(operation, func(t *testing.T) { + b, rec, _, door := workstreamAgentFixture(t) + ctx := context.Background() + s, w, err := door.(*workstreamAgentDoor).workstreamOwner(ctx, true) + if err != nil { + t.Fatal(err) + } + p, _, err := s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: w.ID, Title: "Existing", Owner: "agent"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MarkDone(ctx, w.ID, "Finished after resolution"); err != nil { + t.Fatal(err) + } + before, err := s.Get(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + events := len(workstreamEvents(rec)) + change, err := workstreamWriteAfterResolution(ctx, s, w.ID, p.ID, operation) + b.workstreamChanged(change) + if !errors.Is(workstreamAgentError(err), session.ErrWorkstreamDone) || len(workstreamEvents(rec)) != events { + t.Fatalf("done after resolution: %+v %v", change, err) + } + after, err := s.Get(ctx, w.ID) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("refused write changed the workstream: %+v %v", after, err) + } + }) + } +} + +func workstreamWriteAfterResolution(ctx context.Context, s *workstream.Store, id, pageID, operation string) (workstream.Change, error) { + if operation == "record" { + _, c, err := s.ProposeRecord(ctx, workstream.RecordPropose{WorkstreamID: id, Kind: "decision", Text: "Late", By: "agent"}) + return c, err + } + ask := session.WorkstreamPageWrite{Title: "Late"} + if operation == "save" { + ask.PageID = pageID + } + _, c, err := workstreamAgentWritePage(ctx, s, id, ask, json.RawMessage(`{"type":"doc","content":[]}`), workstream.Attribution{By: "agent"}) + return c, err +} diff --git a/cmd/codeaf/appworkstreams_agent_test.go b/cmd/codeaf/appworkstreams_agent_test.go new file mode 100644 index 0000000000..4f0f1393cb --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent_test.go @@ -0,0 +1,329 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func workstreamAgentFixture(t *testing.T) (*appBackend, *appRecorder, appwire.Workstream, session.WorkstreamDoor) { + t.Helper() + b, rec, _, _ := workstreamBackend(t) + b.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) } + w := workstreamCreateForTest(t, b, "Agent", workstreamJobRef("7")) + if _, err := b.keepCreatedBy(w.AgentID, workstreamCreatedBy); err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), w.AgentID, "transcript.jsonl") + return b, rec, w, b.workstreamAgentDoor(file) +} + +// Contract (a) binds the capability to its own conversation, even before creation. +func TestWorkstreamAgentDoorIsScopedAndResolvesLazily(t *testing.T) { + b, _, _, person := workstreamBackend(t) + file := filepath.Join(t.TempDir(), "workstream-agent-1", "transcript.jsonl") + if b.workstreamAgentDoor(file) != nil || b.workstreamAgentDoor(filepath.Join(t.TempDir(), person.ID, "transcript.jsonl")) != nil { + t.Fatal("a person's conversation has a workstream door") + } + if _, err := b.keepCreatedBy("workstream-agent-1", workstreamCreatedBy); err != nil { + t.Fatal(err) + } + door := b.workstreamAgentDoor(file) + if door == nil { + t.Fatal("a workstream conversation needs a door before the row exists") + } + ctx := context.Background() + if brief, ok := door.Brief(); ok || brief != (session.WorkstreamBrief{}) { + t.Fatalf("early brief: %+v %v", brief, ok) + } + _, readErr := door.Read(ctx, session.WorkstreamReadAsk{What: "workstream"}) + _, proposeErr := door.Propose(ctx, session.WorkstreamProposal{Kind: "decision", Text: "Words"}) + _, planErr := door.Plan(ctx) + _, setErr := door.SetPlan(ctx, 0, session.WorkstreamPlanState{}) + _, pageErr := door.WritePage(ctx, session.WorkstreamPageWrite{Title: "Notes", UpdatedFrom: "Discussion"}) + for _, err := range []error{readErr, proposeErr, planErr, setErr, pageErr} { + if !errors.Is(err, session.ErrWorkstreamMissing) || err.Error() != "No workstream holds this conversation." { + t.Fatalf("early tool: %v", err) + } + } + w := workstreamCreateForTest(t, b, "Lazy") + brief, ok := door.Brief() + if !ok || brief.Name != w.Name { + t.Fatalf("late resolution: %+v %v", brief, ok) + } + previous := appWorkstreamAgents.Swap(nil) + t.Cleanup(func() { appWorkstreamAgents.Store(previous) }) + if appWorkstreamAgentDoor(file) != nil { + t.Fatal("an engine outside the app has no backend") + } + appWorkstreamAgents.Store(b) + if appWorkstreamAgentDoor(file) == nil { + t.Fatal("the app engine must receive the door") + } +} + +// Contract (b) records proposals by the agent and never creates grants. +func TestWorkstreamAgentRecordsStayProposed(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + for _, kind := range []string{"decision", "question"} { + if _, err := door.Propose(ctx, session.WorkstreamProposal{Kind: kind, Text: "Keep this?"}); err != nil { + t.Fatal(err) + } + } + plan := session.WorkstreamPlanState{Milestones: []workstream.Milestone{{ID: "m1", Title: "Ship"}}, Waves: []workstream.Wave{{N: 2, Items: []workstream.PlanItem{}}}} + if _, err := door.SetPlan(ctx, 0, plan); err != nil { + t.Fatal(err) + } + cases := []struct { + ask session.WorkstreamGoAheadAsk + text string + }{ + {session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 12.5, EndsOn: "2026-11-04", Why: "Review"}, "Proposed go-ahead (not granted): job f#7, up to $12.50, until 2026-11-04. Review"}, + {session.WorkstreamGoAheadAsk{Scope: "wave", Wave: 2, CapUSD: 20, EndsOn: "2026-11-04", Why: "Read"}, "Proposed go-ahead (not granted): wave 2, up to $20.00, until 2026-11-04. Read"}, + {session.WorkstreamGoAheadAsk{Scope: "milestone", MilestoneID: "m1", CapUSD: 30, EndsOn: "2026-11-04", Why: "Ship"}, "Proposed go-ahead (not granted): milestone \"Ship\" (m1), up to $30.00, until 2026-11-04. Ship"}, + } + s, _ := b.workstreamStore(false) + for _, tc := range cases { + p, err := door.Propose(ctx, session.WorkstreamProposal{Kind: "question", Text: "untrusted", GoAhead: &tc.ask}) + if err != nil { + t.Fatal(err) + } + rows, err := s.Records(ctx, w.ID, "decision", "proposed") + if err != nil || rows[0].ID != p.ID || rows[0].Text != tc.text { + t.Fatalf("go-ahead text: %+v %v", rows, err) + } + } + rows, err := s.Records(ctx, w.ID, "", "") + if err != nil || len(rows) != 5 { + t.Fatalf("records: %+v %v", rows, err) + } + for _, row := range rows { + if row.State != "proposed" || row.By != "agent" || row.Source == nil || row.Source.Kind != "chat" || row.Source.ID != w.AgentID || !row.CreatedAt.Equal(b.now()) { + t.Fatalf("proposal attribution: %+v", row) + } + } + grants, err := s.GoAheads(ctx, w.ID, true) + if err != nil || len(grants) != 0 { + t.Fatalf("a proposal granted permission: %+v %v", grants, err) + } + if len(workstreamEvents(rec)) != 7 { + t.Fatalf("each committed write needs one event: %+v", workstreamEvents(rec)) + } +} + +// Contract (c) leaves membership, stage and Factory seams alone when saving drafts. +func TestWorkstreamAgentDraftPlanNeverTouchesFactory(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + t.Fatal("plan write called the Factory queue") + return api.Queue{}, nil + } + b.workstreams.job = func(context.Context, string, int) (api.Job, error) { + t.Fatal("plan write called a Factory job") + return api.Job{}, nil + } + s, _ := b.workstreamStore(false) + before, _ := s.Get(ctx, w.ID) + p, err := door.Plan(ctx) + if err != nil { + t.Fatal(err) + } + p.Waves = []workstream.Wave{{N: 1, AutoStart: false, Items: []workstream.PlanItem{{Kind: "draft", DraftID: "draft_fixed", Title: "Proposed"}}}} + saved, err := door.SetPlan(ctx, p.Version, p) + if err != nil || saved.Waves[0].Items[0].Kind != "draft" { + t.Fatalf("draft plan: %+v %v", saved, err) + } + after, _ := s.Get(ctx, w.ID) + if !reflect.DeepEqual(before.Members, after.Members) || after.Stage != "shaping" { + t.Fatalf("drafts changed membership or stage: %+v", after) + } + if _, err := door.SetPlan(ctx, 0, p); !errors.Is(err, session.ErrWorkstreamVersion) { + t.Fatalf("typed conflict: %v", err) + } +} + +// Contracts (d) and (e) cover receipts, attribution, history and refusal without events. +func TestWorkstreamAgentPagesAreOwnedAndMarked(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + s, _ := b.workstreamStore(false) + nodes, _ := s.Pages(ctx, w.ID) + before, _ := s.Page(ctx, nodes[0].ID, false) + history, _ := s.PageHistory(ctx, before.ID, 50) + events := len(workstreamEvents(rec)) + _, err := door.WritePage(ctx, session.WorkstreamPageWrite{PageID: before.ID, Markdown: "Forbidden", UpdatedFrom: "Discussion"}) + after, _ := s.Page(ctx, before.ID, false) + afterHistory, _ := s.PageHistory(ctx, before.ID, 50) + if !errors.Is(err, session.ErrWorkstreamNotAgentPage) || !reflect.DeepEqual(before, after) || !reflect.DeepEqual(history, afterHistory) || len(workstreamEvents(rec)) != events { + t.Fatalf("person page refusal: %v", err) + } + ask := session.WorkstreamPageWrite{Title: "Notes", ParentID: before.ID, Markdown: "# Notes\n\n[live: jobs]\n", UpdatedFrom: "Discussion"} + created, err := door.WritePage(ctx, ask) + if err != nil || created.Version != 1 { + t.Fatalf("create: %+v %v", created, err) + } + p, _ := s.Page(ctx, created.ID, true) + if p.Owner != "agent" || p.ReadByJobs || p.ParentID != before.ID || p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(b.now()) || p.UpdatedFrom.Label != "Discussion" || p.UpdatedFrom.ID != w.AgentID || *p.Markdown != ask.Markdown { + t.Fatalf("marked page: %+v", p) + } + b.now = func() time.Time { return time.Date(2026, 11, 3, 13, 0, 0, 0, time.UTC) } + ask = session.WorkstreamPageWrite{PageID: p.ID, Markdown: "Updated", UpdatedFrom: "Review", Source: &workstream.Source{Kind: "factory_job", Factory: "f", ID: "7", Label: "Replaced"}} + updated, err := door.WritePage(ctx, ask) + if err != nil || updated.Version != 2 { + t.Fatalf("update: %+v %v", updated, err) + } + p, _ = s.Page(ctx, p.ID, false) + history, _ = s.PageHistory(ctx, p.ID, 50) + if p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(b.now()) || p.UpdatedFrom.Label != "Review" || p.UpdatedFrom.Kind != "factory_job" || history[0].SavedBy != "agent" || !history[0].SavedAt.Equal(b.now()) || !reflect.DeepEqual(p.UpdatedFrom, history[0].From) || len(workstreamEvents(rec)) != events+2 { + t.Fatalf("updated attribution: %+v %+v", p, history) + } + if wireCode(appWorkstreamError(workstream.ErrNotAgentPage)) != appwire.CodeBadParams { + t.Fatal("the wire needs a bad_params ownership refusal") + } +} + +// Contract (a) scopes reads to the owner while preserving the app's wire shapes. +func TestWorkstreamAgentReadUsesWireShapesAndScopesPages(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + s, _ := b.workstreamStore(false) + nodes, _ := s.Pages(ctx, w.ID) + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: nodes[0].ID}) + var page map[string]json.RawMessage + if err != nil || json.Unmarshal(raw, &page) != nil || page["doc"] != nil || string(page["markdown"]) != `""` || page["id"] == nil { + t.Fatalf("page read: %s %v", raw, err) + } + other := workstreamCreateForTest(t, b, "Other") + otherPages, _ := s.Pages(ctx, other.ID) + if _, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: otherPages[0].ID}); !errors.Is(err, workstream.ErrNotFound) { + t.Fatalf("cross-workstream read: %v", err) + } + got, err := door.Read(ctx, session.WorkstreamReadAsk{What: "workstream"}) + result, _ := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + want, _ := json.Marshal(result.Workstream) + if err != nil || string(got) != string(want) { + t.Fatalf("workstream wire: %s %s %v", got, want, err) + } + for _, part := range []string{"pages", "records", "plan", "goaheads"} { + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: part}) + var object map[string]json.RawMessage + if err != nil || json.Unmarshal(raw, &object) != nil || object[part] == nil { + t.Fatalf("%s wire: %s %v", part, raw, err) + } + } +} + +// Contracts (b), (d) and (g) refuse invalid sources and writes to a done workstream. +func TestWorkstreamAgentInvalidSourcesAndDoneWritesChangeNothing(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + other := workstreamCreateForTest(t, b, "Other") + s, _ := b.workstreamStore(false) + pages, _ := s.Pages(ctx, other.ID) + events := len(workstreamEvents(rec)) + for _, source := range []*workstream.Source{ + {Kind: "factory_job", Factory: "f", ID: "8"}, + {Kind: "factory_job", ID: "7"}, + {Kind: "factory_job", Factory: "f", ID: "07"}, + {Kind: "page", ID: pages[0].ID}, + {Kind: "url", ID: "file:///private"}, + {Kind: "url", ID: "https://"}, + {Kind: "chat", ID: "another-chat"}, + } { + if _, err := door.Propose(ctx, session.WorkstreamProposal{Kind: "decision", Text: "Words", Source: source}); err == nil { + t.Fatalf("accepted source %+v", source) + } + if _, err := door.WritePage(ctx, session.WorkstreamPageWrite{Title: "Notes", Markdown: "Words", UpdatedFrom: "Discussion", Source: source}); err == nil { + t.Fatalf("accepted page source %+v", source) + } + } + if len(workstreamEvents(rec)) != events { + t.Fatal("a refusal emitted a change") + } + if _, _, err := s.MarkDone(ctx, w.ID, "Done"); err != nil { + t.Fatal(err) + } + _, proposalErr := door.Propose(ctx, session.WorkstreamProposal{Kind: "decision", Text: "Words"}) + _, planErr := door.SetPlan(ctx, 0, session.WorkstreamPlanState{}) + _, pageErr := door.WritePage(ctx, session.WorkstreamPageWrite{Title: "Notes", Markdown: "Words", UpdatedFrom: "Discussion"}) + for _, err := range []error{proposalErr, planErr, pageErr} { + if !errors.Is(err, session.ErrWorkstreamDone) { + t.Fatalf("done write: %v", err) + } + } + if len(workstreamEvents(rec)) != events { + t.Fatal("a done write emitted a change") + } +} + +// Spend freshness in section 5 announces only the owning workstream. +func TestWorkstreamAgentSettledTurnRefreshesOnlyItsWorkstream(t *testing.T) { + b, rec, w, _ := workstreamAgentFixture(t) + events := len(workstreamEvents(rec)) + b.workstreamAgentTurnSettled("person") + if _, err := b.keepCreatedBy("unheld", workstreamCreatedBy); err != nil { + t.Fatal(err) + } + b.workstreamAgentTurnSettled("unheld") + if len(workstreamEvents(rec)) != events { + t.Fatal("an unheld conversation emitted a workstream change") + } + b.workstreamAgentTurnSettled(w.AgentID) + all := workstreamEvents(rec) + if len(all) != events+1 || all[len(all)-1].ID != w.ID || !reflect.DeepEqual(all[len(all)-1].Parts, []string{"workstream"}) { + t.Fatalf("turn settlement: %+v", all) + } +} + +// Contract (g) bounds encoded editor JSON and emits nothing after refusal. +func TestWorkstreamAgentRejectsOversizeEditorDocument(t *testing.T) { + _, rec, _, door := workstreamAgentFixture(t) + events := len(workstreamEvents(rec)) + _, err := door.WritePage(context.Background(), session.WorkstreamPageWrite{Title: "Big", Markdown: strings.Repeat("x", workstream.DocMax), UpdatedFrom: "Discussion"}) + if !errors.Is(err, session.ErrWorkstreamTooLarge) || len(workstreamEvents(rec)) != events { + t.Fatalf("oversize write: %v", err) + } +} + +// Contracts (b) and (g) validate membership, plan targets, scopes, cap and date. +func TestWorkstreamAgentGoAheadTargetsAreValidatedWithoutGranting(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + events := len(workstreamEvents(rec)) + base := session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 1, EndsOn: "2026-11-04", Why: "Review"} + for _, mutate := range []func(*session.WorkstreamGoAheadAsk){ + func(a *session.WorkstreamGoAheadAsk) { a.Job = 8 }, + func(a *session.WorkstreamGoAheadAsk) { a.Factory = "another" }, + func(a *session.WorkstreamGoAheadAsk) { a.Scope, a.Factory, a.Job, a.Wave = "wave", "", 0, 9 }, + func(a *session.WorkstreamGoAheadAsk) { + a.Scope, a.Factory, a.Job, a.MilestoneID = "milestone", "", 0, "missing" + }, + func(a *session.WorkstreamGoAheadAsk) { a.CapUSD = 0 }, + func(a *session.WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-03" }, + func(a *session.WorkstreamGoAheadAsk) { a.Wave = 1 }, + } { + ask := base + mutate(&ask) + if _, err := door.Propose(ctx, session.WorkstreamProposal{GoAhead: &ask}); err == nil { + t.Fatalf("accepted %+v", ask) + } + } + s, _ := b.workstreamStore(false) + records, _ := s.Records(ctx, w.ID, "", "") + grants, _ := s.GoAheads(ctx, w.ID, true) + if len(records) != 0 || len(grants) != 0 || len(workstreamEvents(rec)) != events { + t.Fatal("a refused proposal changed records, grants or events") + } +} diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go index b022e5e27f..e3cb621e43 100644 --- a/cmd/codeaf/appworkstreams_conformance_test.go +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -649,9 +649,19 @@ func (fixture *workstreamWireFixture) saveByAgent(t *testing.T) { if err != nil || store == nil { t.Fatalf("seed page store: %v", err) } - _, _, err = store.SavePage(context.Background(), fixture.IDs["pg_31d0"], 1, - json.RawMessage(`{"type":"doc","content":[]}`), workstream.Attribution{By: "agent", From: workstreamSource(fixture.source())}) - if err != nil { + // The agent writes only its own pages, so the seed hands it the Overview + // for the one save the contract's examples show and then hands it back, + // still attributed to the agent, without a new version. + ctx, id := context.Background(), fixture.IDs["pg_31d0"] + agent := workstream.Attribution{By: "agent", From: workstreamSource(fixture.source())} + lent, kept := "agent", "you" + if _, _, err := store.UpdatePage(ctx, id, workstream.PagePatch{Owner: &lent, Attribution: agent}); err != nil { + t.Fatal(err) + } + if _, _, err := store.SavePage(ctx, id, 1, json.RawMessage(`{"type":"doc","content":[]}`), agent); err != nil { + t.Fatal(err) + } + if _, _, err := store.UpdatePage(ctx, id, workstream.PagePatch{Owner: &kept, Attribution: agent}); err != nil { t.Fatal(err) } } diff --git a/cmd/codeaf/appworkstreams_pages_test.go b/cmd/codeaf/appworkstreams_pages_test.go index 6d89ba1d6f..1a521d06f6 100644 --- a/cmd/codeaf/appworkstreams_pages_test.go +++ b/cmd/codeaf/appworkstreams_pages_test.go @@ -55,7 +55,7 @@ func TestAppWorkstreamPageGetVersion(t *testing.T) { t.Fatalf("store: %v", err) } doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Earlier"}]}]}`) - first, _, err := store.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Before", Doc: doc, + first, _, err := store.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Before", Owner: "agent", Doc: doc, Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: "old-source"}}}) if err != nil { t.Fatal(err) diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 5e126b436c..b4865d8725 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1152,6 +1152,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // `factory_floor`, now that the hello has settled which conversation this // is: on the foreman's belt and no other (factory_foreman.go). cfg.Floor = foremanFloorDoor(floorStore, workspace, cfg.SessionFile) + cfg.Workstream = appWorkstreamAgentDoor(cfg.SessionFile) // THE THREE ROAD-DEPENDENT CAPABILITIES ARE DECIDED IN ONE PLACE, and this // door no longer keeps its own answer to any of them (chatv3_lanes.go). Each diff --git a/docs/changes/unreleased/2289-app-workstream-agent-tools.md b/docs/changes/unreleased/2289-app-workstream-agent-tools.md new file mode 100644 index 0000000000..365cd05003 --- /dev/null +++ b/docs/changes/unreleased/2289-app-workstream-agent-tools.md @@ -0,0 +1,25 @@ +--- +kind: added +title: Workstream agents propose records and draft jobs and write their own pages +pr: 2289 +surface: [app, chat] +invalidates: + - A workstream's agent previously had no workstream tools or prompt; it now receives both in the app. +--- + +Each workstream's own agent gets six tools and a short workstream prompt. +Decisions and questions remain proposals; job proposals append plan drafts +without starting jobs. Go-ahead proposals are decision records and grant +nothing. Page writes enforce agent ownership in the store transaction and +preserve the agent mark, source and time in page history. Reads use recorded +facts, and settled agent turns notify the app to refresh workstream spend. +Ordinary conversations and child workers receive no workstream capability. + +Prompt briefs use a non-blocking cached snapshot and fall back when unavailable. +Agent record and page writes check done inside the write transaction, including +when the person marks done after the agent's read. Markdown list items always +start with a paragraph, and malformed inline delimiters take linear work. +Tool arguments refuse repeated keys at every object depth before calling a tool. +Workstream tools use the shared argument decoder and mark input refusals before +running. Whole-valued decimal job and wave numbers are accepted; proposed caps +retain fractional dollars. A plan version conflict gets one fresh read and save. diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 825d277aff..ae3ef989f3 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -169,3 +169,35 @@ 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. + +## What a workstream's agent can do: proposals and its own pages + +A workstream's own agent in the app has six tools. Other conversations, tasks +and errands do not receive them. `workstream_read` reads the workstream, pages, +decisions, questions, plan and go-aheads. Counts, costs, states and dates come +from records. An absent number is unknown; the agent must say so and never +estimate it. Page live blocks such as `[live: jobs]` show current facts. + +`propose_decision` and `propose_question` create proposals by the agent. The +agent proposes; the person keeps. `propose_jobs` adds 1–20 draft jobs to the +plan, leaving existing waves and settings intact. Drafts create no Factory +jobs and start nothing; the person launches them. A plan conflict is retried +once against a fresh read, then refused if it changes again. + +`propose_goahead` makes a proposed decision beginning `Proposed go-ahead (not +granted): `. Its scope, cap and end date are proposals. It grants nothing; the +person chooses the real permission. The proposed cap is greater than zero and +at most $10,000, including fractional dollars such as $12.50; its end date is +after today in UTC and within 90 days. + +`write_agent_page` creates or updates only the agent's own pages. Every write +carries an AI mark, its written-at time and a named source; history keeps that +attribution. New pages have Read by jobs off. The agent refuses the person's +pages with “That page is the person's. You write only your own pages; +suggesting edits on theirs is not available yet.” Page documents are limited +to 1 MiB. A page that changed must be read again before writing. Done +workstreams refuse all these writes. + +Tool arguments refuse unknown fields and repeated object keys, including inside +sources and draft jobs, before reading or writing the workstream. The agent keeps +its page titled “Where it stands” current; the Overview shows it with the AI mark. diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index d7a56971a3..525d82fa57 100644 --- a/internal/session/actioncategory.go +++ b/internal/session/actioncategory.go @@ -224,7 +224,7 @@ func ActionCategoryForTool(tool string) ActionCategory { // A manager looking at its team: the states, and a member's page. "team_status", "team_read", // The foreman reading the factory floor (a mark is a proposal, not a change). - "factory_floor", "factory_status": + "factory_floor", "factory_status", "workstream_read": return ActionRead // Changing something that exists. @@ -234,7 +234,8 @@ func ActionCategoryForTool(tool string) ActionCategory { // Writing content or generating an artifact. case "write", "generate_image", "generate_music", "generate_video", - "remember", "propose_subharness", "factory_add", "factory_recipe", "factory_item", "build_harness", "calendar_create": + "remember", "propose_subharness", "factory_add", "factory_recipe", "factory_item", "build_harness", "calendar_create", + "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page": return ActionCreate // Executing and waiting on what it prints. diff --git a/internal/session/inherit.go b/internal/session/inherit.go index 3113ed686e..b4fa6f16ca 100644 --- a/internal/session/inherit.go +++ b/internal/session/inherit.go @@ -386,7 +386,7 @@ func adoptSeed(child *Agent, quick *quickTaskSpec) { // prompt over the top of it an hour later — the same mark a Config that // carries a System gets at construction (agent.go), said here because this // page arrives after the agent was built. - child.system = promotedSystem(quick.system) + child.system = promotedSystem(workstreamWithoutPage(quick.system)) child.systemOwn = false messages[0] = textMessage("system", child.system) child.messages = messages diff --git a/internal/session/manual_test.go b/internal/session/manual_test.go index 933e98ff90..0c886e956f 100644 --- a/internal/session/manual_test.go +++ b/internal/session/manual_test.go @@ -132,6 +132,24 @@ func TestTheManualMentionsTheDesignThreadsOwnTool(t *testing.T) { } } +// Contract (h) checks the offered belt, including its six conditional tools. +func TestTheManualMentionsTheWorkstreamAgentsTools(t *testing.T) { + agent := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}}} + agent.tools = agent.belt() + names := map[string]bool{} + for _, tool := range agent.offeredTools() { + if !manual.Chat().Mentions(tool.Name) { + t.Errorf("the chat manual does not mention %s", tool.Name) + } + names[tool.Name] = true + } + for _, name := range []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page"} { + if !names[name] { + t.Errorf("the workstream agent does not offer %s", name) + } + } +} + // The manual tool answers out of the CHAT's pages and must never reach the // resident's. Both corpora ship in this binary, and a chat that answered from // the wrong one would describe a product the person is not using — fluently, diff --git a/internal/session/prompt.go b/internal/session/prompt.go index 582229d166..ef40bedc69 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -399,6 +399,7 @@ func renderSystemAt(config Config, now time.Time) string { out.WriteString(catalog) } + out.WriteString(workstreamSection(config)) out.WriteString(workerFooter(config, now)) return out.String() } diff --git a/internal/session/questionconversation.go b/internal/session/questionconversation.go index 613d5336df..a5aa7305d6 100644 --- a/internal/session/questionconversation.go +++ b/internal/session/questionconversation.go @@ -96,6 +96,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { } a.mu.Lock() cfg := a.config + cfg.Workstream = nil cfg.Model = a.model cfg.SessionFile, cfg.Place = "", Place{} cfg.rootSession = a.id @@ -107,7 +108,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { if cfg.ApprovalPolicy == nil { cfg.ApprovalPolicy = a.config.ApprovalPolicy } - cfg.System = a.system + cfg.System = workstreamWithoutPage(a.system) if a.guardianOverride != nil { cfg.Guardian = *a.guardianOverride } diff --git a/internal/session/session.go b/internal/session/session.go index 196a892bab..d986e424f8 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2523,6 +2523,9 @@ type Config struct { // foremanFloorDoor), on a launch that has a Factory door. Floor FloorDoor + // Workstream is set by the app only on a workstream's own agent. Nil is no door. + Workstream WorkstreamDoor + // factoryWindow overrides how long one factory card holds its tool call // open, for tests that watch the window end. Zero is [factoryCardWindow]. factoryWindow time.Duration diff --git a/internal/session/toolargs_test.go b/internal/session/toolargs_test.go index 3a95f60aa6..0cae4045f1 100644 --- a/internal/session/toolargs_test.go +++ b/internal/session/toolargs_test.go @@ -489,6 +489,8 @@ func TestNoWholeNumberArgumentIsDeclaredANumber(t *testing.T) { // whole-number-shaped anywhere else is still caught. fractionalIn := map[string]map[string]bool{ "tools_factory_item.go": {"cap": true}, + // A GO-AHEAD'S proposed cap is dollars, a float64 where twelve fifty is ordinary. + "workstream_schema.go": {"cap_usd": true}, } for _, path := range toolArgumentSources(t) { body, err := os.ReadFile(path) diff --git a/internal/session/tools.go b/internal/session/tools.go index dd5d1177b6..e6820913ab 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -273,6 +273,7 @@ func (a *Agent) belt() []bare.Tool { // factory_floor (tools_floor.go) is the foreman's read of the whole floor // and its marks, absent on the same law: no floor behind it, no verb. tools = append(tools, a.floorTools()...) + tools = append(tools, a.workstreamTools()...) tools = append(tools, a.harnessTools()...) // The saved PROGRAMS, and the list that says which ones there are // (tools_subharness.go). They are conditional on the same terms the three diff --git a/internal/session/workstream_contract.go b/internal/session/workstream_contract.go new file mode 100644 index 0000000000..a7d5d61a73 --- /dev/null +++ b/internal/session/workstream_contract.go @@ -0,0 +1,91 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// WorkstreamDoor is the workstream agent's one way into its own workstream. +type WorkstreamDoor interface { + // Brief returns a non-blocking snapshot, or false when none is ready. + Brief() (WorkstreamBrief, bool) + Read(context.Context, WorkstreamReadAsk) (json.RawMessage, error) + Propose(context.Context, WorkstreamProposal) (WorkstreamProposed, error) + Plan(context.Context) (WorkstreamPlanState, error) + SetPlan(context.Context, int, WorkstreamPlanState) (WorkstreamPlanState, error) + WritePage(context.Context, WorkstreamPageWrite) (WorkstreamPageWritten, error) +} + +var ErrWorkstreamMissing = errors.New("No workstream holds this conversation.") + +var ErrWorkstreamDone = errors.New("This workstream is done; nothing was added or written.") + +var ErrWorkstreamVersion = errors.New("the workstream version changed") + +var ErrWorkstreamNotAgentPage = errors.New("That page is the person's. You write only your own pages; suggesting edits on theirs is not available yet.") + +var ErrWorkstreamTooLarge = errors.New("That page is too large; the encoded document must be at most 1 MiB.") + +const WorkstreamGoAheadProposalPrefix = "Proposed go-ahead (not granted): " + +// WorkstreamWhereItStandsTitle is the agent page the app shows on the Overview as +// the agent's summary, so the agent keeps the page with exactly this title current. +const WorkstreamWhereItStandsTitle = "Where it stands" + +type WorkstreamBrief struct { + Name string + Why string +} + +type WorkstreamReadAsk struct { + What string `json:"what"` + PageID string `json:"page_id,omitempty"` + Kind string `json:"kind,omitempty"` + State string `json:"state,omitempty"` +} + +type WorkstreamProposal struct { + Kind string + Text string + Source *workstream.Source + GoAhead *WorkstreamGoAheadAsk +} + +type WorkstreamProposed struct { + ID string +} + +type WorkstreamPlanState struct { + Version int + Milestones []workstream.Milestone + Waves []workstream.Wave +} + +type WorkstreamGoAheadAsk struct { + Scope string `json:"scope"` + Factory string `json:"factory,omitempty"` + Job int `json:"job,omitempty"` + Wave int `json:"wave,omitempty"` + MilestoneID string `json:"milestone_id,omitempty"` + CapUSD float64 `json:"cap_usd"` + EndsOn string `json:"ends_on"` + Why string `json:"why"` +} + +type WorkstreamPageWrite struct { + PageID string `json:"page_id,omitempty"` + Title string `json:"title,omitempty"` + ParentID string `json:"parent_id,omitempty"` + Markdown string `json:"markdown"` + UpdatedFrom string `json:"updated_from"` + Source *workstream.Source `json:"source,omitempty"` +} + +type WorkstreamPageWritten struct { + ID string + Title string + Version int +} diff --git a/internal/session/workstream_factory_test.go b/internal/session/workstream_factory_test.go new file mode 100644 index 0000000000..4af91f6511 --- /dev/null +++ b/internal/session/workstream_factory_test.go @@ -0,0 +1,108 @@ +package session + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +type workstreamForbiddenFactory struct { + t *testing.T +} + +func (d workstreamForbiddenFactory) Add(context.Context, factory.Item) (int, error) { + d.t.Fatal("propose_jobs called Factory.Add") + return 0, nil +} + +func (d workstreamForbiddenFactory) Preview(context.Context, int, ItemChange) (factory.Item, factory.Item, error) { + d.t.Fatal("propose_jobs called FactoryItem.Preview") + return factory.Item{}, factory.Item{}, nil +} + +func (d workstreamForbiddenFactory) Apply(context.Context, int, ItemChange) (factory.Item, error) { + d.t.Fatal("propose_jobs called FactoryItem.Apply") + return factory.Item{}, nil +} + +func (d workstreamForbiddenFactory) EditRun(context.Context, string, factory.RunEdit) (factory.Item, []string, error) { + d.t.Fatal("propose_jobs called FactoryRun.EditRun") + return factory.Item{}, nil, nil +} + +func (d workstreamForbiddenFactory) StartRun(context.Context, string) (string, error) { + d.t.Fatal("propose_jobs called FactoryRun.StartRun") + return "", nil +} + +func (d workstreamForbiddenFactory) BankStage(context.Context, string, factory.Kind, string) error { + d.t.Fatal("propose_jobs called Recipe.BankStage") + return nil +} + +func (d workstreamForbiddenFactory) BankPolicy(context.Context, string, string) error { + d.t.Fatal("propose_jobs called Recipe.BankPolicy") + return nil +} + +func (d workstreamForbiddenFactory) BankHabit(context.Context, string, string) error { + d.t.Fatal("propose_jobs called Recipe.BankHabit") + return nil +} + +func (d workstreamForbiddenFactory) Snapshot(context.Context) (factory.Snapshot, error) { + d.t.Fatal("propose_jobs called Floor.Snapshot") + return factory.Snapshot{}, nil +} + +func (d workstreamForbiddenFactory) Mark(context.Context, []int, bool) error { + d.t.Fatal("propose_jobs called Floor.Mark") + return nil +} + +func (d workstreamForbiddenFactory) Chat(string) JobChat { + d.t.Fatal("propose_jobs called JobChat.Chat") + return nil +} + +func (d workstreamForbiddenFactory) Spend(float64) { + d.t.Fatal("propose_jobs called JobBudget.Spend") +} + +func (d workstreamForbiddenFactory) Refusal() string { + d.t.Fatal("propose_jobs called JobBudget.Refusal") + return "" +} + +func (d workstreamForbiddenFactory) Report(context.Context, factory.StageResult) error { + d.t.Fatal("propose_jobs called Stage.Report") + return nil +} + +func (d workstreamForbiddenFactory) Edit(context.Context, factory.PlanEdit) error { + d.t.Fatal("propose_jobs called Stage.Edit") + return nil +} + +// Contract (c) puts failing doors behind every Factory capability during the call. +func TestWorkstreamJobsDoNotCallAnyFactoryDoor(t *testing.T) { + door := &workstreamFakeDoor{} + forbidden := workstreamForbiddenFactory{t: t} + a := &Agent{config: Config{Workstream: door, Factory: forbidden, FactoryItem: forbidden, FactoryRun: forbidden, Recipe: forbidden, Floor: forbidden, JobChat: forbidden, JobBudget: forbidden, Stage: forbidden}} + for _, tool := range a.workstreamTools() { + if tool.Name != "propose_jobs" { + continue + } + got, failed, err := tool.Execute(context.Background(), json.RawMessage(`{"jobs":[{"title":"Draft"}]}`)) + if err != nil || failed || got != "Added 1 draft jobs to wave 1 of the plan. They are drafts: nothing runs until the person launches them." { + t.Fatalf("draft proposal: %q %v %v", got, failed, err) + } + if len(door.saves) != 1 || door.saves[0].Waves[0].Items[0].Kind != "draft" { + t.Fatalf("not a draft: %+v", door.saves) + } + return + } + t.Fatal("missing propose_jobs") +} diff --git a/internal/session/workstream_limits.go b/internal/session/workstream_limits.go new file mode 100644 index 0000000000..c6d4564438 --- /dev/null +++ b/internal/session/workstream_limits.go @@ -0,0 +1,15 @@ +package session + +const workstreamLineMax = 200 + +const workstreamRecordMax = 2000 + +const workstreamDraftTextMax = 4000 + +const workstreamJobsMax = 20 + +const workstreamGoAheadCapMax = 10000 + +const workstreamGoAheadDaysMax = 90 + +const workstreamGoAheadWhyMax = 1000 diff --git a/internal/session/workstream_schema.go b/internal/session/workstream_schema.go new file mode 100644 index 0000000000..52051534ed --- /dev/null +++ b/internal/session/workstream_schema.go @@ -0,0 +1,173 @@ +package session + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" + "strings" +) + +var workstreamSourceSchema = fmt.Sprintf(`{"type":"object","properties":{"kind":{"type":"string","enum":["factory_job","page","url"]},"id":{"type":"string"},"factory":{"type":"string"},"label":{"type":"string","maxLength":%d}},"required":["kind","id"],"additionalProperties":false}`, workstreamLineMax) + +const workstreamReadSchema = `{"type":"object","properties":{"what":{"type":"string","enum":["workstream","pages","page","records","plan","goaheads"]},"page_id":{"type":"string"},"kind":{"type":"string","enum":["decision","question"]},"state":{"type":"string","enum":["proposed","kept","dropped","superseded"]}},"required":["what"],"additionalProperties":false}` + +var workstreamRecordSchema = fmt.Sprintf(`{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":%d},"source":%s},"required":["text"],"additionalProperties":false}`, workstreamRecordMax, workstreamSourceSchema) + +var workstreamJobsSchema = fmt.Sprintf(`{"type":"object","properties":{"jobs":{"type":"array","minItems":1,"maxItems":%d,"items":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":%d},"repo":{"type":"string","maxLength":%d},"text":{"type":"string","maxLength":%d}},"required":["title"],"additionalProperties":false}},"wave":{"type":"integer"},"wave_title":{"type":"string","maxLength":%d},"milestone_id":{"type":"string"}},"required":["jobs"],"additionalProperties":false}`, workstreamJobsMax, workstreamLineMax, workstreamLineMax, workstreamDraftTextMax, workstreamLineMax) + +var workstreamGoAheadSchema = fmt.Sprintf(`{"type":"object","properties":{"scope":{"type":"string","enum":["job","wave","milestone"]},"factory":{"type":"string"},"job":{"type":"integer"},"wave":{"type":"integer"},"milestone_id":{"type":"string"},"cap_usd":{"type":"number","exclusiveMinimum":0,"maximum":%d},"ends_on":{"type":"string"},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["scope","cap_usd","ends_on","why"],"additionalProperties":false}`, workstreamGoAheadCapMax, workstreamGoAheadWhyMax) + +var workstreamPageSchema = fmt.Sprintf(`{"type":"object","properties":{"page_id":{"type":"string"},"title":{"type":"string","minLength":1,"maxLength":%d},"parent_id":{"type":"string"},"markdown":{"type":"string"},"updated_from":{"type":"string","minLength":1,"maxLength":%d},"source":%s},"required":["markdown","updated_from"],"additionalProperties":false}`, workstreamLineMax, workstreamLineMax, workstreamSourceSchema) + +type workstreamArgumentSchema struct { + Type string `json:"type"` + Properties map[string]workstreamArgumentSchema `json:"properties"` + Required []string `json:"required"` + Enum []string `json:"enum"` + Items *workstreamArgumentSchema `json:"items"` +} + +func workstreamDecode(args json.RawMessage, schema workstreamArgumentSchema, target any) (map[string]any, error) { + if err := decodeToolArguments(args, target); err != nil { + return nil, err + } + if err := workstreamUniqueKeys(args); err != nil { + return nil, err + } + var value any + if err := decodeToolArguments(args, &value); err != nil { + return nil, err + } + if err := workstreamCheckArguments(value, schema, "arguments"); err != nil { + return nil, err + } + return value.(map[string]any), nil +} + +// Walk the original token stream so repeated fields survive typed decoding. +func workstreamUniqueKeys(args json.RawMessage) error { + decoder := json.NewDecoder(bytes.NewReader(args)) + decoder.UseNumber() + if err := workstreamUniqueValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + return fmt.Errorf("arguments must contain one JSON value") + } + return nil +} + +func workstreamUniqueValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + return workstreamUniqueObject(decoder) + case '[': + for decoder.More() { + if err := workstreamUniqueValue(decoder); err != nil { + return err + } + } + _, err := decoder.Token() + return err + } + return fmt.Errorf("unexpected JSON delimiter %q", delimiter) +} + +func workstreamUniqueObject(decoder *json.Decoder) error { + seen := make(map[string]bool) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return err + } + name, ok := token.(string) + if !ok { + return fmt.Errorf("object keys must be strings") + } + if seen[name] { + return fmt.Errorf("Duplicate field %q is not allowed.", name) + } + seen[name] = true + if err := workstreamUniqueValue(decoder); err != nil { + return err + } + } + _, err := decoder.Token() + return err +} + +func workstreamCheckArguments(value any, schema workstreamArgumentSchema, path string) error { + if value == nil { + return fmt.Errorf("%s must not be null", path) + } + switch schema.Type { + case "object": + return workstreamCheckObject(value, schema, path) + case "array": + items, ok := value.([]any) + if !ok { + return fmt.Errorf("%s must be an array", path) + } + for i, item := range items { + if err := workstreamCheckArguments(item, *schema.Items, fmt.Sprintf("%s[%d]", path, i)); err != nil { + return err + } + } + case "string": + text, ok := value.(string) + if !ok { + return fmt.Errorf("%s must be a string", path) + } + if len(schema.Enum) > 0 && !workstreamHasWord(schema.Enum, text) { + return fmt.Errorf("%s must be one of %s", path, strings.Join(schema.Enum, ", ")) + } + } + return nil +} + +func workstreamCheckObject(value any, schema workstreamArgumentSchema, path string) error { + fields, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s must be a JSON object", path) + } + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if _, ok := schema.Properties[name]; !ok { + return fmt.Errorf("Unknown field %q is not allowed.", name) + } + } + for _, name := range names { + if err := workstreamCheckArguments(fields[name], schema.Properties[name], name); err != nil { + return err + } + } + for _, name := range schema.Required { + if _, ok := fields[name]; !ok { + return fmt.Errorf("%s is required", name) + } + } + return nil +} + +func workstreamHasWord(words []string, word string) bool { + for _, candidate := range words { + if candidate == word { + return true + } + } + return false +} diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go new file mode 100644 index 0000000000..2a7308c3e4 --- /dev/null +++ b/internal/session/workstream_tools.go @@ -0,0 +1,512 @@ +package session + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +const workstreamReadMax = 60000 + +const workstreamPlanChanged = "The plan changed while you were proposing; nothing was added. Read the plan and propose again." + +const workstreamPromptRules = "\n- You propose; the person keeps. Decisions, questions, jobs and go-aheads you raise with propose_decision, propose_question, propose_jobs and propose_goahead are proposals until the person keeps them.\n" + + "- You write only your own pages, with write_agent_page. The person's pages are theirs; you do not change them.\n" + + "- Keep your page titled \"" + WorkstreamWhereItStandsTitle + "\" current with write_agent_page, saying what it was updated from.\n" + + "- You never invent numbers. Counts, costs, states and dates come from workstream_read; on a page, use a live block such as [live: jobs] instead of writing a number.\n" + + "- You start nothing. Jobs start only when the person launches them or inside a go-ahead they granted." + +func (c Config) mayWorkstream() bool { + return c.Workstream != nil && !c.readingTask() && !c.InTask && !c.Errand +} + +func workstreamSection(config Config) string { + if !config.mayWorkstream() { + return "" + } + return "\n\n" + workstreamPage(config) +} + +func workstreamPage(config Config) string { + brief, ok := config.Workstream.Brief() + intro := "You are the agent of a workstream. Read it with workstream_read before you answer." + if ok && strings.TrimSpace(brief.Name) != "" { + intro = fmt.Sprintf("You are the agent of the workstream %q.", workstreamCut(strings.Join(strings.Fields(brief.Name), " "), 48)) + if why := strings.Join(strings.Fields(brief.Why), " "); why != "" { + intro += " Why it exists: " + workstreamCut(why, 64) + } + } + return "WORKSTREAM\n" + intro + workstreamPromptRules +} + +func workstreamWithoutPage(page string) string { + start := strings.Index(page, "\n\nWORKSTREAM\n") + if start < 0 { + return page + } + end := strings.Index(page[start:], workstreamPromptRules) + if end < 0 { + return page + } + return page[:start] + page[start+end+len(workstreamPromptRules):] +} + +func (a *Agent) workstreamTools() []bare.Tool { + return a.workstreamToolsAt(time.Now) +} + +func (a *Agent) workstreamToolsAt(now func() time.Time) []bare.Tool { + if !a.config.mayWorkstream() { + return nil + } + door := a.config.Workstream + return []bare.Tool{ + workstreamTool("workstream_read", "Read this workstream's recorded facts. Absent numbers are unknown; never estimate them.", workstreamReadSchema, func(ctx context.Context, ask WorkstreamReadAsk) (string, error) { + return workstreamRead(ctx, door, ask) + }), + workstreamRecordTool(door, "decision"), + workstreamRecordTool(door, "question"), + workstreamTool("propose_jobs", "Append draft jobs to the plan. Nothing is created in the Factory or started; the person launches drafts.", workstreamJobsSchema, func(ctx context.Context, ask workstreamJobsAsk) (string, error) { + return workstreamProposeJobs(ctx, door, ask) + }), + workstreamTool("propose_goahead", "Propose a go-ahead as a decision. The cap and end date are proposals; this grants nothing.", workstreamGoAheadSchema, func(ctx context.Context, ask WorkstreamGoAheadAsk) (string, error) { + return workstreamProposeGoAhead(ctx, door, ask, now()) + }), + workstreamTool("write_agent_page", "Write only your own pages, marked as agent-written with their source and time. The person's pages are refused. Keep the page titled \""+WorkstreamWhereItStandsTitle+"\" current; the app shows it on the Overview.", workstreamPageSchema, func(ctx context.Context, ask WorkstreamPageWrite) (string, error) { + return workstreamWritePage(ctx, door, ask) + }), + } +} + +func workstreamTool[T any](name, description, schemaJSON string, run func(context.Context, T) (string, error)) bare.Tool { + var schema workstreamArgumentSchema + _ = json.Unmarshal([]byte(schemaJSON), &schema) + return bare.Tool{ + Name: name, + Description: description, + Schema: json.RawMessage(schemaJSON), + Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { + var ask T + fields, err := workstreamDecode(args, schema, &ask) + if err != nil { + return rejectToolArguments(ctx, invalidArgumentsPrefix+err.Error()), true, nil + } + if err := workstreamFieldRules(fields, ask); err != nil { + return rejectToolArguments(ctx, invalidArgumentsPrefix+err.Error()), true, nil + } + text, err := run(ctx, ask) + if err != nil { + var argument *toolArgumentError + if errors.As(err, &argument) { + return rejectToolArguments(ctx, err.Error()), true, nil + } + return err.Error(), true, nil + } + return text, false, nil + }, + } +} + +func workstreamFieldRules(fields map[string]any, ask any) error { + if err := workstreamReferenceFields(fields, ask); err != nil { + return err + } + _, page := fields["page_id"] + switch ask := ask.(type) { + case WorkstreamReadAsk: + if page != (ask.What == "page") { + return errors.New("page_id is required for and only for page") + } + if ask.What != "records" { + return workstreamRefuseFields(fields, "kind", "state") + } + case workstreamJobsAsk: + if ask.Wave != nil { + return workstreamRefuseFields(fields, "wave_title", "milestone_id") + } + case WorkstreamGoAheadAsk: + return workstreamGoAheadFields(fields, ask) + case WorkstreamPageWrite: + if page { + return workstreamRefuseFields(fields, "title", "parent_id") + } + } + return nil +} + +func workstreamReferenceFields(fields map[string]any, ask any) error { + ids := make(map[string]string) + var source *workstream.Source + switch ask := ask.(type) { + case WorkstreamReadAsk: + ids["page_id"] = ask.PageID + case workstreamJobsAsk: + ids["milestone_id"] = ask.MilestoneID + case WorkstreamGoAheadAsk: + ids["milestone_id"] = ask.MilestoneID + case WorkstreamPageWrite: + ids["page_id"] = ask.PageID + ids["parent_id"] = ask.ParentID + source = ask.Source + case workstreamRecordAsk: + source = ask.Source + } + for _, name := range []string{"page_id", "parent_id", "milestone_id"} { + if _, exists := fields[name]; exists && strings.TrimSpace(ids[name]) == "" { + return fmt.Errorf("%s must name an existing record", name) + } + } + if source != nil && source.Kind != "factory_job" { + fields, _ := fields["source"].(map[string]any) + return workstreamRefuseFields(fields, "factory") + } + return nil +} + +func workstreamRefuseFields(fields map[string]any, names ...string) error { + for _, name := range names { + if _, exists := fields[name]; exists { + return fmt.Errorf("%s is not allowed for this request", name) + } + } + return nil +} + +func workstreamGoAheadFields(fields map[string]any, ask WorkstreamGoAheadAsk) error { + switch ask.Scope { + case "job": + return workstreamRefuseFields(fields, "wave", "milestone_id") + case "wave": + return workstreamRefuseFields(fields, "factory", "job", "milestone_id") + case "milestone": + return workstreamRefuseFields(fields, "factory", "job", "wave") + } + return nil +} + +func workstreamRead(ctx context.Context, door WorkstreamDoor, ask WorkstreamReadAsk) (string, error) { + if ask.What == "page" && strings.TrimSpace(ask.PageID) == "" { + return "", errors.New("page_id is required for page") + } + raw, err := door.Read(ctx, ask) + if err != nil { + return "", err + } + text := string(raw) + if ask.What == "workstream" { + text = "Numbers absent below are unknown; say so and never estimate them.\n" + text + } + if len(text) > workstreamReadMax { + const tail = "\nThe workstream read was cut." + text = workstreamCut(text, workstreamReadMax-len(tail)) + tail + } + return text, nil +} + +func workstreamCut(text string, limit int) string { + if len(text) <= limit { + return text + } + for limit > 0 && !utf8.RuneStart(text[limit]) { + limit-- + } + return text[:limit] +} + +type workstreamRecordAsk struct { + Text string `json:"text"` + Source *workstream.Source `json:"source,omitempty"` +} + +func workstreamRecordTool(door WorkstreamDoor, kind string) bare.Tool { + return workstreamTool("propose_"+kind, "Propose a "+kind+". Only the person can keep it; it is always proposed by the agent.", workstreamRecordSchema, func(ctx context.Context, ask workstreamRecordAsk) (string, error) { + if err := workstreamText(ask.Text, "text", workstreamRecordMax, true, false); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + if err := workstreamSourceText(ask.Source); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + p, err := door.Propose(ctx, WorkstreamProposal{Kind: kind, Text: ask.Text, Source: ask.Source}) + if err != nil { + return "", err + } + return "Proposed " + kind + " " + p.ID + ". Nothing is kept until the person keeps it.", nil + }) +} + +func workstreamText(text, field string, limit int, required, line bool) error { + if !utf8.ValidString(text) || len(text) > limit || required && strings.TrimSpace(text) == "" { + return fmt.Errorf("%s must be %smost %d bytes", field, workstreamTextBound(required), limit) + } + for _, r := range text { + if unicode.IsControl(r) && (line || r != '\n' && r != '\t') { + return fmt.Errorf("%s has a control character or more than one line", field) + } + } + return nil +} + +func workstreamTextBound(required bool) string { + if required { + return "non-empty and at " + } + return "at " +} + +func workstreamSourceText(source *workstream.Source) error { + if source == nil { + return nil + } + if err := workstreamText(source.Label, "source.label", workstreamLineMax, false, true); err != nil { + return err + } + if source.Factory != "" && source.Kind != "factory_job" { + return errors.New("source.factory is allowed only for factory_job") + } + return workstreamText(source.ID, "source.id", 4096, true, false) +} + +type workstreamDraftAsk struct { + Title string `json:"title"` + Repo string `json:"repo,omitempty"` + Text string `json:"text,omitempty"` +} + +type workstreamJobsAsk struct { + Jobs []workstreamDraftAsk `json:"jobs"` + Wave *int `json:"wave,omitempty"` + WaveTitle string `json:"wave_title,omitempty"` + MilestoneID string `json:"milestone_id,omitempty"` +} + +func workstreamValidateJobs(ask workstreamJobsAsk) error { + if len(ask.Jobs) < 1 || len(ask.Jobs) > workstreamJobsMax { + return fmt.Errorf("jobs must contain 1–%d drafts", workstreamJobsMax) + } + if ask.Wave != nil && *ask.Wave <= 0 { + return errors.New("wave must be an existing positive wave number") + } + if err := workstreamText(ask.WaveTitle, "wave_title", workstreamLineMax, false, true); err != nil { + return err + } + for _, job := range ask.Jobs { + if err := workstreamValidateDraft(job); err != nil { + return err + } + } + return nil +} + +func workstreamValidateDraft(job workstreamDraftAsk) error { + if err := workstreamText(job.Title, "title", workstreamLineMax, true, true); err != nil { + return err + } + if err := workstreamText(job.Repo, "repo", workstreamLineMax, false, true); err != nil { + return err + } + return workstreamText(job.Text, "text", workstreamDraftTextMax, false, false) +} + +func workstreamProposeJobs(ctx context.Context, door WorkstreamDoor, ask workstreamJobsAsk) (string, error) { + if err := workstreamValidateJobs(ask); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + drafts, err := workstreamDrafts(ask.Jobs) + if err != nil { + return "", err + } + plan, err := door.Plan(ctx) + if err != nil { + return "", err + } + wave, err := workstreamSaveDrafts(ctx, door, plan, ask, drafts) + if errors.Is(err, ErrWorkstreamVersion) { + plan, err = door.Plan(ctx) + if err != nil { + return "", err + } + wave, err = workstreamSaveDrafts(ctx, door, plan, ask, drafts) + } + if errors.Is(err, ErrWorkstreamVersion) { + return "", errors.New(workstreamPlanChanged) + } + if err != nil { + return "", err + } + return fmt.Sprintf("Added %d draft jobs to wave %d of the plan. They are drafts: nothing runs until the person launches them.", len(drafts), wave), nil +} + +func workstreamSaveDrafts(ctx context.Context, door WorkstreamDoor, plan WorkstreamPlanState, ask workstreamJobsAsk, drafts []workstream.PlanItem) (int, error) { + next, wave, err := workstreamAppendDrafts(plan, ask, drafts) + if err != nil { + return 0, err + } + _, err = door.SetPlan(ctx, plan.Version, next) + return wave, err +} + +func workstreamDrafts(jobs []workstreamDraftAsk) ([]workstream.PlanItem, error) { + out := make([]workstream.PlanItem, 0, len(jobs)) + for _, job := range jobs { + var id [8]byte + if _, err := rand.Read(id[:]); err != nil { + return nil, err + } + out = append(out, workstream.PlanItem{Kind: "draft", DraftID: "draft_" + hex.EncodeToString(id[:]), Title: job.Title, Repo: job.Repo, Text: job.Text}) + } + return out, nil +} + +func workstreamAppendDrafts(plan WorkstreamPlanState, ask workstreamJobsAsk, drafts []workstream.PlanItem) (WorkstreamPlanState, int, error) { + plan.Waves = append([]workstream.Wave(nil), plan.Waves...) + if ask.Wave != nil { + for i, wave := range plan.Waves { + if wave.N == *ask.Wave { + plan.Waves[i].Items = append(append([]workstream.PlanItem(nil), wave.Items...), drafts...) + return plan, wave.N, nil + } + } + return plan, 0, errors.New("wave must name an existing plan wave") + } + if ask.MilestoneID != "" && !workstreamHasMilestone(plan, ask.MilestoneID) { + return plan, 0, errors.New("milestone_id must name an existing plan milestone") + } + n := 1 + if len(plan.Waves) > 0 { + n = plan.Waves[len(plan.Waves)-1].N + 1 + } + plan.Waves = append(plan.Waves, workstream.Wave{N: n, Title: ask.WaveTitle, MilestoneID: ask.MilestoneID, AutoStart: false, Items: append([]workstream.PlanItem(nil), drafts...)}) + return plan, n, nil +} + +func workstreamHasMilestone(plan WorkstreamPlanState, id string) bool { + for _, milestone := range plan.Milestones { + if milestone.ID == id { + return true + } + } + return false +} + +func workstreamProposeGoAhead(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk, now time.Time) (string, error) { + if err := WorkstreamValidateGoAhead(ask, now); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + scope, err := workstreamGoAheadScope(ctx, door, ask) + if err != nil { + return "", err + } + text := fmt.Sprintf("%s%s, up to $%.2f, until %s. %s", WorkstreamGoAheadProposalPrefix, scope, ask.CapUSD, ask.EndsOn, ask.Why) + p, err := door.Propose(ctx, WorkstreamProposal{Kind: "decision", Text: text, GoAhead: &ask}) + if err != nil { + return "", err + } + return "Proposed a go-ahead as decision " + p.ID + ". It grants nothing; the person grants it if they agree.", nil +} + +// WorkstreamValidateGoAhead is shared with the app so its clock validates proposals too. +func WorkstreamValidateGoAhead(ask WorkstreamGoAheadAsk, now time.Time) error { + if !(ask.CapUSD > 0 && ask.CapUSD <= workstreamGoAheadCapMax) { + return fmt.Errorf("cap_usd must be greater than 0 and at most %d", workstreamGoAheadCapMax) + } + end, err := time.Parse("2006-01-02", ask.EndsOn) + today := now.UTC().Truncate(24 * time.Hour) + if err != nil || !end.After(today) || end.After(today.AddDate(0, 0, workstreamGoAheadDaysMax)) { + return fmt.Errorf("ends_on must be YYYY-MM-DD, after today in UTC and at most %d days ahead", workstreamGoAheadDaysMax) + } + if err := workstreamText(ask.Why, "why", workstreamGoAheadWhyMax, true, false); err != nil { + return err + } + return workstreamValidateGoAheadTarget(ask) +} + +func workstreamValidateGoAheadTarget(ask WorkstreamGoAheadAsk) error { + if workstreamGoAheadOtherFields(ask) { + return errors.New("The go-ahead has fields for another scope.") + } + switch ask.Scope { + case "job": + if ask.Factory == "" || ask.Job <= 0 { + return errors.New("job scope requires only factory and a positive job") + } + case "wave": + if ask.Wave <= 0 { + return errors.New("wave scope requires only a positive wave") + } + case "milestone": + if ask.MilestoneID == "" { + return errors.New("milestone scope requires only milestone_id") + } + default: + return errors.New("scope must be job, wave or milestone") + } + return nil +} + +func workstreamGoAheadOtherFields(ask WorkstreamGoAheadAsk) bool { + switch ask.Scope { + case "job": + return ask.Wave != 0 || ask.MilestoneID != "" + case "wave": + return ask.Factory != "" || ask.Job != 0 || ask.MilestoneID != "" + case "milestone": + return ask.Factory != "" || ask.Job != 0 || ask.Wave != 0 + } + return false +} + +func workstreamGoAheadScope(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk) (string, error) { + if ask.Scope == "job" { + return fmt.Sprintf("job %s#%d", ask.Factory, ask.Job), nil + } + plan, err := door.Plan(ctx) + if err != nil { + return "", err + } + if ask.Scope == "wave" { + for _, wave := range plan.Waves { + if wave.N == ask.Wave { + return fmt.Sprintf("wave %d", wave.N), nil + } + } + return "", errors.New("wave must name an existing plan wave") + } + for _, milestone := range plan.Milestones { + if milestone.ID == ask.MilestoneID { + return fmt.Sprintf("milestone \"%s\" (%s)", milestone.Title, milestone.ID), nil + } + } + return "", errors.New("milestone_id must name an existing plan milestone") +} + +func workstreamWritePage(ctx context.Context, door WorkstreamDoor, ask WorkstreamPageWrite) (string, error) { + if ask.PageID == "" { + if err := workstreamText(ask.Title, "title", workstreamLineMax, true, true); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + } + if err := workstreamText(ask.UpdatedFrom, "updated_from", workstreamLineMax, true, true); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + if err := workstreamSourceText(ask.Source); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + p, err := door.WritePage(ctx, ask) + if errors.Is(err, ErrWorkstreamVersion) { + return "", errors.New("The page changed while you were writing. Read it again before writing.") + } + if err != nil { + return "", err + } + return fmt.Sprintf("Wrote %s (%s, version %d), marked as written by the agent from %q.", p.Title, p.ID, p.Version, ask.UpdatedFrom), nil +} diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go new file mode 100644 index 0000000000..6859db2b51 --- /dev/null +++ b/internal/session/workstream_tools_test.go @@ -0,0 +1,433 @@ +package session + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +type workstreamFakeDoor struct { + brief WorkstreamBrief + briefReady bool + read json.RawMessage + readAsk WorkstreamReadAsk + proposals []WorkstreamProposal + plan WorkstreamPlanState + reads int + saves []WorkstreamPlanState + bases []int + conflicts int + pageWrites []WorkstreamPageWrite + pageError error + otherCalls int +} + +func (d *workstreamFakeDoor) Brief() (WorkstreamBrief, bool) { + return d.brief, d.briefReady +} + +func (d *workstreamFakeDoor) Read(_ context.Context, ask WorkstreamReadAsk) (json.RawMessage, error) { + d.otherCalls++ + d.readAsk = ask + return d.read, nil +} + +func (d *workstreamFakeDoor) Propose(_ context.Context, p WorkstreamProposal) (WorkstreamProposed, error) { + d.otherCalls++ + d.proposals = append(d.proposals, p) + return WorkstreamProposed{ID: "record-1"}, nil +} + +func (d *workstreamFakeDoor) Plan(context.Context) (WorkstreamPlanState, error) { + d.reads++ + return d.plan, nil +} + +func (d *workstreamFakeDoor) SetPlan(_ context.Context, base int, p WorkstreamPlanState) (WorkstreamPlanState, error) { + d.saves = append(d.saves, p) + d.bases = append(d.bases, base) + if len(d.saves) <= d.conflicts { + d.plan.Version++ + d.plan.Waves = append(d.plan.Waves, workstream.Wave{N: 9, Title: "Person's wave", AutoStart: true, Items: []workstream.PlanItem{}}) + return WorkstreamPlanState{}, ErrWorkstreamVersion + } + d.plan = p + d.plan.Version++ + return d.plan, nil +} + +func (d *workstreamFakeDoor) WritePage(_ context.Context, p WorkstreamPageWrite) (WorkstreamPageWritten, error) { + d.otherCalls++ + d.pageWrites = append(d.pageWrites, p) + return WorkstreamPageWritten{ID: "page-1", Title: "Notes", Version: 2}, d.pageError +} + +func workstreamToolForTest(t *testing.T, d *workstreamFakeDoor, name string) bare.Tool { + t.Helper() + a := &Agent{config: Config{Workstream: d}} + now := func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) } + for _, tool := range a.workstreamToolsAt(now) { + if tool.Name == name { + return tool + } + } + t.Fatalf("missing %s", name) + return bare.Tool{} +} + +func workstreamCallForTest(t *testing.T, d *workstreamFakeDoor, name, args string, refused bool) string { + t.Helper() + got, failed, err := workstreamToolForTest(t, d, name).Execute(context.Background(), json.RawMessage(args)) + if err != nil || failed != refused { + t.Fatalf("%s: %q failed=%v err=%v", name, got, failed, err) + } + return got +} + +// Contract (a) preserves the ordinary page and belt byte for byte. +func TestWorkstreamAgentToolsAndPromptAreScoped(t *testing.T) { + cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()} + now := time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC) + plain := &Agent{config: cfg} + belt := plain.belt() + page := renderSystemAt(cfg, now) + d := &workstreamFakeDoor{brief: WorkstreamBrief{Name: "Usage", Why: "Explain recorded usage"}, briefReady: true} + cfg.Workstream = d + with := &Agent{config: cfg} + if len(with.belt()) != len(belt)+6 || len(with.workstreamTools()) != 6 { + t.Fatal("the workstream must add exactly six tools") + } + if !reflect.DeepEqual(workstreamOrdinaryBelt(belt), workstreamOrdinaryBelt(with.belt())) { + t.Fatal("the workstream changed the ordinary belt's bytes") + } + section := "\n\n" + workstreamPage(cfg) + rendered := renderSystemAt(cfg, now) + if !strings.Contains(rendered, `workstream "Usage"`) || !strings.Contains(rendered, "Explain recorded usage") || strings.Replace(rendered, section, "", 1) != page { + t.Fatal("the gated section changed the ordinary page") + } + if workstreamWithoutPage(rendered) != page { + t.Fatal("a child prompt retained the workstream section") + } + workstreamCheckPromotedPrompt(t, rendered, page) +} + +func TestWorkstreamAgentToolsAndPromptStayOffChildren(t *testing.T) { + cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}} + now := time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC) + for _, mutate := range []func(*Config){ + func(c *Config) { c.InTask = true }, + func(c *Config) { c.Errand = true }, + func(c *Config) { c.taskControl = &TaskNode{spec: taskSpec{quick: &quickTaskSpec{reading: true}}} }, + func(c *Config) { c.Workstream = nil }, + } { + child := cfg + mutate(&child) + a := &Agent{config: child} + if len(a.workstreamTools()) != 0 || strings.Contains(renderSystemAt(child, now), "WORKSTREAM\n") { + t.Fatal("a child or plain agent inherited the workstream") + } + } +} + +func workstreamCheckPromotedPrompt(t *testing.T, rendered, page string) { + t.Helper() + quick := &quickTaskSpec{system: rendered} + quick.seed = append(quick.seed, textMessage("system", rendered)) + child := &Agent{} + adoptSeed(child, quick) + if child.system != promotedSystem(page) || messageContentText(quick.seed[0]) != rendered { + t.Fatal("a promoted worker retained the workstream section or changed its parent's seed") + } +} + +func TestWorkstreamPromptUsesSnapshotOrFallback(t *testing.T) { + d := &workstreamFakeDoor{} + cfg := Config{Workstream: d} + if !strings.Contains(workstreamPage(cfg), "Read it with workstream_read before you answer.") { + t.Fatal("the early prompt must remain usable") + } + d.briefReady = true + d.brief = WorkstreamBrief{Name: strings.Repeat(`"`, 256), Why: strings.Repeat("why ", 1000)} + if len(workstreamPage(cfg)) > 1000 { + t.Fatal("the workstream prompt section exceeded its budget") + } +} + +func TestWorkstreamPromptFallbackRendersImmediatelyUnderTheSessionLock(t *testing.T) { + a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}}} + finished := make(chan string, 1) + guard.Go("session/workstream-prompt-test", func() { + finished <- workstreamPromptUnderLock(a) + }) + select { + case page := <-finished: + if !strings.Contains(page, "Read it with workstream_read before you answer.") { + t.Fatal("a missing snapshot must render the fallback") + } + if !a.mu.TryLock() { + t.Fatal("prompt rendering retained the session lock") + } + defer a.mu.Unlock() + case <-time.After(3 * time.Second): + t.Fatal("the prompt did not render promptly without a store") + } +} + +func workstreamPromptUnderLock(a *Agent) string { + a.mu.Lock() + defer a.mu.Unlock() + return renderSystemAt(a.config, time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC)) +} + +func workstreamOrdinaryBelt(tools []bare.Tool) []string { + names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page"} + var out []string + for _, tool := range tools { + if !workstreamHasWord(names, tool.Name) { + out = append(out, tool.Name+"\n"+tool.Description+"\n"+string(tool.Schema)) + } + } + return out +} + +// Contract (b) appends only drafts and preserves an existing wave's settings. +func TestWorkstreamJobsAppendToExistingWave(t *testing.T) { + d := &workstreamFakeDoor{plan: WorkstreamPlanState{Version: 3, Waves: []workstream.Wave{{N: 2, AutoStart: true, Items: []workstream.PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}}} + got := workstreamCallForTest(t, d, "propose_jobs", `{"wave":2,"jobs":[{"title":"One"},{"title":"Two"}]}`, false) + wave := d.saves[0].Waves[0] + if got != "Added 2 draft jobs to wave 2 of the plan. They are drafts: nothing runs until the person launches them." || !wave.AutoStart || len(wave.Items) != 3 || wave.Items[0].Job != 7 || wave.Items[1].DraftID == wave.Items[2].DraftID { + t.Fatalf("appended wave: %q %+v", got, wave) + } +} + +// Contract (g) labels unknown readings and caps text without splitting UTF-8. +func TestWorkstreamReadCapsTextAndNamesUnknownNumbers(t *testing.T) { + d := &workstreamFakeDoor{read: json.RawMessage(`{"name":"Usage"}`)} + got := workstreamCallForTest(t, d, "workstream_read", `{"what":"workstream"}`, false) + if got != "Numbers absent below are unknown; say so and never estimate them.\n"+string(d.read) { + t.Fatalf("unknown numbers: %q", got) + } + d.read = json.RawMessage(`{"markdown":"` + strings.Repeat("é", 40000) + `"}`) + got = workstreamCallForTest(t, d, "workstream_read", `{"what":"page","page_id":"p1"}`, false) + if len(got) > 60000 || !strings.HasSuffix(got, "\nThe workstream read was cut.") || d.readAsk.PageID != "p1" { + t.Fatalf("capped read: %d %+v", len(got), d.readAsk) + } +} + +// Contract (e) returns attribution; a page version conflict is refused without retry. +func TestWorkstreamPageWritesAndConflictsHaveExactAnswers(t *testing.T) { + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, "write_agent_page", `{"title":"Notes","markdown":"Words","updated_from":"Discussion"}`, false) + if got != `Wrote Notes (page-1, version 2), marked as written by the agent from "Discussion".` || len(d.pageWrites) != 1 || d.pageWrites[0].Source != nil { + t.Fatalf("write receipt: %q %+v", got, d.pageWrites) + } + d.pageError = ErrWorkstreamVersion + got = workstreamCallForTest(t, d, "write_agent_page", `{"page_id":"p1","markdown":"Words","updated_from":"Discussion"}`, true) + if got != "The page changed while you were writing. Read it again before writing." || len(d.pageWrites) != 2 { + t.Fatalf("conflict receipt: %q %+v", got, d.pageWrites) + } +} + +// Contract (g) also applies to nested objects and fields for another operation. +func TestWorkstreamArgumentsRefuseNestedFactsAndScopeMixing(t *testing.T) { + cases := []struct { + name string + args string + key string + }{ + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":true}}`, "kept"}, + {"propose_jobs", `{"jobs":[{"title":"Draft","job":7}]}`, "job"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":1,"wave_title":"New"}`, "wave_title"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":1,"milestone_id":"m1"}`, "milestone_id"}, + {"write_agent_page", `{"page_id":"p1","title":"Rename","markdown":"Words","updated_from":"Discussion"}`, "title"}, + {"write_agent_page", `{"page_id":"p1","parent_id":"p2","markdown":"Words","updated_from":"Discussion"}`, "parent_id"}, + {"propose_goahead", `{"scope":"wave","wave":1,"job":0,"cap_usd":1,"ends_on":"2026-11-04","why":"Words"}`, "job"}, + {"workstream_read", `{"what":"pages","page_id":"p1"}`, "page_id"}, + {"workstream_read", `{"what":"plan","state":"kept"}`, "state"}, + {"workstream_read", `{"what":"plan","kind":"decision"}`, "kind"}, + } + for _, tc := range cases { + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, tc.name, tc.args, true) + if !strings.Contains(got, tc.key) || d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("%s: %q %+v", tc.name, got, d) + } + } +} + +// Contracts (b) and (g) bound proposed permission by the UTC day and dollar cap. +func TestWorkstreamGoAheadDatesAndCapsAreOnlyProposals(t *testing.T) { + now := time.Date(2026, 11, 3, 23, 0, 0, 0, time.FixedZone("west", -5*60*60)) + base := WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 10000, EndsOn: "2026-11-05", Why: "Review"} + if err := WorkstreamValidateGoAhead(base, now); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*WorkstreamGoAheadAsk){ + func(a *WorkstreamGoAheadAsk) { a.CapUSD = 0 }, + func(a *WorkstreamGoAheadAsk) { a.CapUSD = 10000.01 }, + func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-04" }, + func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2027-02-03" }, + func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-05T00:00:00Z" }, + func(a *WorkstreamGoAheadAsk) { a.Why = "" }, + } { + ask := base + mutate(&ask) + if err := WorkstreamValidateGoAhead(ask, now); err == nil { + t.Fatalf("accepted %+v", ask) + } + } +} + +// Contract (b) exposes no way to keep a record or grant a go-ahead. +func TestWorkstreamProposalsAreOnlyWords(t *testing.T) { + d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} + for _, kind := range []string{"decision", "question"} { + got := workstreamCallForTest(t, d, "propose_"+kind, `{"text":"Keep this?"}`, false) + want := "Proposed " + kind + " record-1. Nothing is kept until the person keeps it." + if got != want || d.proposals[len(d.proposals)-1].Kind != kind || d.proposals[len(d.proposals)-1].Source != nil { + t.Fatalf("proposal: %q %+v", got, d.proposals) + } + } + got := workstreamCallForTest(t, d, "propose_goahead", `{"scope":"wave","wave":2,"cap_usd":12.5,"ends_on":"2026-11-04","why":"Review the result"}`, false) + p := d.proposals[len(d.proposals)-1] + if got != "Proposed a go-ahead as decision record-1. It grants nothing; the person grants it if they agree." || p.Kind != "decision" || p.Text != "Proposed go-ahead (not granted): wave 2, up to $12.50, until 2026-11-04. Review the result" { + t.Fatalf("go-ahead: %q %+v", got, p) + } +} + +// Contract (f) rereads and reapplies once, preserving every existing plan field. +func TestWorkstreamJobsRetryOnlyOneVersionConflict(t *testing.T) { + for _, conflicts := range []int{0, 1, 2} { + d := &workstreamFakeDoor{conflicts: conflicts, plan: WorkstreamPlanState{Version: 4, Milestones: []workstream.Milestone{{ID: "m1", Title: "Ship"}}, Waves: []workstream.Wave{{N: 3, Title: "Existing", MilestoneID: "m1", AutoStart: true, Items: []workstream.PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}}} + before := d.plan + got := workstreamCallForTest(t, d, "propose_jobs", `{"jobs":[{"title":"Draft","repo":"api","text":"Read first"}]}`, conflicts == 2) + wantCalls := min(conflicts+1, 2) + if len(d.saves) != wantCalls || d.reads != wantCalls || d.otherCalls != 0 { + t.Fatalf("conflicts=%d calls=%+v", conflicts, d) + } + if !reflect.DeepEqual(d.saves[0].Waves[0], before.Waves[0]) || !reflect.DeepEqual(d.saves[0].Milestones, before.Milestones) { + t.Fatal("existing plan changed") + } + workstreamCheckRetriedDrafts(t, d, conflicts, got) + } +} + +func workstreamCheckRetriedDrafts(t *testing.T, d *workstreamFakeDoor, conflicts int, got string) { + t.Helper() + last := d.saves[len(d.saves)-1] + wave := last.Waves[len(last.Waves)-1] + if wave.AutoStart || len(wave.Items) != 1 || wave.Items[0].Kind != "draft" || !strings.HasPrefix(wave.Items[0].DraftID, "draft_") { + t.Fatalf("not drafts: %+v", wave) + } + if conflicts == 2 && got != "The plan changed while you were proposing; nothing was added. Read the plan and propose again." { + t.Fatalf("second conflict: %q", got) + } + if conflicts == 1 && (wave.N != 10 || d.bases[1] != 5 || last.Waves[1].Title != "Person's wave") { + t.Fatalf("did not reapply to the new plan: %+v", last) + } + if conflicts > 0 && d.saves[0].Waves[1].Items[0].DraftID != wave.Items[0].DraftID { + t.Fatal("retry changed the draft identity") + } +} + +func TestWorkstreamArgumentsRefuseDuplicateKeysBeforeTheDoor(t *testing.T) { + cases := []struct { + name string + args string + key string + }{ + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":true},"source":{"kind":"url","id":"https://example.com"}}`, "source"}, + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","id":"https://example.com"}}`, "id"}, + {"write_agent_page", `{"title":"Notes","markdown":"First","markdown":"Second","updated_from":"Discussion"}`, "markdown"}, + {"propose_jobs", `{"jobs":[{"title":"First","title":"Second"}]}`, "title"}, + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com"},"sour\u0063e":{"kind":"url","id":"https://example.com"}}`, "source"}, + } + for _, tc := range cases { + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, tc.name, tc.args, true) + if !strings.Contains(got, tc.key) || d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("%s: %q %+v", tc.name, got, d) + } + } +} + +// Contract (d) surfaces an ownership refusal without pretending to write. +func TestWorkstreamPageOwnershipRefusal(t *testing.T) { + d := &workstreamFakeDoor{pageError: ErrWorkstreamNotAgentPage} + got := workstreamCallForTest(t, d, "write_agent_page", `{"page_id":"page-1","markdown":"Words","updated_from":"Discussion"}`, true) + if got != "That page is the person's. You write only your own pages; suggesting edits on theirs is not available yet." { + t.Fatalf("refusal: %q", got) + } +} + +// Contract (g) refuses unknown fields before touching any door. +func TestWorkstreamToolsRefuseInventedFacts(t *testing.T) { + names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page"} + fields := []string{"kept", "state", "cost_usd", "spend_usd", "count", "merged", "budget_usd", "auto_start", "version"} + for _, name := range names { + for _, field := range fields { + if name == "workstream_read" && field == "state" { + continue + } + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, name, `{"`+field+`":true}`, true) + if !strings.Contains(got, field) || d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("%s accepted %s: %q", name, field, got) + } + } + var schema map[string]any + if err := json.Unmarshal(workstreamToolForTest(t, &workstreamFakeDoor{}, name).Schema, &schema); err != nil || schema["additionalProperties"] != false { + t.Fatalf("%s schema: %+v %v", name, schema, err) + } + properties := schema["properties"].(map[string]any) + for _, field := range fields { + if name == "workstream_read" && field == "state" { + continue + } + if _, exists := properties[field]; exists { + t.Fatalf("%s schema accepts invented %s", name, field) + } + } + } +} + +// Contract (h) assigns an action family to every workstream tool. +func TestWorkstreamToolActionCategories(t *testing.T) { + for _, tool := range (&Agent{config: Config{Workstream: &workstreamFakeDoor{}}}).workstreamTools() { + want := ActionCreate + if tool.Name == "workstream_read" { + want = ActionRead + } + if got := ActionCategoryForTool(tool.Name); got != want { + t.Errorf("%s: %s, want %s", tool.Name, got, want) + } + } +} + +// The app draws the agent's summary from the agent page titled exactly +// "Where it stands", so the prompt and write_agent_page both name that title. +func TestWorkstreamAgentKeepsWhereItStandsCurrent(t *testing.T) { + if WorkstreamWhereItStandsTitle != "Where it stands" { + t.Fatalf("the Overview summary page title moved: %q", WorkstreamWhereItStandsTitle) + } + cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}} + if !strings.Contains(workstreamPage(cfg), `titled "Where it stands" current with write_agent_page`) { + t.Fatal("the workstream prompt does not ask the agent to keep Where it stands current") + } + described := false + for _, tool := range (&Agent{config: cfg}).workstreamTools() { + if tool.Name == "write_agent_page" { + described = strings.Contains(tool.Description, `"Where it stands"`) + } + } + if !described { + t.Fatal("write_agent_page does not name the Where it stands page") + } +} diff --git a/internal/session/workstream_validation_test.go b/internal/session/workstream_validation_test.go new file mode 100644 index 0000000000..46317e6ce2 --- /dev/null +++ b/internal/session/workstream_validation_test.go @@ -0,0 +1,159 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// Contract (g) validates byte bounds and JSON shapes before any read or write. +func TestWorkstreamToolBoundsRefuseBeforeTheDoor(t *testing.T) { + cases := []struct { + name string + args string + }{ + {"propose_decision", `{"text":""}`}, + {"propose_decision", `{"text":"` + strings.Repeat("é", 1001) + `"}`}, + {"propose_question", `{"text":"Words","source":{"kind":"url","id":"https://example.com","label":"` + strings.Repeat("x", 201) + `"}}`}, + {"propose_jobs", `{"jobs":[]}`}, + {"propose_jobs", `{"jobs":[{"title":"` + strings.Repeat("x", 201) + `"}]}`}, + {"propose_jobs", `{"jobs":[{"title":"Draft","repo":"two\nlines"}]}`}, + {"propose_jobs", `{"jobs":[{"title":"Draft","text":"` + strings.Repeat("x", 4001) + `"}]}`}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":"1"}`}, + {"propose_goahead", `{"scope":"job","factory":"f","job":7,"cap_usd":"1","ends_on":"2026-11-04","why":"Words"}`}, + {"write_agent_page", `{"markdown":"Words","updated_from":"Discussion"}`}, + {"write_agent_page", `{"title":"Notes","markdown":"Words","updated_from":"two\nlines"}`}, + {"workstream_read", `[]`}, + {"workstream_read", `null`}, + {"workstream_read", `{"what":null}`}, + {"workstream_read", `{"what":"page"}`}, + } + for _, tc := range cases { + d := &workstreamFakeDoor{} + ctx, mark := withToolRejection(context.Background()) + text, bad, err := workstreamToolForTest(t, d, tc.name).Execute(ctx, json.RawMessage(tc.args)) + if err != nil || !bad || !mark.RejectedBeforeRun() { + t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) + } + if d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("invalid %s reached the door: %+v", tc.name, d) + } + } +} + +func TestWorkstreamArgumentRefusalsAreMarked(t *testing.T) { + for _, tc := range []struct { + name string + args string + key string + }{ + {"workstream_read", `{"what":`, "valid JSON"}, + {"workstream_read", `{"what":"page"}`, "page_id"}, + {"workstream_read", `{"what":"pages","page_id":""}`, "page_id"}, + {"workstream_read", `{"what":"plan","kind":"decision"}`, "kind"}, + {"workstream_read", `"{\"what\":\"pages\"}"`, "JSON object"}, + {"propose_decision", `{"text":"Words","kept":false}`, "kept"}, + {"propose_question", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":false}}`, "kept"}, + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","id":"https://example.com"}}`, "id"}, + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","factory":""}}`, "factory"}, + {"propose_decision", `{"text":"Words","source":null}`, "source"}, + {"propose_decision", `{"text":"Words","source":"{\"kind\":\"url\",\"id\":\"https://example.com\",\"kept\":true}"}`, "source"}, + {"propose_jobs", `{"jobs":[{"title":"Draft","kept":false}]}`, "kept"}, + {"propose_jobs", `{"jobs":[null]}`, "jobs[0]"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":null}`, "wave"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":2,"wave_title":""}`, "wave_title"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":2,"milestone_id":""}`, "milestone_id"}, + {"propose_goahead", `{"scope":"wave","wave":2,"factory":"","cap_usd":12.5,"ends_on":"2026-11-04","why":"Review"}`, "factory"}, + {"write_agent_page", `{"page_id":"p1","title":"","markdown":"Words","updated_from":"Discussion"}`, "title"}, + {"write_agent_page", `{"page_id":"p1","parent_id":"","markdown":"Words","updated_from":"Discussion"}`, "parent_id"}, + } { + d := &workstreamFakeDoor{} + ctx, mark := withToolRejection(context.Background()) + text, bad, err := workstreamToolForTest(t, d, tc.name).Execute(ctx, json.RawMessage(tc.args)) + if err != nil || !bad || !mark.RejectedBeforeRun() || !strings.Contains(text, tc.key) { + t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) + } + if d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("invalid %s reached the door: %+v", tc.name, d) + } + } +} + +func TestWorkstreamDoorErrorsAreNotArgumentRejections(t *testing.T) { + for _, problem := range []error{ErrWorkstreamVersion, ErrWorkstreamNotAgentPage, ErrWorkstreamDone, errors.New("Invalid arguments: from the page store")} { + d := &workstreamFakeDoor{pageError: problem} + ctx, mark := withToolRejection(context.Background()) + text, bad, err := workstreamToolForTest(t, d, "write_agent_page").Execute(ctx, json.RawMessage(`{"page_id":"p1","markdown":"Words","updated_from":"Discussion"}`)) + if err != nil || !bad || mark.RejectedBeforeRun() || len(d.pageWrites) != 1 { + t.Fatalf("%v: %q bad=%v rejected=%v err=%v", problem, text, bad, mark.RejectedBeforeRun(), err) + } + } +} + +func TestWorkstreamSchemasAreWellFormedAndCarryNoReference(t *testing.T) { + for _, tool := range (&Agent{config: Config{Workstream: &workstreamFakeDoor{}}}).workstreamTools() { + if !json.Valid(tool.Schema) { + t.Fatalf("%s has a malformed schema", tool.Name) + } + for _, mark := range []string{`"$ref"`, `"$defs"`, `"definitions"`} { + if strings.Contains(string(tool.Schema), mark) { + t.Fatalf("%s schema carries %s", tool.Name, mark) + } + } + } +} + +func TestWorkstreamWholeNumbersUseTheSharedArgumentGrammar(t *testing.T) { + for _, number := range []string{"2.0", "2e0"} { + d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} + workstreamCallForTest(t, d, "propose_jobs", `{"wave":`+number+`,"jobs":[{"title":"Draft"}]}`, false) + if len(d.saves) != 1 || len(d.saves[0].Waves[0].Items) != 1 { + t.Fatalf("whole-valued wave %s did not append one draft: %+v", number, d) + } + } + for _, tc := range []struct { + number string + want string + }{ + {"2.5", `Invalid arguments: wave takes a whole number: send {"wave":2}, not 2.5`}, + {`"2"`, `Invalid arguments: wave takes a whole number: send {"wave":2}, not "2"`}, + } { + d := &workstreamFakeDoor{} + ctx, mark := withToolRejection(context.Background()) + text, bad, err := workstreamToolForTest(t, d, "propose_jobs").Execute(ctx, json.RawMessage(`{"wave":`+tc.number+`,"jobs":[{"title":"Draft"}]}`)) + if err != nil || !bad || !mark.RejectedBeforeRun() || text != tc.want { + t.Fatalf("wave %s: %q bad=%v rejected=%v err=%v", tc.number, text, bad, mark.RejectedBeforeRun(), err) + } + if d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatal("an invalid wave reached the door") + } + } +} + +func TestWorkstreamGoAheadDecodesFractionalDollarsAndWholeTargets(t *testing.T) { + for _, tc := range []struct { + name string + target string + scope string + }{ + {"job", `"factory":"f","job":7.0`, "job f#7"}, + {"wave", `"wave":2e0`, "wave 2"}, + } { + d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} + ctx, mark := withToolRejection(context.Background()) + args := `{"scope":"` + tc.name + `",` + tc.target + `,"cap_usd":12.50,"ends_on":"2026-11-04","why":"Review"}` + text, bad, err := workstreamToolForTest(t, d, "propose_goahead").Execute(ctx, json.RawMessage(args)) + if err != nil || bad || mark.RejectedBeforeRun() || len(d.proposals) != 1 { + t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) + } + proposal := d.proposals[0] + want := WorkstreamGoAheadProposalPrefix + tc.scope + ", up to $12.50, until 2026-11-04. Review" + if proposal.GoAhead == nil || proposal.GoAhead.CapUSD != 12.5 || proposal.Text != want || proposal.Kind != "decision" { + t.Fatalf("fractional dollars changed: %+v", proposal) + } + } +} diff --git a/internal/workstream/pagedoc/frommarkdown.go b/internal/workstream/pagedoc/frommarkdown.go new file mode 100644 index 0000000000..acb836c9d5 --- /dev/null +++ b/internal/workstream/pagedoc/frommarkdown.go @@ -0,0 +1,359 @@ +package pagedoc + +import ( + "encoding/json" + "regexp" + "strconv" + "strings" +) + +var workstreamMarkdownHeading = regexp.MustCompile(`^(#+) +(.*)$`) + +var workstreamMarkdownList = regexp.MustCompile(`^( *)([-*+] |([0-9]+)[.)] )(.*)$`) + +var workstreamMarkdownLive = regexp.MustCompile(`^\[live: (jobs|waves)( milestone=([^\s\]]+))?\]$`) + +var workstreamMarkdownJob = regexp.MustCompile(`^\[live: job ([^\s#]*)#([0-9]+)\]$`) + +var workstreamMarkdownDecision = regexp.MustCompile(`^\[live: decision ([^\s\]]+)\]$`) + +// FromMarkdown accepts prose without requiring the agent to construct editor JSON. +func FromMarkdown(md string) json.RawMessage { + md = strings.ToValidUTF8(md, "�") + md = strings.ReplaceAll(strings.ReplaceAll(md, "\r\n", "\n"), "\r", "\n") + doc := map[string]any{"type": "doc", "content": workstreamMarkdownBlocks(strings.Split(md, "\n"), 0)} + out, _ := json.Marshal(doc) + return out +} + +func workstreamMarkdownNode(kind string, content []map[string]any) map[string]any { + return map[string]any{"type": kind, "content": content} +} + +func workstreamMarkdownBlocks(lines []string, depth int) []map[string]any { + out := make([]map[string]any, 0) + for i := 0; i < len(lines); { + line := strings.TrimSpace(lines[i]) + if line == "" || line == "---" { + i++ + continue + } + if fence, language := workstreamMarkdownFence(line); fence != "" { + node, next := workstreamMarkdownCode(lines, i, fence, language) + out = append(out, node) + i = next + continue + } + if match := workstreamMarkdownHeading.FindStringSubmatch(line); match != nil { + node := workstreamMarkdownNode("heading", workstreamMarkdownInline(match[2])) + node["attrs"] = map[string]any{"level": min(3, len(match[1]))} + out = append(out, node) + i++ + continue + } + if node := workstreamMarkdownLiveNode(line); node != nil { + out = append(out, node) + i++ + continue + } + if depth < 16 && workstreamMarkdownList.MatchString(lines[i]) { + node, next := workstreamMarkdownListNode(lines, i, depth) + out = append(out, node) + i = next + continue + } + parts := []string{workstreamMarkdownProse(line)} + i++ + for i < len(lines) && !workstreamMarkdownBoundary(lines[i], depth) { + parts = append(parts, workstreamMarkdownProse(lines[i])) + i++ + } + text := strings.Join(parts, " ") + out = append(out, workstreamMarkdownNode("paragraph", workstreamMarkdownInline(text))) + } + return out +} + +func workstreamMarkdownProse(line string) string { + return strings.TrimPrefix(strings.TrimSpace(line), "> ") +} + +func workstreamMarkdownBoundary(line string, depth int) bool { + line = strings.TrimSpace(line) + fence, _ := workstreamMarkdownFence(line) + return line == "" || line == "---" || fence != "" || workstreamMarkdownHeading.MatchString(line) || workstreamMarkdownLiveNode(line) != nil || depth < 16 && workstreamMarkdownList.MatchString(line) +} + +func workstreamMarkdownFence(line string) (string, string) { + if !strings.HasPrefix(line, "```") && !strings.HasPrefix(line, "~~~") { + return "", "" + } + i := 0 + for i < len(line) && line[i] == line[0] { + i++ + } + return line[:i], strings.TrimSpace(line[i:]) +} + +func workstreamMarkdownCode(lines []string, at int, fence, language string) (map[string]any, int) { + i := at + 1 + for i < len(lines) { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, fence) && strings.Trim(line, string(fence[0])) == "" { + break + } + i++ + } + text := strings.Join(lines[at+1:i], "\n") + node := workstreamMarkdownNode("codeBlock", []map[string]any{{"type": "text", "text": text}}) + var lang any + if language != "" { + lang = language + } + node["attrs"] = map[string]any{"language": lang} + return node, min(i+1, len(lines)) +} + +func workstreamMarkdownListNode(lines []string, at, depth int) (map[string]any, int) { + first := workstreamMarkdownList.FindStringSubmatch(lines[at]) + indent, ordered := len(first[1]), first[3] != "" + items := make([]map[string]any, 0) + i := at + for i < len(lines) { + match := workstreamMarkdownList.FindStringSubmatch(lines[i]) + if match == nil || len(match[1]) != indent || (match[3] != "") != ordered { + break + } + body, next := workstreamMarkdownListBody(lines, i, indent, match) + items = append(items, workstreamMarkdownListItem(body, depth+1)) + i = next + } + kind := "bulletList" + if ordered { + kind = "orderedList" + } + node := workstreamMarkdownNode(kind, items) + if ordered { + start, err := strconv.Atoi(first[3]) + if err != nil { + start = 1 + } + node["attrs"] = map[string]any{"start": start} + } + return node, i +} + +func workstreamMarkdownListBody(lines []string, at, indent int, match []string) ([]string, int) { + width := len(match[1]) + len(match[2]) + body := []string{match[4]} + i := at + 1 + for i < len(lines) { + line := lines[i] + if strings.TrimSpace(line) == "" { + if i+1 >= len(lines) || len(lines[i+1])-len(strings.TrimLeft(lines[i+1], " ")) <= indent { + break + } + } else if len(line)-len(strings.TrimLeft(line, " ")) <= indent { + break + } + cut := min(width, len(line)-len(strings.TrimLeft(line, " "))) + body = append(body, line[cut:]) + i++ + } + return body, i +} + +func workstreamMarkdownListItem(lines []string, depth int) map[string]any { + content := workstreamMarkdownBlocks(lines, depth) + if len(content) == 0 || content[0]["type"] != "paragraph" { + content = append([]map[string]any{workstreamMarkdownNode("paragraph", []map[string]any{})}, content...) + } + return workstreamMarkdownNode("listItem", content) +} + +func workstreamMarkdownLiveNode(line string) map[string]any { + attrs := map[string]any{"kind": nil, "milestone_id": nil, "factory": nil, "job": nil, "record_id": nil} + switch { + case workstreamMarkdownLive.MatchString(line): + match := workstreamMarkdownLive.FindStringSubmatch(line) + attrs["kind"] = match[1] + if match[3] != "" { + attrs["milestone_id"] = match[3] + } + case workstreamMarkdownJob.MatchString(line): + match := workstreamMarkdownJob.FindStringSubmatch(line) + n, err := strconv.Atoi(match[2]) + if err != nil { + return nil + } + attrs["kind"], attrs["factory"], attrs["job"] = "job", match[1], n + case workstreamMarkdownDecision.MatchString(line): + match := workstreamMarkdownDecision.FindStringSubmatch(line) + attrs["kind"], attrs["record_id"] = "decision", match[1] + default: + return nil + } + return map[string]any{"type": "liveBlock", "attrs": attrs} +} + +func workstreamMarkdownInline(text string) []map[string]any { + index := workstreamMarkdownInlineIndex{ + code: workstreamMarkdownCodeRuns(text), + parens: workstreamMarkdownParens(text), + linkClose: strings.Index(text, "]("), + } + out := make([]map[string]any, 0) + var plain strings.Builder + flush := func() { + if plain.Len() > 0 { + out = append(out, map[string]any{"type": "text", "text": plain.String()}) + plain.Reset() + } + } + for i := 0; i < len(text); { + if text[i] == '\\' && i+1 < len(text) { + plain.WriteByte(text[i+1]) + i += 2 + continue + } + if strings.HasPrefix(text[i:], "**") || strings.HasPrefix(text[i:], "__") { + i += 2 + continue + } + nodes, used := index.special(text, i) + if used > 0 { + if nodes == nil { + plain.WriteString(text[i : i+used]) + } else { + flush() + out = append(out, nodes...) + } + i += used + continue + } + plain.WriteByte(text[i]) + i++ + } + flush() + return out +} + +type workstreamMarkdownRun struct { + end int + close int +} + +type workstreamMarkdownInlineIndex struct { + code map[int]workstreamMarkdownRun + parens map[int]int + linkClose int +} + +// Indexing runs once lets an unmatched opener consume its entire run as text. +func workstreamMarkdownCodeRuns(text string) map[int]workstreamMarkdownRun { + out := make(map[int]workstreamMarkdownRun) + next := make(map[int]int) + for i := len(text) - 1; i >= 0; { + if text[i] != '`' { + i-- + continue + } + end := i + 1 + for i >= 0 && text[i] == '`' { + i-- + } + start := i + 1 + out[start] = workstreamMarkdownRun{end: end, close: workstreamMarkdownNextRun(next, end-start)} + if end-start > 1 { + // An escaped first backtick leaves the suffix available as an opener. + out[start+1] = workstreamMarkdownRun{end: end, close: workstreamMarkdownNextRun(next, end-start-1)} + } + next[end-start] = start + } + return out +} + +func workstreamMarkdownNextRun(next map[int]int, width int) int { + if at, ok := next[width]; ok { + return at + } + return -1 +} + +// Balanced destinations are indexed once, including failures, for every link. +func workstreamMarkdownParens(text string) map[int]int { + out := make(map[int]int) + var stack []int + for i := 0; i < len(text); i++ { + if text[i] == '\\' && i+1 < len(text) { + i++ + continue + } + switch text[i] { + case '(': + stack = append(stack, i) + case ')': + if len(stack) > 0 { + last := len(stack) - 1 + out[stack[last]] = i + stack = stack[:last] + } + } + } + return out +} + +func (index *workstreamMarkdownInlineIndex) special(text string, at int) ([]map[string]any, int) { + if text[at] == '`' { + run := index.code[at] + if run.close < 0 { + return nil, run.end - at + } + body := text[run.end:run.close] + if strings.HasPrefix(body, " ") && strings.HasSuffix(body, " ") && strings.TrimSpace(body) != "" { + body = body[1 : len(body)-1] + } + width := run.end - at + return []map[string]any{{"type": "text", "text": body, "marks": []map[string]any{{"type": "code"}}}}, run.close + width - at + } + if text[at] == '[' { + return index.link(text, at) + } + return nil, 0 +} + +func (index *workstreamMarkdownInlineIndex) link(text string, at int) ([]map[string]any, int) { + if index.linkClose >= 0 && index.linkClose < at { + index.linkClose = strings.Index(text[at:], "](") + if index.linkClose >= 0 { + index.linkClose += at + } + } + close := index.linkClose + if close < 0 { + return nil, 0 + } + end, ok := index.parens[close+1] + if !ok { + return nil, 0 + } + href := workstreamMarkdownUnescape(text[close+2 : end]) + // The first ]( bounds this label, so parsing it cannot recurse into links. + nodes := workstreamMarkdownInline(text[at+1 : close]) + for _, node := range nodes { + marks, _ := node["marks"].([]map[string]any) + node["marks"] = append(marks, map[string]any{"type": "link", "attrs": map[string]any{"href": href}}) + } + return nodes, end - at + 1 +} + +func workstreamMarkdownUnescape(text string) string { + var out strings.Builder + for i := 0; i < len(text); i++ { + if text[i] == '\\' && i+1 < len(text) { + i++ + } + out.WriteByte(text[i]) + } + return out.String() +} diff --git a/internal/workstream/pagedoc/frommarkdown_test.go b/internal/workstream/pagedoc/frommarkdown_test.go new file mode 100644 index 0000000000..49a46a2466 --- /dev/null +++ b/internal/workstream/pagedoc/frommarkdown_test.go @@ -0,0 +1,136 @@ +package pagedoc + +import ( + "encoding/json" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/guard" +) + +func TestWorkstreamFromMarkdownContractRoundTrip(t *testing.T) { + md, err := os.ReadFile("testdata/example.md") + if err != nil { + t.Fatal(err) + } + workstreamMarkdownRoundTrip(t, string(md), string(md)) + for _, md := range []string{ + "# One\n\n## Two\n\n### Three\n", + "- Parent\n - Child\n- Next\n", + "3. Parent\n 4. Child\n4. Next\n", + "Keep \\*literal\\* and `a_b` and [a `code`](https://example.com).\n", + "````go\na := \"```\"\n````\n", + "[live: jobs]\n\n[live: waves milestone=m2]\n\n[live: job api#0]\n\n[live: decision r2]\n", + } { + workstreamMarkdownRoundTrip(t, md, md) + } +} + +func TestWorkstreamFromMarkdownDegradesUnsupportedMarkup(t *testing.T) { + workstreamMarkdownRoundTrip(t, "See [guide](https://example.com/a(b)) and [more](https://example.com/a\\(b\\)).\n", "See [guide](https://example.com/a%28b%29) and [more](https://example.com/a%28b%29).\n") + workstreamMarkdownRoundTrip(t, "#### Deep\n\n> **Plain** __words__\ncontinued\n\n---\n\n+ First\n* Second\n\n7) Ordered\n\n~~~js\ncall()\n~~~\n", "### Deep\n\nPlain words continued\n\n- First\n- Second\n\n7. Ordered\n\n```js\ncall()\n```\n") + for _, md := range []string{"", "\n", "```\n", "[unfinished", "`unfinished", "text\\", string([]byte{255})} { + if !json.Valid(FromMarkdown(md)) { + t.Fatalf("invalid doc for %q", md) + } + } +} + +func TestWorkstreamFromMarkdownLiveAttributes(t *testing.T) { + var doc map[string]any + if err := json.Unmarshal(FromMarkdown("[live: jobs milestone=m1]"), &doc); err != nil { + t.Fatal(err) + } + node := doc["content"].([]any)[0].(map[string]any) + want := map[string]any{"kind": "jobs", "milestone_id": "m1", "factory": nil, "job": nil, "record_id": nil} + if node["type"] != "liveBlock" || !reflect.DeepEqual(node["attrs"], want) { + t.Fatalf("live block: %+v", node) + } +} + +func workstreamMarkdownRoundTrip(t *testing.T, input, want string) { + t.Helper() + got, err := Markdown(FromMarkdown(input)) + if err != nil || got != want { + t.Fatalf("round trip %q:\ngot %q\nwant %q\nerror %v", input, got, want, err) + } +} + +func TestWorkstreamFromMarkdownListItemsStartWithParagraphs(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"- \n", "-\n"}, + {"- # Heading\n", "- # Heading\n"}, + {"- [live: jobs]\n", "- [live: jobs]\n"}, + {"- ```go\n call()\n ```\n", "- ```go\n call()\n ```\n"}, + {"- - Nested\n", "- - Nested\n"}, + {"- Parent\n - # Nested heading\n - \n", "- Parent\n - # Nested heading\n -\n"}, + } + for _, tc := range cases { + var doc map[string]any + if err := json.Unmarshal(FromMarkdown(tc.input), &doc); err != nil { + t.Fatal(err) + } + if count := workstreamCheckListParagraphs(t, doc); count == 0 { + t.Fatalf("no list items in %q", tc.input) + } + workstreamMarkdownRoundTrip(t, tc.input, tc.want) + } +} + +func workstreamCheckListParagraphs(t *testing.T, node map[string]any) int { + t.Helper() + count := 0 + kids := children(node) + if node["type"] == "listItem" { + count++ + if len(kids) == 0 || kids[0]["type"] != "paragraph" { + t.Fatalf("list item must begin with a paragraph: %+v", node) + } + } + for _, child := range kids { + count += workstreamCheckListParagraphs(t, child) + } + return count +} + +func TestWorkstreamFromMarkdownUnmatchedDelimitersAreBounded(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"backticks", "Notes " + strings.Repeat("`", 200000), "Notes " + strings.Repeat("\\`", 200000) + "\n"}, + {"brackets", strings.Repeat("[", 200000), strings.Repeat("\\[", 200000) + "\n"}, + {"unfinished link", strings.Repeat("[", 200000) + "](unfinished", strings.Repeat("\\[", 200000) + "\\](unfinished\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + finished := make(chan json.RawMessage, 1) + guard.Go("workstream/markdown-delimiters-test", func() { finished <- FromMarkdown(tc.input) }) + select { + case doc := <-finished: + if !json.Valid(doc) { + t.Fatal("conversion returned invalid editor JSON") + } + got, err := Markdown(doc) + if err != nil || got != tc.want { + t.Fatalf("conversion lost delimiter text: got %d bytes, want %d; %v", len(got), len(tc.want), err) + } + case <-time.After(5 * time.Second): + t.Fatal("malformed inline input exceeded the conversion bound") + } + }) + } +} + +func TestWorkstreamFromMarkdownCodeRequiresMatchingRunLengths(t *testing.T) { + workstreamMarkdownRoundTrip(t, "Notes ``a ` b`` and `c`.\n", "Notes ``a ` b`` and `c`.\n") + workstreamMarkdownRoundTrip(t, "Notes ``unclosed ` tail.\n", "Notes \\`\\`unclosed \\` tail.\n") + workstreamMarkdownRoundTrip(t, "Notes \\``code`.\n", "Notes \\``code`.\n") +} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go index c856f975ef..4611a0c599 100644 --- a/internal/workstream/pages.go +++ b/internal/workstream/pages.go @@ -240,6 +240,9 @@ func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawM } return ErrNotFound } + if err := workstreamPageWriteAllowed(ctx, tx, p, by); err != nil { + return err + } if p.Version != base { return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} } @@ -365,32 +368,44 @@ func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, la } return true, nil } -func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, error) { +func workstreamPreparePageCreate(p PageCreate) (PageCreate, any, error) { if err := validTitle(p.Title); err != nil { - return Page{}, Change{}, err + return p, nil, err } owner, err := actor(p.Owner) if err != nil { - return Page{}, Change{}, err + return p, nil, err } by, err := actor(p.By) if err != nil { - return Page{}, Change{}, err + return p, nil, err + } + if by == "agent" && owner != "agent" { + return p, nil, invalid("the agent creates only its own pages") } source, err := sourceJSON(p.From) if err != nil { - return Page{}, Change{}, err + return p, nil, err } if p.Doc == nil { p.Doc = json.RawMessage(emptyDoc) } if err := checkDoc(p.Doc); err != nil { + return p, nil, err + } + p.Owner, p.By = owner, by + return p, source, nil +} + +func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, error) { + p, source, err := workstreamPreparePageCreate(p) + if 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 { + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, p.By); err != nil { return err } id, err := s.newID() @@ -400,14 +415,14 @@ func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, 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 { + 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, p.Owner, p.ReadByJobs, at, p.By, source, string(p.Doc)); err != nil { return err } page, _, err := livePageIn(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 { + if _, err := placePage(ctx, tx, page, p.ParentID, p.AfterID, p.AfterID == "", at, p.By, source); err != nil { return err } if err := saveVersion(ctx, tx, id); err != nil { diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go index 09eb222dc6..3b5b84c257 100644 --- a/internal/workstream/pages_test.go +++ b/internal/workstream/pages_test.go @@ -28,6 +28,10 @@ func TestPartAPageSaveHistoryAndRestore(t *testing.T) { if err != nil { t.Fatal(err) } + owner := "agent" + if _, _, err := s.UpdatePage(ctx, first.ID, PagePatch{Owner: &owner}); 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"}}) @@ -224,7 +228,7 @@ func TestWorkstreamPageGetSavedVersion(t *testing.T) { ctx := context.Background() w := createStream(t, s, "Version reads") firstDoc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]}`) - first, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "First title", Doc: firstDoc, + first, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "First title", Owner: "agent", Doc: firstDoc, Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: "first-source"}}}) if err != nil { t.Fatal(err) @@ -315,6 +319,11 @@ func TestWorkstreamDeletedPageTreeRestore(t *testing.T) { second := createWorkstreamPage(t, s, w.ID, root.ID, "Second") sibling := createWorkstreamPage(t, s, w.ID, parent.ID, "Sibling") doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Keep me"}]}]}`) + // The agent writes only its own pages, so the page is the agent's for this save. + agentOwner := "agent" + if _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{Owner: &agentOwner}); err != nil { + t.Fatal(err) + } if _, _, err := s.SavePage(ctx, root.ID, 1, doc, Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}); err != nil { t.Fatal(err) } diff --git a/internal/workstream/records.go b/internal/workstream/records.go index 6b47ae28eb..40262f3e96 100644 --- a/internal/workstream/records.go +++ b/internal/workstream/records.go @@ -80,6 +80,9 @@ func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Cha if err := recordText(p.Kind, p.Text); err != nil { return Record{}, Change{}, err } + // Only an explicitly attributed agent write is refused on a done workstream; + // the wire's unattributed proposal keeps the behaviour it always had. + asked := p.By if p.By == "" { if p.Kept { p.By = "you" @@ -98,7 +101,7 @@ func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Cha 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 { + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, asked); err != nil { return err } id, err := s.newID() diff --git a/internal/workstream/workstream_agent.go b/internal/workstream/workstream_agent.go new file mode 100644 index 0000000000..395c02d8c0 --- /dev/null +++ b/internal/workstream/workstream_agent.go @@ -0,0 +1,57 @@ +package workstream + +import ( + "context" + "database/sql" + "errors" +) + +var ErrNotAgentPage = errors.New("the agent writes only its own pages") + +var ErrDone = errors.New("the workstream is done") + +// workstreamWriteAllowed checks done in the transaction that owns the write. +func workstreamWriteAllowed(ctx context.Context, tx *sql.Tx, id, by string) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if by == "agent" && !w.DoneAt.IsZero() { + return ErrDone + } + return nil +} + +func workstreamPageWriteAllowed(ctx context.Context, tx *sql.Tx, p Page, by string) error { + if by != "agent" { + return nil + } + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, by); err != nil { + return err + } + if p.Owner != "agent" { + return ErrNotAgentPage + } + return nil +} + +// ByAgent resolves the owner at call time because its conversation opens first. +func (s *Store) ByAgent(ctx context.Context, agentID string) (Workstream, error) { + var out Workstream + if agentID == "" { + return out, ErrNotFound + } + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return ErrNotFound + } + var id string + if err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE agent_id=?", agentID).Scan(&id); err != nil { + return found(err) + } + var err error + out, err = getIn(ctx, tx, id) + return err + }) + return out, err +} diff --git a/internal/workstream/workstream_agent_done_test.go b/internal/workstream/workstream_agent_done_test.go new file mode 100644 index 0000000000..c7e3f44300 --- /dev/null +++ b/internal/workstream/workstream_agent_done_test.go @@ -0,0 +1,155 @@ +package workstream + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" +) + +type workstreamWriteSnapshot struct { + stream Workstream + records []Record + pages []Page + history map[string][]PageVersion +} + +func workstreamSnapshotWrites(t *testing.T, s *Store, id string) workstreamWriteSnapshot { + t.Helper() + ctx := context.Background() + w, err := s.Get(ctx, id) + if err != nil { + t.Fatal(err) + } + records, err := s.Records(ctx, id, "", "") + if err != nil { + t.Fatal(err) + } + nodes, err := s.Pages(ctx, id) + if err != nil { + t.Fatal(err) + } + out := workstreamWriteSnapshot{stream: w, records: records, history: map[string][]PageVersion{}} + for _, node := range nodes { + p, err := s.Page(ctx, node.ID, false) + if err != nil { + t.Fatal(err) + } + out.pages = append(out.pages, p) + history, err := s.PageHistory(ctx, node.ID, 50) + if err != nil { + t.Fatal(err) + } + out.history[node.ID] = history + } + return out +} + +func TestWorkstreamDoneRefusesAgentWritesInsideTheirTransactions(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Done writes") + p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes", Owner: "agent"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { + t.Fatal(err) + } + before := workstreamSnapshotWrites(t, s, w.ID) + *now = now.Add(time.Hour) + who := Attribution{By: "agent"} + writes := []struct { + name string + call func() (Change, error) + }{ + {"record", func() (Change, error) { + _, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Late proposal", By: "agent"}) + return c, err + }}, + {"create", func() (Change, error) { + _, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Late page", Owner: "agent", Attribution: who}) + return c, err + }}, + {"save", func() (Change, error) { + _, c, err := s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), who) + return c, err + }}, + {"restore", func() (Change, error) { + _, c, err := s.RestorePage(ctx, p.ID, p.Version, p.Version, who) + return c, err + }}, + } + for _, write := range writes { + t.Run(write.name, func(t *testing.T) { + c, err := write.call() + if !errors.Is(err, ErrDone) || !reflect.DeepEqual(c, Change{}) { + t.Fatalf("done write: %+v %v", c, err) + } + if after := workstreamSnapshotWrites(t, s, w.ID); !reflect.DeepEqual(before, after) { + t.Fatal("a refused write changed records, pages, history or the workstream") + } + }) + } +} + +func TestWorkstreamDoneStillAllowsThePersonsPageWrites(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Person pages") + if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + p, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person notes"}) + if err != nil || c.ID != w.ID || p.Owner != "you" || p.UpdatedBy != "you" { + t.Fatalf("person create: %+v %+v %v", p, c, err) + } + p, c, err = s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), Attribution{}) + if err != nil || c.ID != w.ID || p.Version != 2 || p.UpdatedBy != "you" { + t.Fatalf("person save: %+v %+v %v", p, c, err) + } + history, err := s.PageHistory(ctx, p.ID, 50) + if err != nil || len(history) != 2 || history[0].SavedBy != "you" { + t.Fatalf("person history: %+v %v", history, err) + } +} + +func TestWorkstreamDoneStillAllowsThePersonsRecords(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Person records") + if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + r, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Person decision", Kept: true}) + if err != nil || c.ID != w.ID || r.By != "you" || r.State != "kept" { + t.Fatalf("person record: %+v %+v %v", r, c, err) + } + after, err := s.Get(ctx, w.ID) + if err != nil || !after.UpdatedAt.Equal(*now) || after.DoneAt.IsZero() { + t.Fatalf("person write timestamp: %+v %v", after, err) + } +} + +// The wire's unattributed proposal still lands on a done workstream as it always +// has; only an explicitly attributed agent write is refused there. +func TestWorkstreamDoneStillAllowsTheUnattributedProposal(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Unattributed records") + if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + r, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Follow-up"}) + if err != nil || c.ID != w.ID || r.By != "agent" || r.State != "proposed" { + t.Fatalf("unattributed proposal: %+v %+v %v", r, c, err) + } + if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Agent", By: "agent"}); !errors.Is(err, ErrDone) { + t.Fatalf("explicit agent proposal on a done workstream: %v", err) + } +} diff --git a/internal/workstream/workstream_agent_test.go b/internal/workstream/workstream_agent_test.go new file mode 100644 index 0000000000..a657498f83 --- /dev/null +++ b/internal/workstream/workstream_agent_test.go @@ -0,0 +1,106 @@ +package workstream + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" +) + +// Contract (a) resolves a conversation to its workstream without creating one. +func TestWorkstreamByAgentResolvesOnlyItsOwner(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + for _, id := range []string{"", "missing"} { + if _, err := s.ByAgent(ctx, id); !errors.Is(err, ErrNotFound) { + t.Fatalf("unheld agent %q: %v", id, err) + } + } + w := createStream(t, s, "Agent") + got, err := s.ByAgent(ctx, w.AgentID) + if err != nil || !reflect.DeepEqual(got, w) { + t.Fatalf("owner: %+v %v", got, err) + } +} + +// Contract (d) includes ownership changing after the version was read. +func TestWorkstreamAgentCannotSaveThePersonsPage(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Ownership") + for _, race := range []bool{false, true} { + owner := "you" + if race { + owner = "agent" + } + p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes", Owner: owner}) + if err != nil { + t.Fatal(err) + } + base := p.Version + if race { + you := "you" + if _, _, err := s.UpdatePage(ctx, p.ID, PagePatch{Owner: &you}); err != nil { + t.Fatal(err) + } + } + before, _ := s.Page(ctx, p.ID, false) + history, _ := s.PageHistory(ctx, p.ID, 50) + stream, _ := s.Get(ctx, w.ID) + _, change, err := s.SavePage(ctx, p.ID, base, json.RawMessage(`{"type":"doc","content":[]}`), Attribution{By: "agent"}) + if !errors.Is(err, ErrNotAgentPage) || !reflect.DeepEqual(change, Change{}) { + t.Fatalf("race=%v: %+v %v", race, change, err) + } + after, _ := s.Page(ctx, p.ID, false) + afterHistory, _ := s.PageHistory(ctx, p.ID, 50) + afterStream, _ := s.Get(ctx, w.ID) + if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(history, afterHistory) || !reflect.DeepEqual(stream, afterStream) { + t.Fatalf("race=%v changed a refused page", race) + } + } + _, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Wrong owner", Owner: "you", Attribution: Attribution{By: "agent"}}) + if !errors.Is(err, ErrInvalid) || c.ID != "" { + t.Fatalf("agent creation with person owner: %+v %v", c, err) + } +} + +// Contract (e) keeps the same source and clock in the page and its history. +func TestWorkstreamAgentPagesCarryAttributionAndTime(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Attribution") + source := &Source{Kind: "chat", ID: w.AgentID, Label: "Plan discussion"} + who := Attribution{By: "agent", From: source} + p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent", Attribution: who}) + if err != nil { + t.Fatal(err) + } + workstreamCheckAgentPage(t, p, *now, source) + *now = now.Add(time.Minute) + p, _, err = s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), who) + if err != nil { + t.Fatal(err) + } + workstreamCheckAgentPage(t, p, *now, source) + history, err := s.PageHistory(ctx, p.ID, 50) + if err != nil || len(history) != 2 { + t.Fatalf("history: %+v %v", history, err) + } + for _, v := range history { + if v.SavedBy != "agent" || !reflect.DeepEqual(v.From, source) { + t.Fatalf("history attribution: %+v", v) + } + } + if !history[0].SavedAt.Equal(*now) { + t.Fatalf("history clock: %+v", history[0]) + } +} + +func workstreamCheckAgentPage(t *testing.T, p Page, now time.Time, source *Source) { + t.Helper() + if p.Owner != "agent" || p.ReadByJobs || p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(now) || !reflect.DeepEqual(p.UpdatedFrom, source) { + t.Fatalf("agent mark: %+v", p) + } +}