From 0ac4fa62c4ebd7a14f76fff6b57614a4e62c2261 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 19:29:21 -0500 Subject: [PATCH 01/20] ci: gate pull requests into and pushes to workstreams/main The Workstreams feature lands on workstreams/main in work-package pull requests before it goes to app/v3. Listing the line here gives each of those pull requests the light gate and its touched packages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c20d302765..4f53233663 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,12 +35,14 @@ on: branches: - dev - santos/dev2 + - workstreams/main # Also on the push, because a merge of two individually-green branches can # still be red, and the merge is exactly the moment nobody is watching. push: branches: - dev - santos/dev2 + - workstreams/main workflow_dispatch: permissions: From c76748305b00be01af7af305867f5ed84af677dc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 20:23:48 -0500 Subject: [PATCH 02/20] app: workstream wire contract v0 (types, handlers, not-yet stubs) (#2263) The Workstreams plan, WP-0: Go types for the 25 workstream, page, record and goahead methods and the workstream.changed event, registered through typed() and delegating to an optional Workstreamer; appBackend answers every one unknown_method "workstreams: not implemented yet" and does not announce the workstreams feature until the store lands (WP-1). Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 108 ++++ cmd/codeaf/appworkstreams_test.go | 142 +++++ .../2263-app-workstreams-contract.md | 10 + docs/design/app-v3/PROTOCOL.md | 56 ++ internal/appwire/server.go | 1 + internal/appwire/workstreams.go | 556 ++++++++++++++++++ internal/appwire/workstreams_test.go | 246 ++++++++ 7 files changed, 1119 insertions(+) create mode 100644 cmd/codeaf/appworkstreams.go create mode 100644 cmd/codeaf/appworkstreams_test.go create mode 100644 docs/changes/unreleased/2263-app-workstreams-contract.md create mode 100644 internal/appwire/workstreams.go create mode 100644 internal/appwire/workstreams_test.go diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go new file mode 100644 index 0000000000..6287c13030 --- /dev/null +++ b/cmd/codeaf/appworkstreams.go @@ -0,0 +1,108 @@ +package main + +import "github.com/Agent-Field/codeaf/internal/appwire" + +var _ appwire.Workstreamer = (*appBackend)(nil) + +// WP-1 makes this true when the store lands. +func (b *appBackend) ServesWorkstreams() bool { return false } + +func (b *appBackend) WorkstreamList(appwire.WorkstreamListParams) (appwire.WorkstreamListResult, error) { + return appwire.WorkstreamListResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamGet(appwire.WorkstreamGetParams) (appwire.WorkstreamGetResult, error) { + return appwire.WorkstreamGetResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamCreate(appwire.WorkstreamCreateParams) (appwire.WorkstreamCreateResult, error) { + return appwire.WorkstreamCreateResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamUpdate(appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { + return appwire.WorkstreamUpdateResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams) (appwire.WorkstreamMarkDoneResult, error) { + return appwire.WorkstreamMarkDoneResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamReopen(appwire.WorkstreamReopenParams) (appwire.WorkstreamReopenResult, error) { + return appwire.WorkstreamReopenResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPlanGet(appwire.WorkstreamPlanGetParams) (appwire.WorkstreamPlanGetResult, error) { + return appwire.WorkstreamPlanGetResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPlanSet(appwire.WorkstreamPlanSetParams) (appwire.WorkstreamPlanSetResult, error) { + return appwire.WorkstreamPlanSetResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageList(appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { + return appwire.WorkstreamPageListResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageGet(appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { + return appwire.WorkstreamPageGetResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageSave(appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { + return appwire.WorkstreamPageSaveResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageCreate(appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { + return appwire.WorkstreamPageCreateResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams) (appwire.WorkstreamPageUpdateResult, error) { + return appwire.WorkstreamPageUpdateResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageMove(appwire.WorkstreamPageMoveParams) (appwire.WorkstreamPageMoveResult, error) { + return appwire.WorkstreamPageMoveResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams) (appwire.WorkstreamPageDeleteResult, error) { + return appwire.WorkstreamPageDeleteResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams) (appwire.WorkstreamPageHistoryResult, error) { + return appwire.WorkstreamPageHistoryResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) { + return appwire.WorkstreamPageRestoreResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamRecordList(appwire.WorkstreamRecordListParams) (appwire.WorkstreamRecordListResult, error) { + return appwire.WorkstreamRecordListResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams) (appwire.WorkstreamRecordProposeResult, error) { + return appwire.WorkstreamRecordProposeResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams) (appwire.WorkstreamRecordKeepResult, error) { + return appwire.WorkstreamRecordKeepResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams) (appwire.WorkstreamRecordDropResult, error) { + return appwire.WorkstreamRecordDropResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams) (appwire.WorkstreamRecordSupersedeResult, error) { + return appwire.WorkstreamRecordSupersedeResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams) (appwire.WorkstreamGoAheadListResult, error) { + return appwire.WorkstreamGoAheadListResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams) (appwire.WorkstreamGoAheadGrantResult, error) { + return appwire.WorkstreamGoAheadGrantResult{}, appwire.ErrWorkstreamsNotYet +} + +func (b *appBackend) WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams) (appwire.WorkstreamGoAheadRevokeResult, error) { + return appwire.WorkstreamGoAheadRevokeResult{}, appwire.ErrWorkstreamsNotYet +} diff --git a/cmd/codeaf/appworkstreams_test.go b/cmd/codeaf/appworkstreams_test.go new file mode 100644 index 0000000000..94da8f9508 --- /dev/null +++ b/cmd/codeaf/appworkstreams_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// V4: the real backend implements every contract stub without touching its fields. +func TestAppWorkstreamsAreNotYetServed(t *testing.T) { + var backend appwire.Workstreamer = &appBackend{} + if backend.ServesWorkstreams() { + t.Fatal("the stub backend claims workstreams") + } + calls := []struct { + name string + call func() error + }{ + {"workstream.list", func() error { + _, err := backend.WorkstreamList(appwire.WorkstreamListParams{}) + return err + }}, + {"workstream.get", func() error { + _, err := backend.WorkstreamGet(appwire.WorkstreamGetParams{}) + return err + }}, + {"workstream.create", func() error { + _, err := backend.WorkstreamCreate(appwire.WorkstreamCreateParams{}) + return err + }}, + {"workstream.update", func() error { + _, err := backend.WorkstreamUpdate(appwire.WorkstreamUpdateParams{}) + return err + }}, + {"workstream.mark_done", func() error { + _, err := backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{}) + return err + }}, + {"workstream.reopen", func() error { + _, err := backend.WorkstreamReopen(appwire.WorkstreamReopenParams{}) + return err + }}, + {"workstream.plan_get", func() error { + _, err := backend.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{}) + return err + }}, + {"workstream.plan_set", func() error { + _, err := backend.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{}) + return err + }}, + {"page.list", func() error { + _, err := backend.WorkstreamPageList(appwire.WorkstreamPageListParams{}) + return err + }}, + {"page.get", func() error { + _, err := backend.WorkstreamPageGet(appwire.WorkstreamPageGetParams{}) + return err + }}, + {"page.save", func() error { + _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{}) + return err + }}, + {"page.create", func() error { + _, err := backend.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{}) + return err + }}, + {"page.update", func() error { + _, err := backend.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{}) + return err + }}, + {"page.move", func() error { + _, err := backend.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{}) + return err + }}, + {"page.delete", func() error { + _, err := backend.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{}) + return err + }}, + {"page.history", func() error { + _, err := backend.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{}) + return err + }}, + {"page.restore", func() error { + _, err := backend.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{}) + return err + }}, + {"record.list", func() error { + _, err := backend.WorkstreamRecordList(appwire.WorkstreamRecordListParams{}) + return err + }}, + {"record.propose", func() error { + _, err := backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{}) + return err + }}, + {"record.keep", func() error { + _, err := backend.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{}) + return err + }}, + {"record.drop", func() error { + _, err := backend.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{}) + return err + }}, + {"record.supersede", func() error { + _, err := backend.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{}) + return err + }}, + {"goahead.list", func() error { + _, err := backend.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{}) + return err + }}, + {"goahead.grant", func() error { + _, err := backend.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{}) + return err + }}, + {"goahead.revoke", func() error { + _, err := backend.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{}) + return err + }}, + } + if len(calls) != len(appwire.WorkstreamMethods) { + t.Fatal("stub test does not cover every contract method") + } + for i, call := range calls { + if call.name != appwire.WorkstreamMethods[i] { + t.Fatalf("stub test index differs at %s", call.name) + } + t.Run(call.name, func(t *testing.T) { + err := call.call() + var wireErr *appwire.Error + if !errors.As(err, &wireErr) { + t.Fatal("stub must return an appwire.Error") + } + if wireErr.Code != appwire.CodeUnknownMethod { + t.Errorf("code = %s, want unknown_method", wireErr.Code) + } + if wireErr.Message != "workstreams: not implemented yet" { + t.Errorf("message = %s", wireErr.Message) + } + }) + } +} diff --git a/docs/changes/unreleased/2263-app-workstreams-contract.md b/docs/changes/unreleased/2263-app-workstreams-contract.md new file mode 100644 index 0000000000..7a31edbb2c --- /dev/null +++ b/docs/changes/unreleased/2263-app-workstreams-contract.md @@ -0,0 +1,10 @@ +--- +kind: added +title: codeaf app registers the workstream methods (contract only; they answer not implemented yet) +pr: 2263 +surface: [app, docs] +invalidates: [] +--- + +The 25 workstream, page, record and go-ahead methods and `workstream.changed` exist on the wire with their types, answering `unknown_method` with `workstreams: not implemented yet`. +Hello carries no `workstreams` feature until the store lands. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index cfa498a0e9..ebaa8eb44c 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -810,6 +810,14 @@ continue to apply; no method, parameter or reply field is added. | `files.read` | `{agent, limit, offset, path}` | `FileReadResult` | | `files.stat` | `{agent, paths}` | `FilesResult` | +## goahead methods + +| method | params | result | +|---|---|---| +| `goahead.grant` | `in.WorkstreamGoAheadGrantParams` | `WorkstreamGoAheadGrantResult` | +| `goahead.list` | `in.WorkstreamGoAheadListParams` | `WorkstreamGoAheadListResult` | +| `goahead.revoke` | `in.WorkstreamGoAheadRevokeParams` | `WorkstreamGoAheadRevokeResult` | + ## harness methods | method | params | result | @@ -942,6 +950,20 @@ $0.10 goes out as `0.1`. | `orders.runs` | `{id, limit}` | `RunsResult` | | `orders.stop` | `in.orderAt` | `OrderResult` | +## page methods + +| method | params | result | +|---|---|---| +| `page.create` | `in.WorkstreamPageCreateParams` | `WorkstreamPageCreateResult` | +| `page.delete` | `in.WorkstreamPageDeleteParams` | `WorkstreamPageDeleteResult` | +| `page.get` | `in.WorkstreamPageGetParams` | `WorkstreamPageGetResult` | +| `page.history` | `in.WorkstreamPageHistoryParams` | `WorkstreamPageHistoryResult` | +| `page.list` | `in.WorkstreamPageListParams` | `WorkstreamPageListResult` | +| `page.move` | `in.WorkstreamPageMoveParams` | `WorkstreamPageMoveResult` | +| `page.restore` | `in.WorkstreamPageRestoreParams` | `WorkstreamPageRestoreResult` | +| `page.save` | `in.WorkstreamPageSaveParams` | `WorkstreamPageSaveResult` | +| `page.update` | `in.WorkstreamPageUpdateParams` | `WorkstreamPageUpdateResult` | + ## ping methods | method | params | result | @@ -1003,6 +1025,16 @@ $0.10 goes out as `0.1`. | `quick.promote` | `in.IDParams` | `taskResult` | | `quick.undo` | `in.IDParams` | `Empty` | +## record methods + +| method | params | result | +|---|---|---| +| `record.drop` | `in.WorkstreamRecordDropParams` | `WorkstreamRecordDropResult` | +| `record.keep` | `in.WorkstreamRecordKeepParams` | `WorkstreamRecordKeepResult` | +| `record.list` | `in.WorkstreamRecordListParams` | `WorkstreamRecordListResult` | +| `record.propose` | `in.WorkstreamRecordProposeParams` | `WorkstreamRecordProposeResult` | +| `record.supersede` | `in.WorkstreamRecordSupersedeParams` | `WorkstreamRecordSupersedeResult` | + ## render methods | method | params | result | @@ -1207,6 +1239,19 @@ role pins and checked as the other pins are. |---|---|---| | `work.graph` | `in.GraphRequest` | `GraphResult` | +## workstream methods + +| method | params | result | +|---|---|---| +| `workstream.create` | `in.WorkstreamCreateParams` | `WorkstreamCreateResult` | +| `workstream.get` | `in.WorkstreamGetParams` | `WorkstreamGetResult` | +| `workstream.list` | `in.WorkstreamListParams` | `WorkstreamListResult` | +| `workstream.mark_done` | `in.WorkstreamMarkDoneParams` | `WorkstreamMarkDoneResult` | +| `workstream.plan_get` | `in.WorkstreamPlanGetParams` | `WorkstreamPlanGetResult` | +| `workstream.plan_set` | `in.WorkstreamPlanSetParams` | `WorkstreamPlanSetResult` | +| `workstream.reopen` | `in.WorkstreamReopenParams` | `WorkstreamReopenResult` | +| `workstream.update` | `in.WorkstreamUpdateParams` | `WorkstreamUpdateResult` | + ## Events | event | data | @@ -1263,6 +1308,7 @@ role pins and checked as the other pins are. | `turn.thinking` | `TurnThinking` | | `turn.thinking_done` | `TurnThinkingDone` | | `turn.tool` | `TurnTool` | +| `workstream.changed` | `WorkstreamChanged` | ## Slash catalogue commands and forms @@ -1445,6 +1491,16 @@ the state file), so on upgrade every agent starts read, and a new agent starts read because it has said nothing. Engines that serve it announce `agents.mark_seen` in `hello.features`. +## Workstreams (v0 proposal) + +The Workstreams plan, WP-0, registers 25 methods and `workstream.changed` with their wire types. +The `workstreams` feature is announced only by an engine that serves them. +This build does not announce it and answers `unknown_method` with +`workstreams: not implemented yet`; the store lands in WP-1. +A workstream is a collection, sharing its id, naming rules and membership methods. +Its jobs use `factory_job` refs with `factory`; a job in another workstream is refused +as `in_other_workstream`. Versioned page and plan writes refuse stale bases with `version_conflict`. + ## Errands (`agents.errand`) An errand is one short, detached, metered model call with the app's own prompt diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 9be52b9c41..291c052e61 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -197,6 +197,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello.Contract = Contract hello = withFeature(hello, FeatureAgentEffortRungs) hello = withCollectionsFeature(hello, engine) + hello = withWorkstreamsFeature(hello, engine) hello = withMessageReadsFeature(hello, engine) hello = withWorkGraphFeature(hello, engine) hello = withSlashCatalogFeature(hello, engine) diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go new file mode 100644 index 0000000000..983ae6b33a --- /dev/null +++ b/internal/appwire/workstreams.go @@ -0,0 +1,556 @@ +// Workstreams v0 (the Workstreams plan, WP-0) is a proposal until reviewed. +// PROTOCOL.md, "workstream methods", is the wire index. +package appwire + +import ( + "encoding/json" + "reflect" + "time" +) + +const ( + FeatureWorkstreams = "workstreams" + EventWorkstreamChanged = "workstream.changed" + CodeInOtherWorkstream = "in_other_workstream" + CodeVersionConflict = "version_conflict" +) + +// ErrWorkstreamsNotYet keeps the registered contract unavailable until WP-1. +var ErrWorkstreamsNotYet = Errorf(CodeUnknownMethod, "workstreams: not implemented yet") + +// WorkstreamMethods follows the contract index order. +var WorkstreamMethods = []string{ + "workstream.list", + "workstream.get", + "workstream.create", + "workstream.update", + "workstream.mark_done", + "workstream.reopen", + "workstream.plan_get", + "workstream.plan_set", + "page.list", + "page.get", + "page.save", + "page.create", + "page.update", + "page.move", + "page.delete", + "page.history", + "page.restore", + "record.list", + "record.propose", + "record.keep", + "record.drop", + "record.supersede", + "goahead.list", + "goahead.grant", + "goahead.revoke", +} + +// Optional numbers and booleans use pointers so known zero and false survive. +// Optional times use omitzero; required lists must be initialized even when empty. +// Numeric counts, versions, positions, job numbers and waves are non-negative. +type WorkstreamWake struct { + Morning bool `json:"morning"` + RelatedIssues bool `json:"related_issues"` +} + +type WorkstreamRef struct { + Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,factory_job"` + ID string `json:"id"` + SessionID *string `json:"session_id,omitempty"` + Factory *string `json:"factory,omitempty"` +} + +type WorkstreamSource struct { + Kind string `json:"kind" enum:"chat,message,factory_job,page,url"` + ID string `json:"id"` + ChatID *string `json:"chat_id,omitempty"` + Factory *string `json:"factory,omitempty"` + Label *string `json:"label,omitempty"` +} + +type WorkstreamRow struct { + ID string `json:"id"` + Name string `json:"name"` + Stage string `json:"stage" enum:"shaping,building,done"` + Total int `json:"total"` + Merged *int `json:"merged,omitempty"` + NeedsYou *int `json:"needs_you,omitempty"` + SpendUSD *float64 `json:"spend_usd,omitempty"` + BudgetUSD *float64 `json:"budget_usd,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Workstream struct { + ID string `json:"id"` + Name string `json:"name"` + Why string `json:"why"` + Stage string `json:"stage" enum:"shaping,building,done"` + AgentID string `json:"agent_id"` + OriginChatID *string `json:"origin_chat_id,omitempty"` + Autonomy string `json:"autonomy" enum:"propose,small,off"` + Wake WorkstreamWake `json:"wake"` + BudgetUSD *float64 `json:"budget_usd,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt time.Time `json:"started_at,omitzero"` + DoneAt time.Time `json:"done_at,omitzero"` + HowItWent *string `json:"how_it_went,omitempty"` + Total int `json:"total"` + Merged *int `json:"merged,omitempty"` + NeedsYou *int `json:"needs_you,omitempty"` + SpendUSD *float64 `json:"spend_usd,omitempty"` +} + +type WorkstreamPlan struct { + WorkstreamID string `json:"workstream_id"` + Version int `json:"version"` + UpdatedAt time.Time `json:"updated_at,omitzero"` + Milestones []WorkstreamMilestone `json:"milestones"` + Waves []WorkstreamWave `json:"waves"` +} + +type WorkstreamMilestone struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type WorkstreamWave struct { + N int `json:"n"` + Title *string `json:"title,omitempty"` + MilestoneID *string `json:"milestone_id,omitempty"` + AutoStart bool `json:"auto_start"` + Items []WorkstreamPlanItem `json:"items"` +} + +type WorkstreamPlanItem struct { + Kind string `json:"kind" enum:"job,draft"` + Factory *string `json:"factory,omitempty"` + Job *int `json:"job,omitempty"` + DraftID *string `json:"draft_id,omitempty"` + Title *string `json:"title,omitempty"` + Repo *string `json:"repo,omitempty"` + Text *string `json:"text,omitempty"` +} + +type WorkstreamPageNode struct { + ID string `json:"id"` + WorkstreamID string `json:"workstream_id"` + ParentID *string `json:"parent_id,omitempty"` + Title string `json:"title"` + Owner string `json:"owner" enum:"you,agent"` + ReadByJobs bool `json:"read_by_jobs"` + Position int `json:"position"` + Version int `json:"version"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by" enum:"you,agent"` + UpdatedFrom *WorkstreamSource `json:"updated_from,omitempty"` +} + +type WorkstreamPage struct { + ID string `json:"id"` + WorkstreamID string `json:"workstream_id"` + ParentID *string `json:"parent_id,omitempty"` + Title string `json:"title"` + Owner string `json:"owner" enum:"you,agent"` + ReadByJobs bool `json:"read_by_jobs"` + Position int `json:"position"` + Version int `json:"version"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by" enum:"you,agent"` + UpdatedFrom *WorkstreamSource `json:"updated_from,omitempty"` + Doc json.RawMessage `json:"doc"` + Markdown *string `json:"markdown,omitempty"` +} + +type WorkstreamPageVersion struct { + Version int `json:"version"` + SavedAt time.Time `json:"saved_at"` + SavedBy string `json:"saved_by" enum:"you,agent"` + From *WorkstreamSource `json:"from,omitempty"` + Title string `json:"title"` + Bytes int `json:"bytes"` +} + +type WorkstreamRecord struct { + ID string `json:"id"` + WorkstreamID string `json:"workstream_id"` + Kind string `json:"kind" enum:"decision,question"` + Text string `json:"text"` + State string `json:"state" enum:"proposed,kept,dropped,superseded"` + By string `json:"by" enum:"you,agent"` + Source *WorkstreamSource `json:"source,omitempty"` + CreatedAt time.Time `json:"created_at"` + DecidedAt time.Time `json:"decided_at,omitzero"` + Supersedes *string `json:"supersedes,omitempty"` + SupersededBy *string `json:"superseded_by,omitempty"` +} + +type WorkstreamGoAhead struct { + ID string `json:"id"` + WorkstreamID string `json:"workstream_id"` + Scope string `json:"scope" enum:"job,wave,milestone"` + 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"` + EndsAt time.Time `json:"ends_at"` + State string `json:"state" enum:"active,ended"` + EndReason *string `json:"end_reason,omitempty" enum:"expired,revoked,workstream_done"` + SpentUSD *float64 `json:"spent_usd,omitempty"` + GrantedAt time.Time `json:"granted_at"` + EndedAt time.Time `json:"ended_at,omitzero"` +} + +type WorkstreamChanged struct { + ID string `json:"id"` + Parts []string `json:"parts,omitempty" enum:"workstream,plan,pages,records,goaheads,jobs"` +} + +type WorkstreamTaken struct { + Factory string `json:"factory"` + Job int `json:"job"` + WorkstreamID string `json:"workstream_id"` + WorkstreamName string `json:"workstream_name"` +} + +type WorkstreamVersionConflict struct { + ID string `json:"id"` + Version int `json:"version"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by" enum:"you,agent"` +} + +type WorkstreamPatch struct { + Name *string `json:"name,omitempty"` + Why *string `json:"why,omitempty"` + Autonomy *string `json:"autonomy,omitempty" enum:"propose,small,off"` + Wake *WorkstreamWake `json:"wake,omitempty"` + BudgetUSD *float64 `json:"budget_usd,omitempty"` +} + +type WorkstreamListParams struct{} + +type WorkstreamListResult struct { + Workstreams []WorkstreamRow `json:"workstreams"` +} + +type WorkstreamGetParams struct { + ID string `json:"id"` +} + +type WorkstreamGetResult struct { + Workstream Workstream `json:"workstream"` +} + +type WorkstreamCreateParams struct { + Name string `json:"name"` + Why *string `json:"why,omitempty"` + OriginChatID *string `json:"origin_chat_id,omitempty"` + Refs []WorkstreamRef `json:"refs,omitempty"` + Model *string `json:"model,omitempty"` + ClientID *string `json:"client_id,omitempty"` +} + +type WorkstreamCreateResult struct { + Workstream Workstream `json:"workstream"` +} + +type WorkstreamUpdateParams struct { + ID string `json:"id"` + Patch WorkstreamPatch `json:"patch"` +} + +type WorkstreamUpdateResult struct { + Workstream Workstream `json:"workstream"` +} + +type WorkstreamMarkDoneParams struct { + ID string `json:"id"` + HowItWent *string `json:"how_it_went,omitempty"` + ArchiveChats *bool `json:"archive_chats,omitempty"` +} + +type WorkstreamMarkDoneResult struct { + Workstream Workstream `json:"workstream"` +} + +type WorkstreamReopenParams struct { + ID string `json:"id"` +} + +type WorkstreamReopenResult struct { + Workstream Workstream `json:"workstream"` +} + +type WorkstreamPlanGetParams struct { + ID string `json:"id"` +} + +type WorkstreamPlanGetResult struct { + Plan WorkstreamPlan `json:"plan"` +} + +type WorkstreamPlanSetParams struct { + ID string `json:"id"` + BaseVersion int `json:"base_version"` + Milestones []WorkstreamMilestone `json:"milestones"` + Waves []WorkstreamWave `json:"waves"` +} + +type WorkstreamPlanSetResult struct { + Plan WorkstreamPlan `json:"plan"` +} + +type WorkstreamPageListParams struct { + WorkstreamID string `json:"workstream_id"` +} + +type WorkstreamPageListResult struct { + Pages []WorkstreamPageNode `json:"pages"` +} + +type WorkstreamPageGetParams struct { + ID string `json:"id"` + Markdown *bool `json:"markdown,omitempty"` +} + +type WorkstreamPageGetResult struct { + Page WorkstreamPage `json:"page"` +} + +type WorkstreamPageSaveParams struct { + ID string `json:"id"` + Doc json.RawMessage `json:"doc"` + BaseVersion int `json:"base_version"` +} + +type WorkstreamPageSaveResult struct { + Page WorkstreamPage `json:"page"` +} + +type WorkstreamPageCreateParams struct { + WorkstreamID string `json:"workstream_id"` + ParentID *string `json:"parent_id,omitempty"` + AfterID *string `json:"after_id,omitempty"` + Title string `json:"title"` + Owner *string `json:"owner,omitempty" enum:"you,agent"` + ReadByJobs *bool `json:"read_by_jobs,omitempty"` + Doc json.RawMessage `json:"doc,omitempty"` +} + +type WorkstreamPageCreateResult struct { + Page WorkstreamPage `json:"page"` +} + +type WorkstreamPageUpdateParams struct { + ID string `json:"id"` + Title *string `json:"title,omitempty"` + Owner *string `json:"owner,omitempty" enum:"you,agent"` + ReadByJobs *bool `json:"read_by_jobs,omitempty"` +} + +type WorkstreamPageUpdateResult struct { + Page WorkstreamPageNode `json:"page"` +} + +type WorkstreamPageMoveParams struct { + ID string `json:"id"` + ParentID *string `json:"parent_id,omitempty"` + AfterID *string `json:"after_id,omitempty"` +} + +type WorkstreamPageMoveResult struct { + Page WorkstreamPageNode `json:"page"` +} + +type WorkstreamPageDeleteParams struct { + ID string `json:"id"` +} + +type WorkstreamPageDeleteResult struct { + Deleted []string `json:"deleted"` +} + +type WorkstreamPageHistoryParams struct { + ID string `json:"id"` + Limit *int `json:"limit,omitempty"` +} + +type WorkstreamPageHistoryResult struct { + Versions []WorkstreamPageVersion `json:"versions"` +} + +type WorkstreamPageRestoreParams struct { + ID string `json:"id"` + Version int `json:"version"` + BaseVersion int `json:"base_version"` +} + +type WorkstreamPageRestoreResult struct { + Page WorkstreamPage `json:"page"` +} + +type WorkstreamRecordListParams struct { + WorkstreamID string `json:"workstream_id"` + Kind *string `json:"kind,omitempty" enum:"decision,question"` + State *string `json:"state,omitempty" enum:"proposed,kept,dropped,superseded"` +} + +type WorkstreamRecordListResult struct { + Records []WorkstreamRecord `json:"records"` +} + +type WorkstreamRecordProposeParams struct { + WorkstreamID string `json:"workstream_id"` + Kind string `json:"kind" enum:"decision,question"` + Text string `json:"text"` + Source *WorkstreamSource `json:"source,omitempty"` + Kept *bool `json:"kept,omitempty"` +} + +type WorkstreamRecordProposeResult struct { + Record WorkstreamRecord `json:"record"` +} + +type WorkstreamRecordKeepParams struct { + ID string `json:"id"` +} + +type WorkstreamRecordKeepResult struct { + Record WorkstreamRecord `json:"record"` +} + +type WorkstreamRecordDropParams struct { + ID string `json:"id"` +} + +type WorkstreamRecordDropResult struct { + Record WorkstreamRecord `json:"record"` +} + +type WorkstreamRecordSupersedeParams struct { + ID string `json:"id"` + Text string `json:"text"` + Kind *string `json:"kind,omitempty" enum:"decision,question"` + Source *WorkstreamSource `json:"source,omitempty"` +} + +type WorkstreamRecordSupersedeResult struct { + Record WorkstreamRecord `json:"record"` + Superseded WorkstreamRecord `json:"superseded"` +} + +type WorkstreamGoAheadListParams struct { + WorkstreamID string `json:"workstream_id"` + Ended *bool `json:"ended,omitempty"` +} + +type WorkstreamGoAheadListResult struct { + GoAheads []WorkstreamGoAhead `json:"goaheads"` +} + +type WorkstreamGoAheadGrantParams struct { + WorkstreamID string `json:"workstream_id"` + Scope string `json:"scope" enum:"job,wave,milestone"` + 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"` + EndsAt time.Time `json:"ends_at"` +} + +type WorkstreamGoAheadGrantResult struct { + GoAhead WorkstreamGoAhead `json:"goahead"` +} + +type WorkstreamGoAheadRevokeParams struct { + ID string `json:"id"` +} + +type WorkstreamGoAheadRevokeResult struct { + GoAhead WorkstreamGoAhead `json:"goahead"` +} + +// Workstreamer is the optional engine half of the workstreams contract. +// A stub implements the shapes but returns ErrWorkstreamsNotYet and serves false. +type Workstreamer interface { + ServesWorkstreams() bool + WorkstreamList(WorkstreamListParams) (WorkstreamListResult, error) + WorkstreamGet(WorkstreamGetParams) (WorkstreamGetResult, error) + WorkstreamCreate(WorkstreamCreateParams) (WorkstreamCreateResult, error) + WorkstreamUpdate(WorkstreamUpdateParams) (WorkstreamUpdateResult, error) + WorkstreamMarkDone(WorkstreamMarkDoneParams) (WorkstreamMarkDoneResult, error) + WorkstreamReopen(WorkstreamReopenParams) (WorkstreamReopenResult, error) + WorkstreamPlanGet(WorkstreamPlanGetParams) (WorkstreamPlanGetResult, error) + WorkstreamPlanSet(WorkstreamPlanSetParams) (WorkstreamPlanSetResult, error) + WorkstreamPageList(WorkstreamPageListParams) (WorkstreamPageListResult, error) + WorkstreamPageGet(WorkstreamPageGetParams) (WorkstreamPageGetResult, error) + WorkstreamPageSave(WorkstreamPageSaveParams) (WorkstreamPageSaveResult, error) + WorkstreamPageCreate(WorkstreamPageCreateParams) (WorkstreamPageCreateResult, error) + WorkstreamPageUpdate(WorkstreamPageUpdateParams) (WorkstreamPageUpdateResult, error) + WorkstreamPageMove(WorkstreamPageMoveParams) (WorkstreamPageMoveResult, error) + WorkstreamPageDelete(WorkstreamPageDeleteParams) (WorkstreamPageDeleteResult, error) + WorkstreamPageHistory(WorkstreamPageHistoryParams) (WorkstreamPageHistoryResult, error) + WorkstreamPageRestore(WorkstreamPageRestoreParams) (WorkstreamPageRestoreResult, error) + WorkstreamRecordList(WorkstreamRecordListParams) (WorkstreamRecordListResult, error) + WorkstreamRecordPropose(WorkstreamRecordProposeParams) (WorkstreamRecordProposeResult, error) + WorkstreamRecordKeep(WorkstreamRecordKeepParams) (WorkstreamRecordKeepResult, error) + WorkstreamRecordDrop(WorkstreamRecordDropParams) (WorkstreamRecordDropResult, error) + WorkstreamRecordSupersede(WorkstreamRecordSupersedeParams) (WorkstreamRecordSupersedeResult, error) + WorkstreamGoAheadList(WorkstreamGoAheadListParams) (WorkstreamGoAheadListResult, error) + WorkstreamGoAheadGrant(WorkstreamGoAheadGrantParams) (WorkstreamGoAheadGrantResult, error) + WorkstreamGoAheadRevoke(WorkstreamGoAheadRevokeParams) (WorkstreamGoAheadRevokeResult, error) +} + +// workstreamCall preserves the params/result types used by the schema generator. +// Bounds and storage behavior land in WP-1; this door only delegates. +func workstreamCall[P, R any](call func(Workstreamer, P) (R, error)) method { + return typed(func(e Engine, p P) (*R, error) { + w, ok := e.(Workstreamer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve workstreams") + } + result, err := call(w, p) + return &result, err + }) +} + +func init() { + methods["workstream.list"] = workstreamCall(Workstreamer.WorkstreamList) + methods["workstream.get"] = workstreamCall(Workstreamer.WorkstreamGet) + methods["workstream.create"] = workstreamCall(Workstreamer.WorkstreamCreate) + methods["workstream.update"] = workstreamCall(Workstreamer.WorkstreamUpdate) + methods["workstream.mark_done"] = workstreamCall(Workstreamer.WorkstreamMarkDone) + methods["workstream.reopen"] = workstreamCall(Workstreamer.WorkstreamReopen) + methods["workstream.plan_get"] = workstreamCall(Workstreamer.WorkstreamPlanGet) + methods["workstream.plan_set"] = workstreamCall(Workstreamer.WorkstreamPlanSet) + methods["page.list"] = workstreamCall(Workstreamer.WorkstreamPageList) + methods["page.get"] = workstreamCall(Workstreamer.WorkstreamPageGet) + methods["page.save"] = workstreamCall(Workstreamer.WorkstreamPageSave) + methods["page.create"] = workstreamCall(Workstreamer.WorkstreamPageCreate) + methods["page.update"] = workstreamCall(Workstreamer.WorkstreamPageUpdate) + methods["page.move"] = workstreamCall(Workstreamer.WorkstreamPageMove) + methods["page.delete"] = workstreamCall(Workstreamer.WorkstreamPageDelete) + methods["page.history"] = workstreamCall(Workstreamer.WorkstreamPageHistory) + methods["page.restore"] = workstreamCall(Workstreamer.WorkstreamPageRestore) + methods["record.list"] = workstreamCall(Workstreamer.WorkstreamRecordList) + methods["record.propose"] = workstreamCall(Workstreamer.WorkstreamRecordPropose) + methods["record.keep"] = workstreamCall(Workstreamer.WorkstreamRecordKeep) + methods["record.drop"] = workstreamCall(Workstreamer.WorkstreamRecordDrop) + methods["record.supersede"] = workstreamCall(Workstreamer.WorkstreamRecordSupersede) + methods["goahead.list"] = workstreamCall(Workstreamer.WorkstreamGoAheadList) + methods["goahead.grant"] = workstreamCall(Workstreamer.WorkstreamGoAheadGrant) + methods["goahead.revoke"] = workstreamCall(Workstreamer.WorkstreamGoAheadRevoke) + eventShapes[EventWorkstreamChanged] = reflect.TypeFor[WorkstreamChanged]() +} + +func withWorkstreamsFeature(hello Hello, e Engine) Hello { + if w, ok := e.(Workstreamer); ok && w.ServesWorkstreams() { + return withFeature(hello, FeatureWorkstreams) + } + return hello +} diff --git a/internal/appwire/workstreams_test.go b/internal/appwire/workstreams_test.go new file mode 100644 index 0000000000..c3fe76e3d2 --- /dev/null +++ b/internal/appwire/workstreams_test.go @@ -0,0 +1,246 @@ +package appwire + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "testing" +) + +// fakeWorkstreamer exercises V4 without a store, Factory or engine binary. +type fakeWorkstreamer struct { + *fakeEngine + serves bool +} + +var _ Workstreamer = (*fakeWorkstreamer)(nil) + +func (f *fakeWorkstreamer) ServesWorkstreams() bool { return f.serves } + +func (f *fakeWorkstreamer) WorkstreamList(WorkstreamListParams) (WorkstreamListResult, error) { + return WorkstreamListResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamGet(WorkstreamGetParams) (WorkstreamGetResult, error) { + return WorkstreamGetResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamCreate(WorkstreamCreateParams) (WorkstreamCreateResult, error) { + return WorkstreamCreateResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamUpdate(WorkstreamUpdateParams) (WorkstreamUpdateResult, error) { + return WorkstreamUpdateResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamMarkDone(WorkstreamMarkDoneParams) (WorkstreamMarkDoneResult, error) { + return WorkstreamMarkDoneResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamReopen(WorkstreamReopenParams) (WorkstreamReopenResult, error) { + return WorkstreamReopenResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPlanGet(WorkstreamPlanGetParams) (WorkstreamPlanGetResult, error) { + return WorkstreamPlanGetResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPlanSet(WorkstreamPlanSetParams) (WorkstreamPlanSetResult, error) { + return WorkstreamPlanSetResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageList(WorkstreamPageListParams) (WorkstreamPageListResult, error) { + return WorkstreamPageListResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageGet(WorkstreamPageGetParams) (WorkstreamPageGetResult, error) { + return WorkstreamPageGetResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageSave(WorkstreamPageSaveParams) (WorkstreamPageSaveResult, error) { + return WorkstreamPageSaveResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageCreate(WorkstreamPageCreateParams) (WorkstreamPageCreateResult, error) { + return WorkstreamPageCreateResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageUpdate(WorkstreamPageUpdateParams) (WorkstreamPageUpdateResult, error) { + return WorkstreamPageUpdateResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageMove(WorkstreamPageMoveParams) (WorkstreamPageMoveResult, error) { + return WorkstreamPageMoveResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageDelete(WorkstreamPageDeleteParams) (WorkstreamPageDeleteResult, error) { + return WorkstreamPageDeleteResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageHistory(WorkstreamPageHistoryParams) (WorkstreamPageHistoryResult, error) { + return WorkstreamPageHistoryResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamPageRestore(WorkstreamPageRestoreParams) (WorkstreamPageRestoreResult, error) { + return WorkstreamPageRestoreResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamRecordList(WorkstreamRecordListParams) (WorkstreamRecordListResult, error) { + return WorkstreamRecordListResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamRecordPropose(WorkstreamRecordProposeParams) (WorkstreamRecordProposeResult, error) { + return WorkstreamRecordProposeResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamRecordKeep(WorkstreamRecordKeepParams) (WorkstreamRecordKeepResult, error) { + return WorkstreamRecordKeepResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamRecordDrop(WorkstreamRecordDropParams) (WorkstreamRecordDropResult, error) { + return WorkstreamRecordDropResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamRecordSupersede(WorkstreamRecordSupersedeParams) (WorkstreamRecordSupersedeResult, error) { + return WorkstreamRecordSupersedeResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamGoAheadList(WorkstreamGoAheadListParams) (WorkstreamGoAheadListResult, error) { + return WorkstreamGoAheadListResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamGoAheadGrant(WorkstreamGoAheadGrantParams) (WorkstreamGoAheadGrantResult, error) { + return WorkstreamGoAheadGrantResult{}, ErrWorkstreamsNotYet +} + +func (f *fakeWorkstreamer) WorkstreamGoAheadRevoke(WorkstreamGoAheadRevokeParams) (WorkstreamGoAheadRevokeResult, error) { + return WorkstreamGoAheadRevokeResult{}, ErrWorkstreamsNotYet +} + +func workstreamHelloHasFeature(hello map[string]any) bool { + for _, feature := range features(hello) { + if feature == FeatureWorkstreams { + return true + } + } + return false +} + +// V1: the contract index and dispatch table name exactly the same 25 methods. +func TestEveryWorkstreamMethodIsRegistered(t *testing.T) { + if len(WorkstreamMethods) != 25 { + t.Fatalf("contract has %d methods, want 25", len(WorkstreamMethods)) + } + known := make(map[string]bool, len(WorkstreamMethods)) + served := Methods() + for _, name := range WorkstreamMethods { + if known[name] { + t.Errorf("duplicate contract method %s", name) + } + known[name] = true + if !slices.Contains(served, name) { + t.Errorf("method not registered: %s", name) + } + } + for _, name := range served { + for _, prefix := range []string{"workstream.", "page.", "record.", "goahead."} { + if strings.HasPrefix(name, prefix) && !known[name] { + t.Errorf("registered method not in contract: %s", name) + } + } + } + if !slices.Contains(Events(), EventWorkstreamChanged) { + t.Error("workstream.changed is not registered") + } +} + +// V4: every registered stub refuses on the wire, without claiming the feature. +func TestWorkstreamMethodsAnswerNotYet(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}} + w := startWireOver(t, fake, fake.fakeEngine) + if workstreamHelloHasFeature(w.next()) { + t.Fatal("the stub claims workstreams") + } + for i, name := range WorkstreamMethods { + t.Run(name, func(t *testing.T) { + w.send(fmt.Sprintf(`{"id":%d,"method":%q,"params":{}}`, i+1, name)) + got := w.next() + if got["id"] != float64(i+1) { + t.Fatal("response id does not match request") + } + err, ok := got["error"].(map[string]any) + if !ok { + t.Fatal("stub response has no error") + } + if err["code"] != CodeUnknownMethod { + t.Errorf("code = %v, want unknown_method", err["code"]) + } + if err["message"] != "workstreams: not implemented yet" { + t.Errorf("message = %v", err["message"]) + } + }) + } +} + +// V4: both interface support and ServesWorkstreams are required for hello. +func TestWorkstreamsFeatureFollowsTheEngine(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + w := startWireOver(t, fake, fake.fakeEngine) + if !workstreamHelloHasFeature(w.next()) { + t.Fatal("serving engine does not announce workstreams") + } + plain := startWire(t, &fakeEngine{}) + if workstreamHelloHasFeature(plain.next()) { + t.Fatal("plain engine claims workstreams") + } + plain.send(`{"id":1,"method":"workstream.list","params":{}}`) + got := plain.next() + err, ok := got["error"].(map[string]any) + if !ok || err["code"] != CodeUnknownMethod { + t.Fatal("plain engine does not answer unknown_method") + } + if err["message"] != "this engine does not serve workstreams" { + t.Errorf("plain engine message = %v", err["message"]) + } + hello := withWorkstreamsFeature(Hello{}, fake) + hello = withWorkstreamsFeature(hello, fake) + if len(hello.Features) != 1 || hello.Features[0] != FeatureWorkstreams { + t.Fatal("feature hook must add workstreams exactly once") + } +} + +// V1: typed handlers and event registration expose the complete contract schema. +func TestWorkstreamSchemaNamesEveryMethod(t *testing.T) { + out, err := SchemaJSON() + if err != nil { + t.Fatal(err) + } + var doc struct { + Methods map[string]json.RawMessage `json:"methods"` + Events map[string]json.RawMessage `json:"events"` + Defs map[string]struct { + Properties map[string]map[string]any `json:"properties"` + } `json:"$defs"` + } + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + for _, name := range WorkstreamMethods { + if doc.Methods[name] == nil { + t.Errorf("schema lacks %s", name) + } + } + if doc.Events[EventWorkstreamChanged] == nil { + t.Error("schema lacks workstream.changed") + } + words, _ := doc.Defs["Workstream"].Properties["stage"]["enum"].([]any) + if len(words) != 3 { + t.Fatalf("Workstream.stage has %d words, want 3", len(words)) + } + for i, want := range []string{"shaping", "building", "done"} { + if words[i] != want { + t.Errorf("Workstream.stage word %d = %v, want %s", i, words[i], want) + } + } +} From f4078a9dea0648cebc889858fac0cc524b60dd06 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 22:13:27 -0500 Subject: [PATCH 03/20] app: workstream pages render from editor JSON to markdown (pagedoc) (#2277) * app: workstream pages render from editor JSON to markdown The Workstreams plan, WP-6c: internal/workstream/pagedoc.Markdown renders a page's editor JSON by the page document format rules, byte for byte the same as the desktop app's renderer, so a job that reads a page sees the markdown a person sees when they export it. Pure: no I/O and no store; invalid JSON is an error, an absent document is empty, unknown nodes keep their text. Co-Authored-By: Claude Opus 5.5 * tests: page markdown matches the desktop app's fixtures and the contract example Every assertion of the desktop app's markdown tests as a Go table row with the identical expected string, the contract's worked example as testdata, invalid and absent documents, and edge cases that pin JavaScript's line terminators, number formatting and coercions. Co-Authored-By: Claude Opus 5.5 * docs: change entry for the workstream page renderer Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../2277-app-workstream-page-markdown.md | 9 + internal/workstream/pagedoc/pagedoc.go | 395 +++++++++++++++++ internal/workstream/pagedoc/pagedoc_test.go | 414 ++++++++++++++++++ .../workstream/pagedoc/testdata/example.json | 25 ++ .../workstream/pagedoc/testdata/example.md | 20 + 5 files changed, 863 insertions(+) create mode 100644 docs/changes/unreleased/2277-app-workstream-page-markdown.md create mode 100644 internal/workstream/pagedoc/pagedoc.go create mode 100644 internal/workstream/pagedoc/pagedoc_test.go create mode 100644 internal/workstream/pagedoc/testdata/example.json create mode 100644 internal/workstream/pagedoc/testdata/example.md diff --git a/docs/changes/unreleased/2277-app-workstream-page-markdown.md b/docs/changes/unreleased/2277-app-workstream-page-markdown.md new file mode 100644 index 0000000000..a9d24c601a --- /dev/null +++ b/docs/changes/unreleased/2277-app-workstream-page-markdown.md @@ -0,0 +1,9 @@ +--- +kind: internal +title: Workstream pages render from editor JSON to markdown in internal/workstream/pagedoc +pr: 2277 +surface: [app] +invalidates: [] +--- + +`pagedoc.Markdown` renders a page's editor JSON by the page document format rules, the same bytes as the desktop app's renderer. Nothing calls it yet; the Factory plan step that reads pages for job briefs will. diff --git a/internal/workstream/pagedoc/pagedoc.go b/internal/workstream/pagedoc/pagedoc.go new file mode 100644 index 0000000000..21c0c5591d --- /dev/null +++ b/internal/workstream/pagedoc/pagedoc.go @@ -0,0 +1,395 @@ +// Package pagedoc renders workstream page editor JSON documents to markdown. +package pagedoc + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "unicode/utf8" +) + +// Markdown renders a workstream page's editor JSON document into deterministic +// markdown matching the desktop app's reference renderer. An absent document +// (nil, empty or whitespace only) returns "", nil, the same as JSON null. +// Invalid JSON returns an error prefixed with "pagedoc: ". +// +// Two untestable boundary cases differ from the JavaScript reference because +// of runtime semantics: Go's JSON decoder replaces lone UTF-16 surrogate +// escapes with U+FFFD, and documents nested deeply enough to overflow the +// JavaScript call stack (where the reference catches the range error and +// returns "") render normally in Go up to encoding/json's 10000-level nesting +// limit, beyond which json.Valid reports an invalid-JSON error. +func Markdown(doc json.RawMessage) (out string, err error) { + // The export boundary must not take the engine down on a document nobody + // foresaw; the reference renderer likewise returns rather than throws. + defer func() { + if r := recover(); r != nil { + out = "" + err = fmt.Errorf("pagedoc: recovered panic: %v", r) + } + }() + + if len(bytes.TrimSpace(doc)) == 0 { + return "", nil + } + if !json.Valid(doc) { + return "", errors.New("pagedoc: invalid JSON") + } + dec := json.NewDecoder(bytes.NewReader(doc)) + dec.UseNumber() + var root any + if err := dec.Decode(&root); err != nil { + return "", fmt.Errorf("pagedoc: invalid JSON: %w", err) + } + rendered := block(object(root)) + if rendered == "" { + return "", nil + } + return strings.TrimRight(rendered, "\n") + "\n", nil +} + +func object(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + return m + } + return nil +} + +func children(node map[string]any) []map[string]any { + raw, ok := node["content"].([]any) + if !ok { + return nil + } + out := make([]map[string]any, len(raw)) + for i, elem := range raw { + out[i] = object(elem) + } + return out +} + +func marks(node map[string]any) []map[string]any { + raw, ok := node["marks"].([]any) + if !ok { + return nil + } + out := make([]map[string]any, len(raw)) + for i, elem := range raw { + out[i] = object(elem) + } + return out +} + +func str(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func number(v any) (float64, bool) { + n, ok := v.(json.Number) + if !ok { + return 0, false + } + f, err := strconv.ParseFloat(string(n), 64) + if err != nil && !errors.Is(err, strconv.ErrRange) { + return 0, false + } + return f, true +} + +func intNumber(v any) (float64, bool) { + f, ok := number(v) + if !ok || math.IsInf(f, 0) || math.IsNaN(f) || math.Trunc(f) != f { + return 0, false + } + return f, true +} + +func jsNumber(x float64) string { + if x == 0 { + return "0" + } + if math.Abs(x) < 1e21 { + return strconv.FormatFloat(x, 'f', -1, 64) + } + return strconv.FormatFloat(x, 'e', -1, 64) +} + +func escape(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + switch s[i] { + case '\\', '`', '*', '_', '[', ']', '<', '>': + b.WriteByte('\\') + } + b.WriteByte(s[i]) + } + return b.String() +} + +func longestBackticks(s string) int { + longest, run := 0, 0 + for i := 0; i < len(s); i++ { + if s[i] == '`' { + run++ + if run > longest { + longest = run + } + } else { + run = 0 + } + } + return longest +} + +func inlineLine(s string) string { + return strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(s) +} + +func trimInline(s string) string { + return strings.Trim(s, " \t") +} + +func descendantText(node map[string]any) string { + var b strings.Builder + b.WriteString(str(node["text"])) + for _, child := range children(node) { + b.WriteString(descendantText(child)) + } + return b.String() +} + +func inlineText(node map[string]any) string { + t := inlineLine(str(node["text"])) + if t == "" { + return "" + } + hasCode := false + for _, m := range marks(node) { + if str(m["type"]) == "code" { + hasCode = true + break + } + } + if !hasCode { + return escape(t) + } + delim := strings.Repeat("`", longestBackticks(t)+1) + pad := "" + if strings.HasPrefix(t, "`") || strings.HasSuffix(t, "`") { + pad = " " + } + return delim + pad + t + pad + delim +} + +func linkHref(node map[string]any) string { + if str(node["type"]) != "text" { + return "" + } + for _, m := range marks(node) { + if str(m["type"]) == "link" { + return str(object(m["attrs"])["href"]) + } + } + return "" +} + +func encodeHref(href string) string { + return strings.NewReplacer( + " ", "%20", + "(", "%28", + ")", "%29", + "<", "%3C", + ">", "%3E", + ).Replace(href) +} + +func inline(nodes []map[string]any) string { + var b strings.Builder + for i := 0; i < len(nodes); { + n := nodes[i] + href := linkHref(n) + text := inlineText(n) + i++ + if href != "" { + for i < len(nodes) && linkHref(nodes[i]) == href { + text += inlineText(nodes[i]) + i++ + } + if text == "" { + continue + } + b.WriteByte('[') + b.WriteString(text) + b.WriteString("](") + b.WriteString(encodeHref(href)) + b.WriteByte(')') + } else { + b.WriteString(text) + } + } + return b.String() +} + +func paragraph(s string) string { + s = trimInline(s) + var b strings.Builder + lineStart := true + for i := 0; i < len(s); { + if lineStart { + switch s[i] { + case '#', '-', '+', '>': + b.WriteByte('\\') + b.WriteByte(s[i]) + i++ + lineStart = false + continue + } + if s[i] >= '0' && s[i] <= '9' { + j := i + 1 + for j < len(s) && s[j] >= '0' && s[j] <= '9' { + j++ + } + b.WriteString(s[i:j]) + if j < len(s) && (s[j] == '.' || s[j] == ')') { + b.WriteByte('\\') + b.WriteByte(s[j]) + j++ + } + i = j + lineStart = false + continue + } + } + r, w := utf8.DecodeRuneInString(s[i:]) + b.WriteString(s[i : i+w]) + i += w + lineStart = r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' + } + return b.String() +} + +func liveBlock(attrs map[string]any) string { + kind := str(attrs["kind"]) + if kind == "" { + kind = "unknown" + } + switch kind { + case "jobs", "waves": + if m := str(attrs["milestone_id"]); m != "" { + return "[live: " + kind + " milestone=" + m + "]" + } + return "[live: " + kind + "]" + case "job": + if job, ok := intNumber(attrs["job"]); ok && job >= 0 { + return "[live: job " + str(attrs["factory"]) + "#" + jsNumber(job) + "]" + } + return "[live: job]" + case "decision": + if r := str(attrs["record_id"]); r != "" { + return "[live: decision " + r + "]" + } + return "[live: decision]" + default: + return "[live: " + kind + "]" + } +} + +func listItem(node map[string]any, prefix string) string { + var body strings.Builder + for _, child := range children(node) { + r := block(child) + if r == "" { + continue + } + if body.Len() > 0 { + t := str(child["type"]) + if t == "bulletList" || t == "orderedList" { + body.WriteByte('\n') + } else { + body.WriteString("\n\n") + } + } + body.WriteString(r) + } + if body.Len() == 0 { + return strings.TrimRight(prefix, " ") + } + lines := strings.Split(body.String(), "\n") + indent := strings.Repeat(" ", len(prefix)) + for i, line := range lines { + if i == 0 { + lines[i] = prefix + line + } else if line != "" { + lines[i] = indent + line + } + } + return strings.Join(lines, "\n") +} + +func block(node map[string]any) string { + attrs := object(node["attrs"]) + switch str(node["type"]) { + case "doc": + var parts []string + for _, child := range children(node) { + if r := block(child); r != "" { + parts = append(parts, r) + } + } + return strings.Join(parts, "\n\n") + case "paragraph": + return paragraph(inline(children(node))) + case "heading": + t := trimInline(inline(children(node))) + if t == "" { + return "" + } + level := 1 + if f, ok := number(attrs["level"]); ok && !math.IsInf(f, 0) && !math.IsNaN(f) { + tr := math.Trunc(f) + if tr < 1 { + level = 1 + } else if tr > 3 { + level = 3 + } else { + level = int(tr) + } + } + return strings.Repeat("#", level) + " " + t + case "bulletList", "orderedList": + start := 1.0 + if s, ok := intNumber(attrs["start"]); ok { + start = s + } + kids := children(node) + items := make([]string, len(kids)) + isBullet := str(node["type"]) == "bulletList" + for i, item := range kids { + prefix := "- " + if !isBullet { + prefix = jsNumber(start+float64(i)) + ". " + } + items[i] = listItem(item, prefix) + } + return strings.Join(items, "\n") + case "listItem": + return listItem(node, "") + case "codeBlock": + t := descendantText(node) + fence := strings.Repeat("`", max(3, longestBackticks(t)+1)) + if t != "" { + return fence + str(attrs["language"]) + "\n" + t + "\n" + fence + } + return fence + str(attrs["language"]) + "\n" + fence + case "liveBlock": + return liveBlock(attrs) + default: + return paragraph(escape(inlineLine(descendantText(node)))) + } +} diff --git a/internal/workstream/pagedoc/pagedoc_test.go b/internal/workstream/pagedoc/pagedoc_test.go new file mode 100644 index 0000000000..e2ce9fa7ee --- /dev/null +++ b/internal/workstream/pagedoc/pagedoc_test.go @@ -0,0 +1,414 @@ +package pagedoc + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +type testCase struct { + name, input, want string +} + +func doc(blocks ...string) string { + return `{"type":"doc","content":[` + strings.Join(blocks, ",") + `]}` +} + +func runCases(t *testing.T, cases []testCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Markdown(json.RawMessage(tc.input)) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.name, err) + } + if got != tc.want { + t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) + } + }) + } +} + +// Ported from the desktop app's markdown tests: "paragraphs, heading levels 1–3 and clamping, empty blocks and exactly one terminal newline". +// Covers validation contract (a). +func TestMarkdownParagraphsHeadingsEmptyBlocksAndTerminalNewline(t *testing.T) { + runCases(t, []testCase{ + {"paragraphs and empty paragraph", doc(`{"type":"paragraph","content":[{"type":"text","text":"First"}]}`, `{"type":"paragraph","content":[]}`, `{"type":"paragraph","content":[{"type":"text","text":"Second "}]}`), "First\n\nSecond\n"}, + {"heading level 1", doc(`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, + {"heading level 2", doc(`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]}`), "## Title\n"}, + {"heading level 3", doc(`{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Title"}]}`), "### Title\n"}, + {"heading level 0 clamped to 1", doc(`{"type":"heading","attrs":{"level":0},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, + {"heading level -2 clamped to 1", doc(`{"type":"heading","attrs":{"level":-2},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, + {"heading level 8 clamped to 3", doc(`{"type":"heading","attrs":{"level":8},"content":[{"type":"text","text":"Title"}]}`), "### Title\n"}, + {"heading default level", doc(`{"type":"heading","content":[{"type":"text","text":"Default"}]}`), "# Default\n"}, + {"empty heading and paragraph", doc(`{"type":"heading","content":[]}`, `{"type":"paragraph","content":[]}`), ""}, + {"null document", `null`, ""}, + {"doc without content", `{"type":"doc"}`, ""}, + {"doc with empty content", doc(), ""}, + }) +} + +// Ported from the desktop app's markdown tests: "paragraph and heading newlines become spaces, including inline code, and outer spaces and tabs are trimmed". +// Covers validation contract (a). +func TestMarkdownInlineNewlinesAndOuterWhitespace(t *testing.T) { + runCases(t, []testCase{ + {"paragraph crlf in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\r\nright"}]}`), "Left right\n"}, + {"paragraph crlf in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\r\nright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, + {"paragraph crlf alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\r\n"}]}`), ""}, + {"paragraph crlf and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\r\n "}]}`), ""}, + {"paragraph cr in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\rright"}]}`), "Left right\n"}, + {"paragraph cr in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\rright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, + {"paragraph cr alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\r"}]}`), ""}, + {"paragraph cr and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\r "}]}`), ""}, + {"paragraph lf in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\nright"}]}`), "Left right\n"}, + {"paragraph lf in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\nright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, + {"paragraph lf alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\n"}]}`), ""}, + {"paragraph lf and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\n "}]}`), ""}, + {"paragraph outer spaces and tabs trimmed", doc(`{"type":"paragraph","content":[{"type":"text","text":" Indented \t"}]}`), "Indented\n"}, + {"paragraph spaces and tabs only", doc(`{"type":"paragraph","content":[{"type":"text","text":"\t "}]}`), ""}, + {"heading crlf in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\r\nright"}]}`), "# Left right\n"}, + {"heading crlf in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\r\nright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, + {"heading crlf alone", doc(`{"type":"heading","content":[{"type":"text","text":"\r\n"}]}`), ""}, + {"heading crlf and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\r\n "}]}`), ""}, + {"heading cr in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\rright"}]}`), "# Left right\n"}, + {"heading cr in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\rright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, + {"heading cr alone", doc(`{"type":"heading","content":[{"type":"text","text":"\r"}]}`), ""}, + {"heading cr and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\r "}]}`), ""}, + {"heading lf in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\nright"}]}`), "# Left right\n"}, + {"heading lf in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\nright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, + {"heading lf alone", doc(`{"type":"heading","content":[{"type":"text","text":"\n"}]}`), ""}, + {"heading lf and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\n "}]}`), ""}, + {"heading outer spaces and tabs trimmed", doc(`{"type":"heading","content":[{"type":"text","text":" Indented \t"}]}`), "# Indented\n"}, + {"heading spaces and tabs only", doc(`{"type":"heading","content":[{"type":"text","text":"\t "}]}`), ""}, + {"whitespace-only blocks omitted between paragraphs", doc(`{"type":"paragraph","content":[{"type":"text","text":"A"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"\n"}]}`, `{"type":"heading","content":[{"type":"text","text":"\n"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"B"}]}`), "A\n\nB\n"}, + {"empty paragraph inside list item omitted", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"\n"}]},{"type":"paragraph","content":[{"type":"text","text":" Kept "}]}]}]}`), "- Kept\n"}, + {"code block newlines and spaces verbatim", doc(`{"type":"codeBlock","content":[{"type":"text","text":" Left\r\nright\rnext\nlast "}]}`), "```\n Left\r\nright\rnext\nlast \n```\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "bullet and ordered lists, custom/default starts, nested and multi-block items, bare empty items". +// Covers validation contract (a). +func TestMarkdownBulletAndOrderedLists(t *testing.T) { + runCases(t, []testCase{ + {"bullet list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"One"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Two"}]}]}]}`), "- One\n- Two\n"}, + {"ordered list with custom start and multi-paragraph items", doc(`{"type":"orderedList","attrs":{"start":9,"ignored":true},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nine"}]},{"type":"paragraph","content":[{"type":"text","text":"Again"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Ten"}]},{"type":"paragraph","content":[{"type":"text","text":"Last"}]}]}]}`), "9. Nine\n\n Again\n10. Ten\n\n Last\n"}, + {"ordered list with default start and empty item", doc(`{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Default"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[]}]}]}`), "1. Default\n2.\n"}, + {"nested lists with empty paragraphs and bare empty item", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[]},{"type":"paragraph","content":[{"type":"text","text":"Parent"}]},{"type":"orderedList","attrs":{"start":3},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Child"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Leaf"}]}]}]}]}]},{"type":"paragraph","content":[]},{"type":"paragraph","content":[{"type":"text","text":"After"}]}]},{"type":"listItem","content":[]}]}`), "- Parent\n 3. Child\n - Leaf\n\n After\n-\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "code blocks preserve text, language, empty content and safe backtick fences". +// Covers validation contract (a). +func TestMarkdownCodeBlocks(t *testing.T) { + runCases(t, []testCase{ + {"code block with language", doc(`{"type":"codeBlock","attrs":{"language":"ts"},"content":[{"type":"text","text":"const x = 1"}]}`), "```ts\nconst x = 1\n```\n"}, + {"code block with triple backticks in text and null language", doc(`{"type":"codeBlock","attrs":{"language":null},"content":[{"type":"text","text":"before ` + "```" + ` after\n"}]}`), "````\nbefore ``` after\n\n````\n"}, + {"empty code block", doc(`{"type":"codeBlock"}`), "```\n```\n"}, + {"code block preserves outer spaces", doc(`{"type":"codeBlock","content":[{"type":"text","text":" x "}]}`), "```\n x \n```\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "inline code delimiters and padding handle embedded and edge backticks without escaping". +// Covers validation contract (a). +func TestMarkdownInlineCode(t *testing.T) { + runCases(t, []testCase{ + {"inline code without escaping", doc(`{"type":"paragraph","content":[{"type":"text","text":"x * y","marks":[{"type":"code"}]}]}`), "`x * y`\n"}, + {"inline code with embedded backtick", doc(`{"type":"paragraph","content":[{"type":"text","text":"a` + "`" + `b","marks":[{"type":"code"}]}]}`), "``a`b``\n"}, + {"inline code with edge backticks", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "`x`" + `","marks":[{"type":"code"}]}]}`), "`` `x` ``\n"}, + {"inline code with leading double backticks", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "``x" + `","marks":[{"type":"code"}]}]}`), "``` ``x ```\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "adjacent same-href text forms one link, code stays inside, destinations encode delimiters, absent href is plain text". +// Covers validation contract (a). +func TestMarkdownLinksAndDestinations(t *testing.T) { + runCases(t, []testCase{ + {"adjacent same-href nodes with inline code and encoded destination", doc(`{"type":"paragraph","content":[{"type":"text","text":"Read ","marks":[{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]},{"type":"text","text":"x","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]},{"type":"text","text":" now","marks":[{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]}]}`), "[Read `x` now](https://example.com/a%20b%28c%29%3Cd%3E)\n"}, + {"adjacent different-href links stay separate", doc(`{"type":"paragraph","content":[{"type":"text","text":"one","marks":[{"type":"link","attrs":{"href":"https://a.test"}}]},{"type":"text","text":"two","marks":[{"type":"link","attrs":{"href":"https://b.test"}}]}]}`), "[one](https://a.test)[two](https://b.test)\n"}, + {"empty and missing href render plain escaped text", doc(`{"type":"paragraph","content":[{"type":"text","text":"empty*","marks":[{"type":"link","attrs":{"href":""}}]},{"type":"text","text":"missing","marks":[{"type":"link","attrs":{}}]}]}`), "empty\\*missing\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "empty text and empty link runs render nothing while non-empty same-href runs remain linked". +// Covers validation contract (a). +func TestMarkdownEmptyTextAndLinkRuns(t *testing.T) { + runCases(t, []testCase{ + {"paragraph empty text no marks", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[]}]}`), ""}, + {"paragraph empty text code mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"code"}]}]}`), ""}, + {"paragraph empty text link mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, + {"paragraph empty text code and link marks", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, + {"paragraph multiple empty link nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, + {"heading empty text no marks", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[]}]}`), ""}, + {"heading empty text code mark", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"code"}]}]}`), ""}, + {"heading empty text link mark", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, + {"heading empty text code and link marks", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, + {"heading multiple empty link nodes", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, + {"empty link between plain text nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"Before"},{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":" after"}]}`), "Before after\n"}, + {"non-empty link surrounded by empty same-href nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"Guide","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), "[Guide](https://example.com)\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "every live block kind with and without attributes, job zero and an empty factory, unknown kinds". +// Covers validation contract (a). +func TestMarkdownLiveBlocks(t *testing.T) { + runCases(t, []testCase{ + {"all four kinds without attributes", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"decision","milestone_id":null,"factory":null,"job":null,"record_id":null}}`), "[live: jobs]\n\n[live: waves]\n\n[live: job]\n\n[live: decision]\n"}, + {"all four kinds with attributes", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":"m1","factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":"m2","factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":"api","job":1107,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"decision","milestone_id":null,"factory":null,"job":null,"record_id":"r1"}}`), "[live: jobs milestone=m1]\n\n[live: waves milestone=m2]\n\n[live: job api#1107]\n\n[live: decision r1]\n"}, + {"job zero without factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":null,"job":0,"record_id":null}}`), "[live: job #0]\n"}, + {"factory without job number", doc(`{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":"api","job":null,"record_id":null}}`), "[live: job]\n"}, + {"unknown kind and missing attrs", doc(`{"type":"liveBlock","attrs":{"kind":"canvas"}}`, `{"type":"liveBlock"}`), "[live: canvas]\n\n[live: unknown]\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "live references require non-empty strings and job numbers require non-negative integers". +// Covers validation contract (a). +func TestMarkdownLiveBlockAttributeValidation(t *testing.T) { + // JSON cannot carry NaN, so the NaN row from the TypeScript loop is omitted; + // 1e400 and -1e400 cover positive and negative infinity after JSON parsing. + runCases(t, []testCase{ + {"jobs milestone empty string", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":""}}`), "[live: jobs]\n"}, + {"waves milestone empty string", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":""}}`), "[live: waves]\n"}, + {"decision record empty string", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":""}}`), "[live: decision]\n"}, + {"job factory empty string", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"","job":7}}`), "[live: job #7]\n"}, + {"jobs milestone number", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":42}}`), "[live: jobs]\n"}, + {"waves milestone number", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":42}}`), "[live: waves]\n"}, + {"decision record number", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":42}}`), "[live: decision]\n"}, + {"job factory number", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":42,"job":7}}`), "[live: job #7]\n"}, + {"jobs milestone false", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":false}}`), "[live: jobs]\n"}, + {"waves milestone false", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":false}}`), "[live: waves]\n"}, + {"decision record false", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":false}}`), "[live: decision]\n"}, + {"job factory false", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":false,"job":7}}`), "[live: job #7]\n"}, + {"jobs milestone object", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":{}}}`), "[live: jobs]\n"}, + {"waves milestone object", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":{}}}`), "[live: waves]\n"}, + {"decision record object", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":{}}}`), "[live: decision]\n"}, + {"job factory object", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":{},"job":7}}`), "[live: job #7]\n"}, + {"jobs milestone null", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":null}}`), "[live: jobs]\n"}, + {"waves milestone null", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":null}}`), "[live: waves]\n"}, + {"decision record null", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":null}}`), "[live: decision]\n"}, + {"job factory null", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":null,"job":7}}`), "[live: job #7]\n"}, + {"job string", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":"7"}}`), "[live: job]\n"}, + {"job fractional", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1.5}}`), "[live: job]\n"}, + {"job negative", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-1}}`), "[live: job]\n"}, + {"job positive infinity", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1e400}}`), "[live: job]\n"}, + {"job negative infinity", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-1e400}}`), "[live: job]\n"}, + {"job boolean", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":false}}`), "[live: job]\n"}, + {"job null", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":null}}`), "[live: job]\n"}, + {"job without factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","job":7}}`), "[live: job #7]\n"}, + {"job zero with factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":0}}`), "[live: job api#0]\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "every escaped character and paragraph line-start case, also inside lists". +// Covers validation contract (a). +func TestMarkdownEscapingAndParagraphLineStarts(t *testing.T) { + runCases(t, []testCase{ + {"escape backslash", doc(`{"type":"paragraph","content":[{"type":"text","text":"\\"}]}`), "\\\\\n"}, + {"escape backtick", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "`" + `"}]}`), "\\`\n"}, + {"escape asterisk", doc(`{"type":"paragraph","content":[{"type":"text","text":"*"}]}`), "\\*\n"}, + {"escape underscore", doc(`{"type":"paragraph","content":[{"type":"text","text":"_"}]}`), "\\_\n"}, + {"escape open bracket", doc(`{"type":"paragraph","content":[{"type":"text","text":"["}]}`), "\\[\n"}, + {"escape close bracket", doc(`{"type":"paragraph","content":[{"type":"text","text":"]"}]}`), "\\]\n"}, + {"escape less than", doc(`{"type":"paragraph","content":[{"type":"text","text":"<"}]}`), "\\<\n"}, + {"escape greater than", doc(`{"type":"paragraph","content":[{"type":"text","text":">"}]}`), "\\>\n"}, + {"line start hash", doc(`{"type":"paragraph","content":[{"type":"text","text":"# title"}]}`), "\\# title\n"}, + {"line start hash after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" # title\t "}]}`), "\\# title\n"}, + {"line start hash in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"# title"}]}]}]}`), "- \\# title\n"}, + {"line start minus", doc(`{"type":"paragraph","content":[{"type":"text","text":"- bullet"}]}`), "\\- bullet\n"}, + {"line start minus after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" - bullet\t "}]}`), "\\- bullet\n"}, + {"line start minus in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"- bullet"}]}]}]}`), "- \\- bullet\n"}, + {"line start plus", doc(`{"type":"paragraph","content":[{"type":"text","text":"+ bullet"}]}`), "\\+ bullet\n"}, + {"line start plus after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" + bullet\t "}]}`), "\\+ bullet\n"}, + {"line start plus in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"+ bullet"}]}]}]}`), "- \\+ bullet\n"}, + {"line start quote", doc(`{"type":"paragraph","content":[{"type":"text","text":"> quote"}]}`), "\\> quote\n"}, + {"line start quote after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" > quote\t "}]}`), "\\> quote\n"}, + {"line start quote in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"> quote"}]}]}]}`), "- \\> quote\n"}, + {"line start dot ordered", doc(`{"type":"paragraph","content":[{"type":"text","text":"12. list"}]}`), "12\\. list\n"}, + {"line start dot ordered after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" 12. list\t "}]}`), "12\\. list\n"}, + {"line start dot ordered in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"12. list"}]}]}]}`), "- 12\\. list\n"}, + {"line start paren ordered", doc(`{"type":"paragraph","content":[{"type":"text","text":"42) list"}]}`), "42\\) list\n"}, + {"line start paren ordered after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" 42) list\t "}]}`), "42\\) list\n"}, + {"line start paren ordered in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"42) list"}]}]}]}`), "- 42\\) list\n"}, + {"mid-paragraph markers after normalized newlines", doc(`{"type":"paragraph","content":[{"type":"text","text":"one \n# two\n3. three "}]}`), "one # two 3. three\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "unknown blocks flatten descendant text, unknown inline nodes render their own text, unknown marks and malformed JSON never throw". +// Covers validation contract (a) and (d). +func TestMarkdownUnknownNodesMarksAndMalformedShapes(t *testing.T) { + runCases(t, []testCase{ + {"unknown block flattens descendant text", doc(`{"type":"future","content":[{"type":"paragraph","content":[{"type":"text","text":"a*"}]},{"type":"other","content":[{"type":"text","text":"b_"}]}]}`), "a\\*b\\_\n"}, + {"unknown inline node renders own text and unknown mark is ignored", doc(`{"type":"paragraph","content":[{"type":"futureInline","text":"","content":[{"type":"text","text":"ignored"}]},{"type":"text","text":" ok","marks":[{"type":"futureMark"}]}]}`), "\\ ok\n"}, + {"empty unknown block omitted", doc(`{"type":"future"}`), ""}, + {"top-level null", `null`, ""}, + {"top-level false", `false`, ""}, + {"top-level number", `7`, ""}, + {"top-level string", `"wrong"`, ""}, + {"top-level array", `[]`, ""}, + {"top-level empty object", `{}`, ""}, + {"malformed content elements and non-string text", `{"type":"doc","content":[null,3,{},{"type":"paragraph","content":[null,{"type":"text","text":7,"marks":[null]}]}]}`, ""}, + {"codeBlock with null attrs and non-array content", `{"type":"codeBlock","attrs":null,"content":"bad"}`, "```\n```\n"}, + }) +} + +// Ported from the desktop app's markdown tests: "whole-document fixture is the wire contract's exact JSON and markdown example". +// Covers validation contract (a) and (b). +func TestMarkdownRendersTheContractExample(t *testing.T) { + rawJSON, err := os.ReadFile(filepath.Join("testdata", "example.json")) + if err != nil { + t.Fatalf("read example.json: %v", err) + } + rawMD, err := os.ReadFile(filepath.Join("testdata", "example.md")) + if err != nil { + t.Fatalf("read example.md: %v", err) + } + example := map[string]any{"type": "doc", "content": []any{ + map[string]any{"type": "heading", "attrs": map[string]any{"level": float64(1)}, "content": []any{map[string]any{"type": "text", "text": "Spec"}}}, + map[string]any{"type": "paragraph", "content": []any{ + map[string]any{"type": "text", "text": "Keep "}, + map[string]any{"type": "text", "text": "event_id", "marks": []any{map[string]any{"type": "code"}}}, + map[string]any{"type": "text", "text": " and "}, + map[string]any{"type": "text", "text": "guide", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://example.com/spec"}}}}, + map[string]any{"type": "text", "text": "."}, + }}, + map[string]any{"type": "bulletList", "content": []any{ + map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Retry safely"}}}}}, + map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Record outcomes"}}}}}, + }}, + map[string]any{"type": "orderedList", "attrs": map[string]any{"start": float64(3)}, "content": []any{ + map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Ship"}}}}}, + }}, + map[string]any{"type": "codeBlock", "attrs": map[string]any{"language": "ts"}, "content": []any{map[string]any{"type": "text", "text": "send(event)"}}}, + map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "jobs", "milestone_id": "m1", "factory": nil, "job": nil, "record_id": nil}}, + map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "waves", "milestone_id": nil, "factory": nil, "job": nil, "record_id": nil}}, + map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "job", "milestone_id": nil, "factory": "api", "job": float64(1107), "record_id": nil}}, + map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "decision", "milestone_id": nil, "factory": nil, "job": nil, "record_id": "r1"}}, + }} + var decoded any + if err := json.Unmarshal(rawJSON, &decoded); err != nil { + t.Fatalf("unmarshal example.json: %v", err) + } + if !reflect.DeepEqual(decoded, example) { + t.Errorf("example.json differs from the reference example") + } + const expected = "# Spec\n\nKeep `event_id` and [guide](https://example.com/spec).\n\n- Retry safely\n- Record outcomes\n\n3. Ship\n\n```ts\nsend(event)\n```\n\n[live: jobs milestone=m1]\n\n[live: waves]\n\n[live: job api#1107]\n\n[live: decision r1]\n" + if string(rawMD) != expected { + t.Errorf("example.md: got %q, want %q", string(rawMD), expected) + } + got, err := Markdown(json.RawMessage(rawJSON)) + if err != nil { + t.Fatalf("Markdown(example.json): unexpected error: %v", err) + } + if got != string(rawMD) { + t.Errorf("Markdown(example.json): got %q, want %q", got, string(rawMD)) + } +} + +// Covers validation contract (c). +func TestMarkdownRefusesInvalidJSON(t *testing.T) { + cases := []struct { + name, input string + }{ + {"unclosed brace", `{`}, + {"trailing comma", `{"type":"doc",}`}, + {"bare word", `nope`}, + {"trailing token", `{"type":"doc"} x`}, + {"unclosed array", `[1,2`}, + {"unclosed content array", `{"type":"doc","content":[`}, + {"nesting past encoding/json limit", strings.Repeat("[", 10001) + strings.Repeat("]", 10001)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Markdown(json.RawMessage(tc.input)) + if err == nil { + t.Fatalf("%s: expected error, got nil (output %q)", tc.name, got) + } + if !strings.HasPrefix(err.Error(), "pagedoc: ") { + t.Errorf("%s: error %q does not start with %q", tc.name, err.Error(), "pagedoc: ") + } + if got != "" { + t.Errorf("%s: got %q, want %q", tc.name, got, "") + } + }) + } +} + +// Covers validation contract (c). +func TestMarkdownTreatsAnAbsentDocumentAsEmpty(t *testing.T) { + cases := []struct { + name string + input json.RawMessage + }{ + {"nil", nil}, + {"empty", json.RawMessage("")}, + {"whitespace only", json.RawMessage(" \n")}, + {"json null", json.RawMessage("null")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Markdown(tc.input) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.name, err) + } + if got != "" { + t.Errorf("%s: got %q, want %q", tc.name, got, "") + } + }) + } +} + +// Covers validation contract (c). +func TestMarkdownRendersADeepDocumentWithoutPanicking(t *testing.T) { + deep := strings.Repeat(`{"type":"future","content":[`, 4000) + `{"type":"text","text":"x"}` + strings.Repeat(`]}`, 4000) + got, err := Markdown(json.RawMessage(deep)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "x\n" { + t.Errorf("deep document: got %q, want %q", got, "x\n") + } +} + +// Covers validation contract (d) and (e). +func TestMarkdownMatchesTheReferenceOnEdgeCases(t *testing.T) { + runCases(t, []testCase{ + {"1 line separator u2028 triggers line-start escape", doc(`{"type":"paragraph","content":[{"type":"text","text":"a\u2028# b"}]}`), "a\u2028\\# b\n"}, + {"2 newline inside link destination triggers line-start escape", doc(`{"type":"paragraph","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"a\n- b"}}]}]}`), "[x](a\n\\-%20b)\n"}, + {"3 bulletList with direct paragraph child", doc(`{"type":"bulletList","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}`), "- a\n"}, + {"4 bulletList with null child", doc(`{"type":"bulletList","content":[null]}`), "-\n"}, + {"5 orderedList with null children", doc(`{"type":"orderedList","content":[null,null]}`), "1.\n2.\n"}, + {"6 unknown inline node with code mark and embedded backtick", doc(`{"type":"paragraph","content":[{"type":"mention","text":"a` + "`" + `b","marks":[{"type":"code"}]}]}`), "``a`b``\n"}, + {"7 unknown inline node with link mark breaks link run", doc(`{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]},{"type":"x","text":"b","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]},{"type":"text","text":"c","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]}]}`), "[a](https://e.x)b[c](https://e.x)\n"}, + {"8 first link mark with empty href hides later link mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"link","attrs":{"href":""}},{"type":"link","attrs":{"href":"https://x"}}]}]}`), "a\n"}, + {"9 text node at block level", doc(`{"type":"text","text":"*a*"}`), "\\*a\\*\n"}, + {"10 nested doc inside doc", doc(doc(`{"type":"paragraph","content":[{"type":"text","text":"a"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"b"}]}`)), "a\n\nb\n"}, + {"11 bare paragraph root without doc", `{"type":"paragraph","content":[{"type":"text","text":"x"}]}`, "x\n"}, + {"12 lf followed by cr becomes two spaces", doc(`{"type":"paragraph","content":[{"type":"text","text":"a\n\rb"}]}`), "a b\n"}, + {"13 non-breaking spaces preserved at paragraph edges", doc(`{"type":"paragraph","content":[{"type":"text","text":"\u00a0x\u00a0"}]}`), "\u00a0x\u00a0\n"}, + {"14 heading text starting with hash is not line-start escaped", doc(`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"# x"}]}`), "# # x\n"}, + {"15 codeBlock collects nested descendant text", doc(`{"type":"codeBlock","content":[{"type":"text","text":"a"},{"type":"x","text":"b","content":[{"type":"text","text":"c"}]}]}`), "```\nabc\n```\n"}, + {"16 codeBlock ignores non-string language", doc(`{"type":"codeBlock","attrs":{"language":5},"content":[{"type":"text","text":"x"}]}`), "```\nx\n```\n"}, + {"17a liveBlock empty kind", doc(`{"type":"liveBlock","attrs":{"kind":""}}`), "[live: unknown]\n"}, + {"17b liveBlock null attrs", doc(`{"type":"liveBlock","attrs":null}`), "[live: unknown]\n"}, + {"17c liveBlock array attrs", doc(`{"type":"liveBlock","attrs":[]}`), "[live: unknown]\n"}, + {"18 bulletList codeBlock with crlf indents cr line", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"codeBlock","content":[{"type":"text","text":"a\r\nb"}]}]}]}`), "- ```\n a\r\n b\n ```\n"}, + {"19 bulletList codeBlock with blank line keeps blank line empty", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"codeBlock","content":[{"type":"text","text":"a\n\nb"}]}]}]}`), "- ```\n a\n\n b\n ```\n"}, + {"20 orderedList start 1e21 uses exponential format", doc(`{"type":"orderedList","attrs":{"start":1e21},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1e+21. x\n"}, + {"21 orderedList start 1e20 uses fixed format", doc(`{"type":"orderedList","attrs":{"start":1e20},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "100000000000000000000. x\n"}, + {"22 orderedList start -0 renders as 0", doc(`{"type":"orderedList","attrs":{"start":-0},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}`), "0. a\n1. b\n"}, + {"23 orderedList start -2 counts up through negative integers", doc(`{"type":"orderedList","attrs":{"start":-2},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}`), "-2. a\n-1. b\n"}, + {"24a orderedList fractional start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":2.5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, + {"24b orderedList string start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":"3"},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, + {"24c orderedList infinite start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":1e400},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, + {"25 orderedList start 3.0 is accepted as integer", doc(`{"type":"orderedList","attrs":{"start":3.0},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "3. x\n"}, + {"26 liveBlock job -0 renders as 0", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-0}}`), "[live: job api#0]\n"}, + {"27 liveBlock job 1e21 uses exponential format", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1e21}}`), "[live: job api#1e+21]\n"}, + {"28 liveBlock job 3.0 renders as 3", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":3.0}}`), "[live: job api#3]\n"}, + {"29 heading level 2.9 truncates to 2", doc(`{"type":"heading","attrs":{"level":2.9},"content":[{"type":"text","text":"T"}]}`), "## T\n"}, + {"30a heading string level falls back to 1", doc(`{"type":"heading","attrs":{"level":"2"},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, + {"30b heading infinite level falls back to 1", doc(`{"type":"heading","attrs":{"level":1e400},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, + {"30c heading level -0.5 clamps to 1", doc(`{"type":"heading","attrs":{"level":-0.5},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, + }) +} diff --git a/internal/workstream/pagedoc/testdata/example.json b/internal/workstream/pagedoc/testdata/example.json new file mode 100644 index 0000000000..a8cfa438e9 --- /dev/null +++ b/internal/workstream/pagedoc/testdata/example.json @@ -0,0 +1,25 @@ +{ + "type": "doc", + "content": [ + { "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Spec" }] }, + { "type": "paragraph", "content": [ + { "type": "text", "text": "Keep " }, + { "type": "text", "text": "event_id", "marks": [{ "type": "code" }] }, + { "type": "text", "text": " and " }, + { "type": "text", "text": "guide", "marks": [{ "type": "link", "attrs": { "href": "https://example.com/spec" } }] }, + { "type": "text", "text": "." } + ] }, + { "type": "bulletList", "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Retry safely" }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Record outcomes" }] }] } + ] }, + { "type": "orderedList", "attrs": { "start": 3 }, "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Ship" }] }] } + ] }, + { "type": "codeBlock", "attrs": { "language": "ts" }, "content": [{ "type": "text", "text": "send(event)" }] }, + { "type": "liveBlock", "attrs": { "kind": "jobs", "milestone_id": "m1", "factory": null, "job": null, "record_id": null } }, + { "type": "liveBlock", "attrs": { "kind": "waves", "milestone_id": null, "factory": null, "job": null, "record_id": null } }, + { "type": "liveBlock", "attrs": { "kind": "job", "milestone_id": null, "factory": "api", "job": 1107, "record_id": null } }, + { "type": "liveBlock", "attrs": { "kind": "decision", "milestone_id": null, "factory": null, "job": null, "record_id": "r1" } } + ] +} diff --git a/internal/workstream/pagedoc/testdata/example.md b/internal/workstream/pagedoc/testdata/example.md new file mode 100644 index 0000000000..36608d30f2 --- /dev/null +++ b/internal/workstream/pagedoc/testdata/example.md @@ -0,0 +1,20 @@ +# Spec + +Keep `event_id` and [guide](https://example.com/spec). + +- Retry safely +- Record outcomes + +3. Ship + +```ts +send(event) +``` + +[live: jobs milestone=m1] + +[live: waves] + +[live: job api#1107] + +[live: decision r1] From d7456f8638420048c2d3b2fd2f5700de08f25add Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:10:19 -0500 Subject: [PATCH 04/20] app: workstream store (schema 3, factory_job refs, all 25 methods served) (#2270) * collections: schema 3 with workstream tables and the factory_job ref kind The collections database moves to schema 3. The first write rebuilds a schema 1 or 2 file by copying its tables (SQLite cannot change a CHECK in place), keeping every collection and membership under the same sequence numbers and times, and adds the workstream tables: rows keyed by the collection id, pages and their versions, records, go-aheads, the plan, and a job claim table whose unique index keeps a Factory job in at most one workstream. factory_job joins the ref kinds in the SQLite CHECK, the workspace model and the wire validator, with its factory id on the ref. Co-Authored-By: Claude Opus 5.5 * workstream: domain package for the workstream store Stage, numbers, plan, pages with versions, records and go-aheads, written over the collections store's transactions. Every rule the Workstreams plan (WP-1) gives the store lives here behind plain Go types, so the wire never reaches into the tables. Co-Authored-By: Claude Opus 5.5 * app: serve the workstream methods from the store Every workstream.*, page.*, record.* and goahead.* method now answers from the store, behind bounds checks at the wire's one door, and hello announces workstreams. Create makes the collection, the row, an Overview page and one bare agent (no first turn), and a repeated client id returns the first. Every write sends workstream.changed. Stage and numbers come from bounded Factory queue reads and the agent's recorded spend; anything unknown is left out rather than sent as zero. Collections mark workstreams, refuse to delete one, and refuse a job another workstream holds with in_other_workstream. Co-Authored-By: Claude Opus 5.5 * docs: change entry for the workstream store Co-Authored-By: Claude Opus 5.5 * app: workstream locks unlock from a defer, factory reads are guarded The guard laws want every lock released by a defer and every goroutine guarded; the workstream Factory cache and batch reads now are. Update, mark done and reopen answer with the same live numbers as get, and workstream text refuses control characters (prose keeps newline and tab). Co-Authored-By: Claude Opus 5.5 * workstream: page.get renders markdown through pagedoc The engine keeps one page renderer: page.get's markdown now comes from pagedoc.Markdown, which matches the desktop app's renderer byte for byte, and the store's own renderer is gone. Co-Authored-By: Claude Opus 5.5 * app: a panicking Factory read still finishes its flight; writes keep the first start A queue read that panics inside its guard now closes its flight and caches the failure, so waiters are released and a later read tries again. Update, mark done and reopen persist the first observed start like get does and announce one workstream.changed for the whole change. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/app.go | 1 + cmd/codeaf/appcollections.go | 40 +- cmd/codeaf/appworkstreams.go | 963 +++++++++++- cmd/codeaf/appworkstreams_test.go | 1313 ++++++++++++++++- cmd/codeaf/chatv3_layout.go | 3 + cmd/codeaf/collections.go | 17 +- cmd/codeaf/collections_factory_job_test.go | 53 + .../unreleased/2270-app-workstreams-store.md | 27 + docs/design/app-v3/PROTOCOL.md | 44 +- internal/appwire/collections.go | 20 +- .../appwire/collections_factory_job_test.go | 41 + internal/appwire/workstreams.go | 24 +- internal/appwire/workstreams_check.go | 372 +++++ internal/appwire/workstreams_check_test.go | 137 ++ internal/manual/chat/collections.md | 65 +- internal/workspace/schema3_test.go | 212 +++ internal/workspace/store.go | 415 +++--- internal/workspace/views.go | 50 +- internal/workspace/workspace.go | 25 +- internal/workspace/workstreams.go | 233 +++ internal/workspace/workstreams_test.go | 162 ++ internal/workstream/goaheads.go | 203 +++ internal/workstream/legacy_test.go | 76 + internal/workstream/pages.go | 514 +++++++ internal/workstream/pages_test.go | 217 +++ internal/workstream/plan.go | 156 ++ internal/workstream/records.go | 223 +++ internal/workstream/records_goaheads_test.go | 240 +++ internal/workstream/store.go | 535 +++++++ internal/workstream/store_test.go | 215 +++ internal/workstream/workstream.go | 233 +++ 31 files changed, 6444 insertions(+), 385 deletions(-) create mode 100644 cmd/codeaf/collections_factory_job_test.go create mode 100644 docs/changes/unreleased/2270-app-workstreams-store.md create mode 100644 internal/appwire/collections_factory_job_test.go create mode 100644 internal/appwire/workstreams_check.go create mode 100644 internal/appwire/workstreams_check_test.go create mode 100644 internal/workspace/schema3_test.go create mode 100644 internal/workspace/workstreams.go create mode 100644 internal/workspace/workstreams_test.go create mode 100644 internal/workstream/goaheads.go create mode 100644 internal/workstream/legacy_test.go create mode 100644 internal/workstream/pages.go create mode 100644 internal/workstream/pages_test.go create mode 100644 internal/workstream/plan.go create mode 100644 internal/workstream/records.go create mode 100644 internal/workstream/records_goaheads_test.go create mode 100644 internal/workstream/store.go create mode 100644 internal/workstream/store_test.go create mode 100644 internal/workstream/workstream.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 64b3af9959..a7da5fbdf5 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -725,6 +725,7 @@ type appBackend struct { collections *workspace.Store collectionsClosed bool collectionsPath string + workstreams appWorkstreams // services is the app's one supervisor of kept-running processes // (appservices.go); nil is the capability absent. services *appServices diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 4471d60792..370454a14e 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -35,6 +35,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream" ) // appCollectionsPath is where the collections store is, the CLI's own default. @@ -97,11 +98,16 @@ func (b *appBackend) closeCollections() { // appCollectionError is a store refusal in the wire's codes. func appCollectionError(err error) error { var wire *appwire.Error + var taken *workspace.WorkstreamTaken switch { case err == nil: return nil case errors.As(err, &wire): return wire + case errors.As(err, &taken): + return &appwire.Error{Code: appwire.CodeInOtherWorkstream, Message: taken.Error(), Detail: appwire.WorkstreamTaken{Factory: taken.Factory, Job: taken.Job, WorkstreamID: taken.WorkstreamID, WorkstreamName: taken.WorkstreamName}} + case errors.Is(err, workspace.ErrWorkstreamDelete): + return appwire.Errorf(appwire.CodeBadParams, "%s", err) case errors.Is(err, workspace.ErrNameTaken): return appwire.Errorf(appwire.CodeConflict, "%s", workspace.ErrNameTaken.Error()) case errors.Is(err, workspace.ErrCycle): @@ -123,11 +129,11 @@ func (b *appBackend) collectionChanged(id string, deleted bool) { } func appStoreRef(r appwire.Ref) workspace.Ref { - return workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: r.SessionID} + return workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: r.SessionID, Factory: r.Factory} } func appWireRef(r workspace.Ref) appwire.Ref { - return appwire.Ref{Kind: string(r.Kind), ID: r.ID, SessionID: r.SessionID} + return appwire.Ref{Kind: string(r.Kind), ID: r.ID, SessionID: r.SessionID, Factory: r.Factory} } // appMoment is a stored moment on the wire: absent when unknown. @@ -144,7 +150,7 @@ func appWireMember(m workspace.Member) appwire.Member { } func appWireCollection(s workspace.Summary) appwire.Collection { - return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds} + return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds, Workstream: s.Workstream} } // describe is one collection as the wire says it. @@ -202,10 +208,20 @@ func (b *appBackend) RenameCollection(id, name string) (appwire.Collection, erro if err != nil { return appwire.Collection{}, err } + before, err := s.Describe(context.Background(), id) + if err != nil { + return appwire.Collection{}, appCollectionError(err) + } + if before.Workstream && before.Name == name { + return appWireCollection(before), nil + } if err := s.Rename(context.Background(), id, name); err != nil { return appwire.Collection{}, appCollectionError(err) } b.collectionChanged(id, false) + if before.Workstream && before.Name != name { + b.workstreamCollectionChanged(id, workstream.PartWorkstream) + } return describeCollection(s, id) } @@ -237,6 +253,7 @@ func (b *appBackend) DeleteCollection(id string) error { b.collectionChanged(id, true) for _, parent := range parents { b.collectionChanged(parent, false) + b.workstreamCollectionChanged(parent, workstream.PartJobs) } return nil } @@ -268,6 +285,7 @@ func (b *appBackend) AddToCollection(id string, refs []appwire.Member) (appwire. } if len(added) > 0 { b.collectionChanged(id, false) + b.workstreamCollectionChanged(id, workstream.PartJobs) } return out, nil } @@ -292,6 +310,7 @@ func (b *appBackend) RemoveFromCollection(id string, refs []appwire.Ref) (appwir } if len(removed) > 0 { b.collectionChanged(id, false) + b.workstreamCollectionChanged(id, workstream.PartJobs) } return out, nil } @@ -308,6 +327,7 @@ func (b *appBackend) OpenCollection(id string, depth, limit int, after string) ( return appwire.CollectionOpenResult{}, appCollectionError(err) } r := newCollectionResolver(b) + r.prepareWorkstreamTree(tree.Level) var nodes func(level workspace.Level) []appwire.Node nodes = func(level workspace.Level) []appwire.Node { out := make([]appwire.Node, 0, len(level.Members)) @@ -359,6 +379,7 @@ func (b *appBackend) ExpandCollection(id string, depth, limit int) (appwire.Coll Missing: e.Deleted, Unread: e.Unread, Collections: e.Collections, More: e.More, } r := newCollectionResolver(b) + r.prepareWorkstreamRefs(e.Refs) for _, m := range e.Refs { target := r.target(m.Ref) switch target.State { @@ -400,6 +421,7 @@ func (b *appBackend) RelinkCollection(id string, from, to appwire.Ref) (appwire. // Relinking a member to itself changed nothing, so it says nothing. if from != to { b.collectionChanged(id, false) + b.workstreamCollectionChanged(id, workstream.PartJobs) } return appwire.CollectionRelinkResult{Member: appWireMember(m), Already: already}, nil } @@ -499,9 +521,10 @@ type collectionResolver struct { crewByID map[string]int crewsErr error - tasks map[string]appOwned[appwire.Task] - procs map[string]appOwned[appwire.Process] - git map[string]*appGitTree + tasks map[string]appOwned[appwire.Task] + procs map[string]appOwned[appwire.Process] + git map[string]*appGitTree + factoryTargets map[workspace.Ref]appwire.Target } // appOwned is one agent's tasks or processes by wire id, or why they could @@ -517,6 +540,11 @@ func newCollectionResolver(b *appBackend) *collectionResolver { func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { switch ref.Kind { + case workspace.FactoryJobKind: + if _, ok := r.factoryTargets[ref]; !ok { + r.prepareWorkstreamRefs([]workspace.Member{{Ref: ref}}) + } + return r.factoryTargets[ref] case workspace.ConversationKind: a, not := r.agent(ref.ID) if not != nil { diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 6287c13030..138e9753de 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -1,108 +1,965 @@ package main -import "github.com/Agent-Field/codeaf/internal/appwire" +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream" +) var _ appwire.Workstreamer = (*appBackend)(nil) -// WP-1 makes this true when the store lands. -func (b *appBackend) ServesWorkstreams() bool { return false } +const ( + workstreamFactoryTimeout = 3 * time.Second + workstreamFactoryFresh = 10 * time.Second + workstreamFactoryCacheMax = 32 + workstreamMissingJobsMax = 20 + workstreamFactoryParallel = 8 +) + +// appWorkstreams belongs to one backend, so cache entries and create retries +// cannot cross profiles. The function seams replace external owners in tests. +type appWorkstreams struct { + createMu sync.Mutex + createAgent func(appwire.CreateAsk) (appwire.Agent, error) + closeAgent func(string) error + archiveChats func([]string) error + queue func(context.Context, string) (api.Queue, error) + job func(context.Context, string, int) (api.Job, error) + spend func(string) *float64 + cacheMu sync.Mutex + cache map[string]workstreamQueueRead + flights map[string]chan struct{} +} -func (b *appBackend) WorkstreamList(appwire.WorkstreamListParams) (appwire.WorkstreamListResult, error) { - return appwire.WorkstreamListResult{}, appwire.ErrWorkstreamsNotYet +type workstreamQueueRead struct { + at time.Time + jobs map[int]api.Job + err error } -func (b *appBackend) WorkstreamGet(appwire.WorkstreamGetParams) (appwire.WorkstreamGetResult, error) { - return appwire.WorkstreamGetResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) ServesWorkstreams() bool { return true } + +func (b *appBackend) workstreamStore(create bool) (*workstream.Store, error) { + s, err := b.collectionStore(create) + if err != nil || s == nil { + return nil, err + } + return workstream.New(s, workstream.Options{Now: b.now}), nil } -func (b *appBackend) WorkstreamCreate(appwire.WorkstreamCreateParams) (appwire.WorkstreamCreateResult, error) { - return appwire.WorkstreamCreateResult{}, appwire.ErrWorkstreamsNotYet +// workstreamDo emits committed receipts, including a read's first observation. +// Unchanged and refused writes have empty receipts and do not notify watchers. +func workstreamDo[P interface{ Check() error }, R any](b *appBackend, p P, call func(*workstream.Store) (R, workstream.Change, error)) (R, error) { + var zero R + if err := p.Check(); err != nil { + return zero, err + } + s, err := b.workstreamStore(false) + if err != nil { + return zero, err + } + if s == nil { + return zero, appwire.Errorf(appwire.CodeNotFound, "workstream target not found") + } + out, change, err := call(s) + // A refresh can fail after a write commits; its receipt still belongs to watchers. + b.workstreamChanged(change) + if err != nil { + return zero, appWorkstreamError(err) + } + return out, nil } -func (b *appBackend) WorkstreamUpdate(appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { - return appwire.WorkstreamUpdateResult{}, appwire.ErrWorkstreamsNotYet +func appWorkstreamError(err error) error { + var stale *workstream.VersionConflict + switch { + case errors.As(err, &stale): + return &appwire.Error{Code: appwire.CodeVersionConflict, Message: stale.Error(), Detail: appwire.WorkstreamVersionConflict{ID: stale.ID, Version: stale.Version, UpdatedAt: stale.UpdatedAt, UpdatedBy: stale.UpdatedBy}} + case errors.Is(err, workstream.ErrNotFound): + return appwire.Errorf(appwire.CodeNotFound, "%s", err) + case errors.Is(err, workstream.ErrConflict): + return appwire.Errorf(appwire.CodeConflict, "%s", err) + case errors.Is(err, workstream.ErrCycle): + return appwire.Errorf(appwire.CodeCycle, "%s", err) + case errors.Is(err, workstream.ErrTooLarge): + return appwire.Errorf(appwire.CodeTooLarge, "%s", err) + } + return appCollectionError(err) } -func (b *appBackend) WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams) (appwire.WorkstreamMarkDoneResult, error) { - return appwire.WorkstreamMarkDoneResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) workstreamChanged(change workstream.Change) { + if change.ID == "" || len(change.Parts) == 0 { + return + } + parts := make([]string, 0, len(change.Parts)) + for _, p := range change.Parts { + parts = append(parts, string(p)) + } + b.emit.Emit(appwire.EventWorkstreamChanged, appwire.WorkstreamChanged{ID: change.ID, Parts: parts}) } -func (b *appBackend) WorkstreamReopen(appwire.WorkstreamReopenParams) (appwire.WorkstreamReopenResult, error) { - return appwire.WorkstreamReopenResult{}, appwire.ErrWorkstreamsNotYet +// workstreamCollectionChanged is used after collection writes that share the +// workstream's identity. Only workstream collections gain this second event. +func (b *appBackend) workstreamCollectionChanged(id string, part workstream.ChangedPart) { + s, err := b.existingStore() + if err != nil { + return + } + c, err := s.Describe(context.Background(), id) + if err == nil && c.Workstream { + parts := []workstream.ChangedPart{workstream.PartWorkstream} + if part != workstream.PartWorkstream { + parts = append(parts, part) + } + b.workstreamChanged(workstream.Change{ID: id, Parts: parts}) + } } -func (b *appBackend) WorkstreamPlanGet(appwire.WorkstreamPlanGetParams) (appwire.WorkstreamPlanGetResult, error) { - return appwire.WorkstreamPlanGetResult{}, appwire.ErrWorkstreamsNotYet +func workstreamValue[T any](p *T) T { + if p != nil { + return *p + } + var zero T + return zero +} +func workstreamOptional(s string) *string { + if s == "" { + return nil + } + return &s +} +func workstreamPositive(n int) *int { + if n == 0 { + return nil + } + return &n } -func (b *appBackend) WorkstreamPlanSet(appwire.WorkstreamPlanSetParams) (appwire.WorkstreamPlanSetResult, error) { - return appwire.WorkstreamPlanSetResult{}, appwire.ErrWorkstreamsNotYet +func appWorkstream(w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts, spend *float64) appwire.Workstream { + jobs := w.Jobs() + states := make([]string, 0, w.Total) + for _, j := range jobs { + states = append(states, facts[j].State) + } + numbers := workstream.CountNumbers(jobs, facts, spend, w.Proposed) + return appwire.Workstream{ID: w.ID, Name: w.Name, Why: w.Why, Stage: workstream.Stage(w.DoneAt, w.StartedAt, states), AgentID: w.AgentID, OriginChatID: workstreamOptional(w.OriginChatID), Autonomy: w.Autonomy, Wake: appwire.WorkstreamWake{Morning: w.Wake.Morning, RelatedIssues: w.Wake.RelatedIssues}, BudgetUSD: w.BudgetUSD, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, StartedAt: w.StartedAt, DoneAt: w.DoneAt, HowItWent: workstreamOptional(w.HowItWent), Total: numbers.Total, Merged: numbers.Merged, NeedsYou: numbers.NeedsYou, SpendUSD: numbers.SpendUSD} } -func (b *appBackend) WorkstreamPageList(appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { - return appwire.WorkstreamPageListResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.WorkstreamListResult, error) { + if err := p.Check(); err != nil { + return appwire.WorkstreamListResult{}, err + } + s, err := b.workstreamStore(false) + out := appwire.WorkstreamListResult{Workstreams: []appwire.WorkstreamRow{}} + if err != nil || s == nil { + return out, err + } + rows, err := s.List(context.Background()) + if err != nil { + return out, appWorkstreamError(err) + } + jobs := []workstream.Job{} + for _, w := range rows { + jobs = append(jobs, w.Jobs()...) + } + facts := b.workstreamFacts(jobs) + for _, w := range rows { + var c workstream.Change + w, c, err = b.workstreamObserved(s, w, facts, workstream.Change{}) + b.workstreamChanged(c) + if err != nil { + return appwire.WorkstreamListResult{}, err + } + v := appWorkstream(w, facts, b.workstreamSpend(w.AgentID)) + out.Workstreams = append(out.Workstreams, appwire.WorkstreamRow{ID: v.ID, Name: v.Name, Stage: v.Stage, Total: v.Total, Merged: v.Merged, NeedsYou: v.NeedsYou, SpendUSD: v.SpendUSD, BudgetUSD: v.BudgetUSD, UpdatedAt: v.UpdatedAt}) + } + return out, nil } -func (b *appBackend) WorkstreamPageGet(appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { - return appwire.WorkstreamPageGetResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.WorkstreamGetResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGetResult, workstream.Change, error) { + w, err := s.Get(context.Background(), p.ID) + if err != nil { + return appwire.WorkstreamGetResult{}, workstream.Change{}, err + } + facts := b.workstreamFacts(w.Jobs()) + w, c, err := b.workstreamObserved(s, w, facts, workstream.Change{}) + return appwire.WorkstreamGetResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err + }) } -func (b *appBackend) WorkstreamPageSave(appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { - return appwire.WorkstreamPageSaveResult{}, appwire.ErrWorkstreamsNotYet +// workstreamAgentAsk is the single agent-creation hook for workstreams. Empty +// text is a bare open in prepareCreate and cannot start a model turn. +func workstreamAgentAsk(p appwire.WorkstreamCreateParams) appwire.CreateAsk { + // WP-10 attaches the workstream system prompt and tools here. + return appwire.CreateAsk{Model: workstreamValue(p.Model), CreatedBy: "workstream"} } -func (b *appBackend) WorkstreamPageCreate(appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { - return appwire.WorkstreamPageCreateResult{}, appwire.ErrWorkstreamsNotYet +// workstreamPreflight checks store-owned refusals without writing or migrating. +// The final Create transaction repeats ownership and naming checks for peers. +func workstreamPreflight(s *workspace.Store, p appwire.WorkstreamCreateParams) error { + ctx := context.Background() + return s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + for _, r := range p.Refs { + if r.Kind == appwire.RefCollection { + return workspace.ErrNotFound + } + } + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT name FROM collections") + if err != nil { + return err + } + taken := false + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return err + } + taken = taken || strings.EqualFold(name, strings.TrimSpace(p.Name)) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + if taken { + return workspace.ErrNameTaken + } + for _, r := range p.Refs { + if r.Kind == appwire.RefCollection { + var exists bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM collections WHERE id=?)", r.ID).Scan(&exists); err != nil { + return err + } + if !exists { + return workspace.ErrNotFound + } + } + if version >= 3 && r.Kind == appwire.RefFactoryJob { + var held workspace.WorkstreamTaken + held.Factory = workstreamValue(r.Factory) + held.Job, _ = strconv.Atoi(r.ID) + err := tx.QueryRowContext(ctx, `SELECT j.workstream_id,c.name FROM workstream_jobs j JOIN collections c ON c.id=j.workstream_id WHERE j.factory=? AND j.job=?`, held.Factory, held.Job).Scan(&held.WorkstreamID, &held.WorkstreamName) + if err == nil { + return &held + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + } + } + return nil + }) } -func (b *appBackend) WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams) (appwire.WorkstreamPageUpdateResult, error) { - return appwire.WorkstreamPageUpdateResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire.WorkstreamCreateResult, error) { + var zero appwire.WorkstreamCreateResult + if err := p.Check(); err != nil { + return zero, err + } + b.workstreams.createMu.Lock() + defer b.workstreams.createMu.Unlock() + // A retry is recognized before origin lookup or agent creation. The domain + // checks client_id again inside its transaction to settle competing peers. + existing, err := b.workstreamStore(false) + if err != nil { + return zero, err + } + if existing != nil && workstreamValue(p.ClientID) != "" { + w, err := existing.ByClientID(context.Background(), *p.ClientID) + if err == nil { + return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil + } + if !errors.Is(err, workstream.ErrNotFound) { + return zero, appWorkstreamError(err) + } + } + if p.OriginChatID != nil { + if _, err := b.find(*p.OriginChatID); err != nil { + return zero, err + } + } + store, err := b.collectionStore(false) + if err != nil { + return zero, err + } + if store != nil { + if err := workstreamPreflight(store, p); err != nil { + return zero, appWorkstreamError(err) + } + } else { + for _, ref := range p.Refs { + if ref.Kind == appwire.RefCollection { + return zero, appCollectionError(workspace.ErrNotFound) + } + } + } + create := b.workstreams.createAgent + if create == nil { + create = b.CreateAgent + } + agent, err := create(workstreamAgentAsk(p)) + if err != nil { + return zero, err + } + refs := make([]workspace.Ref, 0, len(p.Refs)) + for _, r := range p.Refs { + refs = append(refs, workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: workstreamValue(r.SessionID), Factory: workstreamValue(r.Factory)}) + } + var w workstream.Workstream + var change workstream.Change + store, err = b.collectionStore(true) + if err == nil { + s := workstream.New(store, workstream.Options{Now: b.now}) + w, change, err = s.Create(context.Background(), workstream.Create{Name: strings.TrimSpace(p.Name), Why: workstreamValue(p.Why), OriginChatID: workstreamValue(p.OriginChatID), Refs: refs, AgentID: agent.ID, ClientID: workstreamValue(p.ClientID)}) + } + if err != nil || w.AgentID != agent.ID { + closeAgent := b.workstreams.closeAgent + if closeAgent == nil { + closeAgent = func(id string) error { return b.MoveToClosed([]string{id}) } + } + if cleanup := closeAgent(agent.ID); cleanup != nil { + if err != nil { + mapped := appWorkstreamError(err) + var wire *appwire.Error + if errors.As(mapped, &wire) { + copy := *wire + copy.Message += "; the unused workstream agent could not be closed" + return zero, © + } + } + return zero, appwire.Errorf(appwire.CodeEngine, "the unused workstream agent could not be closed: %s", appUnaskedReason("the conversation", cleanup)) + } + } + if err != nil { + return zero, appWorkstreamError(err) + } + b.workstreamChanged(change) + if change.ID != "" { + b.collectionChanged(w.ID, false) + } + return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil +} + +func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamUpdateResult, workstream.Change, error) { + patch := workstream.Patch{Name: p.Patch.Name, Why: p.Patch.Why, Autonomy: p.Patch.Autonomy, BudgetUSD: p.Patch.BudgetUSD} + if p.Patch.Wake != nil { + patch.Wake = &workstream.Wake{Morning: p.Patch.Wake.Morning, RelatedIssues: p.Patch.Wake.RelatedIssues} + } + w, c, err := s.Update(context.Background(), p.ID, patch) + if err != nil { + return appwire.WorkstreamUpdateResult{}, c, err + } + if c.ID != "" && p.Patch.Name != nil { + b.collectionChanged(w.ID, false) + } + facts := b.workstreamFacts(w.Jobs()) + w, c, err = b.workstreamObserved(s, w, facts, c) + return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err + }) +} + +func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (appwire.WorkstreamMarkDoneResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamMarkDoneResult, workstream.Change, error) { + w, c, err := s.MarkDone(context.Background(), p.ID, workstreamValue(p.HowItWent)) + if err != nil { + return appwire.WorkstreamMarkDoneResult{}, c, err + } + facts := b.workstreamFacts(w.Jobs()) + w, c, err = b.workstreamObserved(s, w, facts, c) + // The workstream is already durable even if archiving a chat fails. Publish + // that receipt first; retrying mark_done can finish the archive without a + // second workstream mutation or notification. + b.workstreamChanged(c) + if err != nil { + return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err + } + if workstreamValue(p.ArchiveChats) { + ids := []string{} + for _, r := range w.Members { + if r.Kind == workspace.ConversationKind && r.ID != w.AgentID { + ids = append(ids, r.ID) + } + } + archive := b.workstreams.archiveChats + if archive == nil { + archive = b.Archive + } + if len(ids) > 0 { + err = archive(ids) + } + } + if err != nil { + return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err + } + return appwire.WorkstreamMarkDoneResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, workstream.Change{}, nil + }) +} + +func (b *appBackend) WorkstreamReopen(p appwire.WorkstreamReopenParams) (appwire.WorkstreamReopenResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamReopenResult, workstream.Change, error) { + w, c, err := s.Reopen(context.Background(), p.ID) + if err != nil { + return appwire.WorkstreamReopenResult{}, c, err + } + facts := b.workstreamFacts(w.Jobs()) + w, c, err = b.workstreamObserved(s, w, facts, c) + return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err + }) +} + +func appWorkstreamPlan(p workstream.Plan) appwire.WorkstreamPlan { + out := appwire.WorkstreamPlan{WorkstreamID: p.WorkstreamID, Version: p.Version, UpdatedAt: p.UpdatedAt, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}} + for _, m := range p.Milestones { + out.Milestones = append(out.Milestones, appwire.WorkstreamMilestone{ID: m.ID, Title: m.Title}) + } + for _, w := range p.Waves { + wave := appwire.WorkstreamWave{N: w.N, Title: workstreamOptional(w.Title), MilestoneID: workstreamOptional(w.MilestoneID), AutoStart: w.AutoStart, Items: []appwire.WorkstreamPlanItem{}} + for _, i := range w.Items { + wave.Items = append(wave.Items, appwire.WorkstreamPlanItem{Kind: i.Kind, Factory: workstreamOptional(i.Factory), Job: workstreamPositive(i.Job), DraftID: workstreamOptional(i.DraftID), Title: workstreamOptional(i.Title), Repo: workstreamOptional(i.Repo), Text: workstreamOptional(i.Text)}) + } + out.Waves = append(out.Waves, wave) + } + return out +} +func (b *appBackend) WorkstreamPlanGet(p appwire.WorkstreamPlanGetParams) (appwire.WorkstreamPlanGetResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPlanGetResult, workstream.Change, error) { + v, err := s.Plan(context.Background(), p.ID) + return appwire.WorkstreamPlanGetResult{Plan: appWorkstreamPlan(v)}, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamPlanSet(p appwire.WorkstreamPlanSetParams) (appwire.WorkstreamPlanSetResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPlanSetResult, workstream.Change, error) { + milestones := make([]workstream.Milestone, 0, len(p.Milestones)) + waves := make([]workstream.Wave, 0, len(p.Waves)) + for _, m := range p.Milestones { + milestones = append(milestones, workstream.Milestone{ID: m.ID, Title: m.Title}) + } + for _, w := range p.Waves { + wave := workstream.Wave{N: w.N, Title: workstreamValue(w.Title), MilestoneID: workstreamValue(w.MilestoneID), AutoStart: w.AutoStart, Items: []workstream.PlanItem{}} + for _, i := range w.Items { + wave.Items = append(wave.Items, workstream.PlanItem{Kind: i.Kind, Factory: workstreamValue(i.Factory), Job: workstreamValue(i.Job), DraftID: workstreamValue(i.DraftID), Title: workstreamValue(i.Title), Repo: workstreamValue(i.Repo), Text: workstreamValue(i.Text)}) + } + waves = append(waves, wave) + } + v, c, err := s.SetPlan(context.Background(), p.ID, p.BaseVersion, milestones, waves, workstream.Attribution{}) + return appwire.WorkstreamPlanSetResult{Plan: appWorkstreamPlan(v)}, c, err + }) +} + +func appWorkstreamSource(p *workstream.Source) *appwire.WorkstreamSource { + if p == nil { + return nil + } + return &appwire.WorkstreamSource{Kind: p.Kind, ID: p.ID, ChatID: workstreamOptional(p.ChatID), Factory: workstreamOptional(p.Factory), Label: workstreamOptional(p.Label)} +} +func workstreamSource(p *appwire.WorkstreamSource) *workstream.Source { + if p == nil { + return nil + } + return &workstream.Source{Kind: p.Kind, ID: p.ID, ChatID: workstreamValue(p.ChatID), Factory: workstreamValue(p.Factory), Label: workstreamValue(p.Label)} +} +func appWorkstreamPageNode(p workstream.PageNode) appwire.WorkstreamPageNode { + return appwire.WorkstreamPageNode{ID: p.ID, WorkstreamID: p.WorkstreamID, ParentID: workstreamOptional(p.ParentID), Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs, Position: p.Position, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, UpdatedFrom: appWorkstreamSource(p.UpdatedFrom)} +} +func appWorkstreamPage(p workstream.Page) appwire.WorkstreamPage { + n := appWorkstreamPageNode(p.PageNode) + return appwire.WorkstreamPage{ID: n.ID, WorkstreamID: n.WorkstreamID, ParentID: n.ParentID, Title: n.Title, Owner: n.Owner, ReadByJobs: n.ReadByJobs, Position: n.Position, Version: n.Version, UpdatedAt: n.UpdatedAt, UpdatedBy: n.UpdatedBy, UpdatedFrom: n.UpdatedFrom, Doc: p.Doc, Markdown: p.Markdown} +} +func (b *appBackend) WorkstreamPageList(p appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageListResult, workstream.Change, error) { + pages, err := s.Pages(context.Background(), p.WorkstreamID) + out := appwire.WorkstreamPageListResult{Pages: []appwire.WorkstreamPageNode{}} + for _, page := range pages { + out.Pages = append(out.Pages, appWorkstreamPageNode(page)) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamPageGet(p appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageGetResult, workstream.Change, error) { + v, err := s.Page(context.Background(), p.ID, workstreamValue(p.Markdown)) + return appwire.WorkstreamPageGetResult{Page: appWorkstreamPage(v)}, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamPageSave(p appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageSaveResult, workstream.Change, error) { + v, c, err := s.SavePage(context.Background(), p.ID, p.BaseVersion, p.Doc, workstream.Attribution{}) + return appwire.WorkstreamPageSaveResult{Page: appWorkstreamPage(v)}, c, err + }) +} +func (b *appBackend) WorkstreamPageCreate(p appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageCreateResult, workstream.Change, error) { + v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: workstreamValue(p.ReadByJobs), Doc: p.Doc}) + return appwire.WorkstreamPageCreateResult{Page: appWorkstreamPage(v)}, c, err + }) +} +func (b *appBackend) WorkstreamPageUpdate(p appwire.WorkstreamPageUpdateParams) (appwire.WorkstreamPageUpdateResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageUpdateResult, workstream.Change, error) { + v, c, err := s.UpdatePage(context.Background(), p.ID, workstream.PagePatch{Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs}) + return appwire.WorkstreamPageUpdateResult{Page: appWorkstreamPageNode(v)}, c, err + }) +} +func (b *appBackend) WorkstreamPageMove(p appwire.WorkstreamPageMoveParams) (appwire.WorkstreamPageMoveResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageMoveResult, workstream.Change, error) { + v, c, err := s.MovePage(context.Background(), p.ID, workstreamValue(p.ParentID), workstreamValue(p.AfterID)) + return appwire.WorkstreamPageMoveResult{Page: appWorkstreamPageNode(v)}, c, err + }) +} +func (b *appBackend) WorkstreamPageDelete(p appwire.WorkstreamPageDeleteParams) (appwire.WorkstreamPageDeleteResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageDeleteResult, workstream.Change, error) { + v, c, err := s.DeletePage(context.Background(), p.ID) + return appwire.WorkstreamPageDeleteResult{Deleted: v}, c, err + }) +} +func (b *appBackend) WorkstreamPageHistory(p appwire.WorkstreamPageHistoryParams) (appwire.WorkstreamPageHistoryResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageHistoryResult, workstream.Change, error) { + limit := 50 + if p.Limit != nil { + limit = *p.Limit + } + versions, err := s.PageHistory(context.Background(), p.ID, limit) + out := appwire.WorkstreamPageHistoryResult{Versions: []appwire.WorkstreamPageVersion{}} + for _, v := range versions { + out.Versions = append(out.Versions, appwire.WorkstreamPageVersion{Version: v.Version, SavedAt: v.SavedAt, SavedBy: v.SavedBy, From: appWorkstreamSource(v.From), Title: v.Title, Bytes: v.Bytes}) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamPageRestore(p appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageRestoreResult, workstream.Change, error) { + v, c, err := s.RestorePage(context.Background(), p.ID, p.Version, p.BaseVersion, workstream.Attribution{}) + return appwire.WorkstreamPageRestoreResult{Page: appWorkstreamPage(v)}, c, err + }) +} + +func appWorkstreamRecord(r workstream.Record) appwire.WorkstreamRecord { + return appwire.WorkstreamRecord{ID: r.ID, WorkstreamID: r.WorkstreamID, Kind: r.Kind, Text: r.Text, State: r.State, By: r.By, Source: appWorkstreamSource(r.Source), CreatedAt: r.CreatedAt, DecidedAt: r.DecidedAt, Supersedes: workstreamOptional(r.Supersedes), SupersededBy: workstreamOptional(r.SupersededBy)} +} +func (b *appBackend) WorkstreamRecordList(p appwire.WorkstreamRecordListParams) (appwire.WorkstreamRecordListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordListResult, workstream.Change, error) { + records, err := s.Records(context.Background(), p.WorkstreamID, workstreamValue(p.Kind), workstreamValue(p.State)) + out := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} + for _, r := range records { + out.Records = append(out.Records, appWorkstreamRecord(r)) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamRecordPropose(p appwire.WorkstreamRecordProposeParams) (appwire.WorkstreamRecordProposeResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordProposeResult, workstream.Change, error) { + v, c, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: p.WorkstreamID, Kind: p.Kind, Text: p.Text, Source: workstreamSource(p.Source), Kept: workstreamValue(p.Kept)}) + return appwire.WorkstreamRecordProposeResult{Record: appWorkstreamRecord(v)}, c, err + }) +} +func (b *appBackend) WorkstreamRecordKeep(p appwire.WorkstreamRecordKeepParams) (appwire.WorkstreamRecordKeepResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordKeepResult, workstream.Change, error) { + v, c, err := s.KeepRecord(context.Background(), p.ID) + return appwire.WorkstreamRecordKeepResult{Record: appWorkstreamRecord(v)}, c, err + }) +} +func (b *appBackend) WorkstreamRecordDrop(p appwire.WorkstreamRecordDropParams) (appwire.WorkstreamRecordDropResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordDropResult, workstream.Change, error) { + v, c, err := s.DropRecord(context.Background(), p.ID) + return appwire.WorkstreamRecordDropResult{Record: appWorkstreamRecord(v)}, c, err + }) +} +func (b *appBackend) WorkstreamRecordSupersede(p appwire.WorkstreamRecordSupersedeParams) (appwire.WorkstreamRecordSupersedeResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordSupersedeResult, workstream.Change, error) { + v, old, c, err := s.SupersedeRecord(context.Background(), p.ID, workstream.RecordReplacement{Kind: workstreamValue(p.Kind), Text: p.Text, Source: workstreamSource(p.Source)}) + return appwire.WorkstreamRecordSupersedeResult{Record: appWorkstreamRecord(v), Superseded: appWorkstreamRecord(old)}, c, err + }) +} +func appWorkstreamGoAhead(g workstream.GoAhead) appwire.WorkstreamGoAhead { + return appwire.WorkstreamGoAhead{ID: g.ID, WorkstreamID: g.WorkstreamID, Scope: g.Scope, Factory: workstreamOptional(g.Factory), Job: workstreamPositive(g.Job), Wave: workstreamPositive(g.Wave), MilestoneID: workstreamOptional(g.MilestoneID), CapUSD: g.CapUSD, EndsAt: g.EndsAt, State: g.State, EndReason: workstreamOptional(g.EndReason), GrantedAt: g.GrantedAt, EndedAt: g.EndedAt} +} +func (b *appBackend) WorkstreamGoAheadList(p appwire.WorkstreamGoAheadListParams) (appwire.WorkstreamGoAheadListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadListResult, workstream.Change, error) { + grants, err := s.GoAheads(context.Background(), p.WorkstreamID, workstreamValue(p.Ended)) + out := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} + for _, g := range grants { + out.GoAheads = append(out.GoAheads, appWorkstreamGoAhead(g)) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamGoAheadGrant(p appwire.WorkstreamGoAheadGrantParams) (appwire.WorkstreamGoAheadGrantResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadGrantResult, workstream.Change, error) { + v, c, err := s.GrantGoAhead(context.Background(), workstream.GoAheadGrant{WorkstreamID: p.WorkstreamID, Scope: p.Scope, Factory: workstreamValue(p.Factory), Job: workstreamValue(p.Job), Wave: workstreamValue(p.Wave), MilestoneID: workstreamValue(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt}) + return appwire.WorkstreamGoAheadGrantResult{GoAhead: appWorkstreamGoAhead(v)}, c, err + }) +} +func (b *appBackend) WorkstreamGoAheadRevoke(p appwire.WorkstreamGoAheadRevokeParams) (appwire.WorkstreamGoAheadRevokeResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadRevokeResult, workstream.Change, error) { + v, c, err := s.RevokeGoAhead(context.Background(), p.ID) + return appwire.WorkstreamGoAheadRevokeResult{GoAhead: appWorkstreamGoAhead(v)}, c, err + }) } -func (b *appBackend) WorkstreamPageMove(appwire.WorkstreamPageMoveParams) (appwire.WorkstreamPageMoveResult, error) { - return appwire.WorkstreamPageMoveResult{}, appwire.ErrWorkstreamsNotYet +// workstreamQueue shares successful and failed reads briefly, including an +// in-flight read, so concurrent list/get calls cannot multiply Factory work. +func (b *appBackend) workstreamQueue(ctx context.Context, factory string) (entry workstreamQueueRead) { + state := &b.workstreams + entry, flight, readQueue := state.workstreamQueueStart(factory, b.now()) + if flight == nil { + return entry + } + if !readQueue { + select { + case <-flight: + return b.workstreamQueue(ctx, factory) + case <-ctx.Done(): + return workstreamQueueRead{err: ctx.Err()} + } + } + // Complete ownership even when the guarded reader panics. Only a completed + // read replaces the unavailable entry, so a fault cannot publish success. + entry = workstreamQueueRead{err: errors.New("the Factory queue read did not complete")} + defer func() { + entry.at = b.now() + state.workstreamQueueFinish(factory, flight, entry) + }() + read := state.queue + if read == nil { + read = b.FactoryQueue + } + queue, err := read(ctx, factory) + jobs := map[int]api.Job{} + for _, j := range queue.Jobs { + jobs[j.ID] = j + } + entry = workstreamQueueRead{err: err, jobs: jobs} + return entry } -func (b *appBackend) WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams) (appwire.WorkstreamPageDeleteResult, error) { - return appwire.WorkstreamPageDeleteResult{}, appwire.ErrWorkstreamsNotYet +func (state *appWorkstreams) workstreamQueueStart(factory string, now time.Time) (workstreamQueueRead, chan struct{}, bool) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if entry, ok := state.cache[factory]; ok && now.Sub(entry.at) < workstreamFactoryFresh { + return entry, nil, false + } + if flight := state.flights[factory]; flight != nil { + return workstreamQueueRead{}, flight, false + } + if state.flights == nil { + state.flights = map[string]chan struct{}{} + } + flight := make(chan struct{}) + state.flights[factory] = flight + return workstreamQueueRead{}, flight, true } -func (b *appBackend) WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams) (appwire.WorkstreamPageHistoryResult, error) { - return appwire.WorkstreamPageHistoryResult{}, appwire.ErrWorkstreamsNotYet +func (state *appWorkstreams) workstreamQueueFinish(factory string, flight chan struct{}, entry workstreamQueueRead) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if state.cache == nil { + state.cache = map[string]workstreamQueueRead{} + } + if len(state.cache) >= workstreamFactoryCacheMax { + oldest := "" + var at time.Time + for id, v := range state.cache { + if oldest == "" || v.at.Before(at) { + oldest, at = id, v.at + } + } + delete(state.cache, oldest) + } + state.cache[factory] = entry + delete(state.flights, factory) + close(flight) } -func (b *appBackend) WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) { - return appwire.WorkstreamPageRestoreResult{}, appwire.ErrWorkstreamsNotYet +type workstreamJobRead struct { + job api.Job + err error + notFound bool } -func (b *appBackend) WorkstreamRecordList(appwire.WorkstreamRecordListParams) (appwire.WorkstreamRecordListResult, error) { - return appwire.WorkstreamRecordListResult{}, appwire.ErrWorkstreamsNotYet +// workstreamJobReads gives each factory one concurrent queue read. Missing +// jobs share a global fallback allowance and the same overall deadline. +func (b *appBackend) workstreamJobReads(jobs []workstream.Job) map[workstream.Job]workstreamJobRead { + out := map[workstream.Job]workstreamJobRead{} + grouped := map[string][]workstream.Job{} + for _, j := range jobs { + if _, seen := out[j]; !seen { + out[j] = workstreamJobRead{err: context.DeadlineExceeded} + grouped[j.Factory] = append(grouped[j.Factory], j) + } + } + if len(grouped) == 0 { + return out + } + ctx, cancel := context.WithTimeout(context.Background(), workstreamFactoryTimeout) + defer cancel() + var wg sync.WaitGroup + var mu sync.Mutex + missing := 0 + workstreamMissingAllowed := func() bool { + mu.Lock() + defer mu.Unlock() + if missing >= workstreamMissingJobsMax { + return false + } + missing++ + return true + } + workstreamRememberRead := func(j workstream.Job, read workstreamJobRead) { + mu.Lock() + defer mu.Unlock() + out[j] = read + } + slots := make(chan struct{}, workstreamFactoryParallel) + for factory, members := range grouped { + wg.Add(1) + guard.Go("app/workstream-factory", func() { + defer wg.Done() + select { + case slots <- struct{}{}: + defer func() { <-slots }() + case <-ctx.Done(): + return + } + queue := b.workstreamQueue(ctx, factory) + for _, j := range members { + job, ok := queue.jobs[j.Number] + err := queue.err + if err == nil && !ok { + allowed := workstreamMissingAllowed() + switch { + case !allowed: + err = errors.New("the missing-job read allowance was reached") + case ctx.Err() != nil: + err = ctx.Err() + default: + read := b.workstreams.job + if read == nil { + read = b.FactoryJob + } + job, err = read(ctx, j.Factory, j.Number) + if err == nil && job.ID != j.Number { + err = errors.New("the Factory returned another job") + } + } + } + var wire *appwire.Error + notFound := queue.err == nil && errors.As(err, &wire) && wire.Code == appwire.CodeNotFound + workstreamRememberRead(j, workstreamJobRead{job: job, err: err, notFound: notFound}) + } + }) + } + wg.Wait() + return out } -func (b *appBackend) WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams) (appwire.WorkstreamRecordProposeResult, error) { - return appwire.WorkstreamRecordProposeResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) workstreamFacts(jobs []workstream.Job) map[workstream.Job]workstream.JobFacts { + out := map[workstream.Job]workstream.JobFacts{} + for j, read := range b.workstreamJobReads(jobs) { + if read.err != nil { + continue + } + job := read.job + merged := job.PR != nil && job.PR.MergedAt != nil && !job.PR.MergedAt.IsZero() + out[j] = workstream.JobFacts{State: job.State, Merged: &merged, CostUSD: &job.Cost} + } + return out } -func (b *appBackend) WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams) (appwire.WorkstreamRecordKeepResult, error) { - return appwire.WorkstreamRecordKeepResult{}, appwire.ErrWorkstreamsNotYet +// Collection resolution uses the same bounded batch reader, with each job +// asked once even when it appears through several nested collections. +func (r *collectionResolver) prepareWorkstreamRefs(members []workspace.Member) { + jobs := []workstream.Job{} + refs := map[workstream.Job]workspace.Ref{} + for _, member := range members { + if member.Kind != workspace.FactoryJobKind { + continue + } + number, _ := strconv.Atoi(member.ID) + job := workstream.Job{Factory: member.Factory, Number: number} + jobs = append(jobs, job) + refs[job] = member.Ref + } + r.factoryTargets = map[workspace.Ref]appwire.Target{} + for job, read := range r.b.workstreamJobReads(jobs) { + var target appwire.Target + if read.err != nil { + if read.notFound { + target = appwire.Target{State: appwire.TargetDeleted} + } else { + target = appUnknown("the Factory", read.err) + target.Status = "unknown" + } + } else { + target = appwire.Target{State: appwire.TargetLive, Title: read.job.Title, Status: read.job.State, Repo: read.job.Repo} + } + r.factoryTargets[refs[job]] = target + } } -func (b *appBackend) WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams) (appwire.WorkstreamRecordDropResult, error) { - return appwire.WorkstreamRecordDropResult{}, appwire.ErrWorkstreamsNotYet +func (r *collectionResolver) prepareWorkstreamTree(level workspace.Level) { + members := []workspace.Member{} + var collect func(workspace.Level) + collect = func(level workspace.Level) { + for _, node := range level.Members { + if node.Kind == workspace.FactoryJobKind { + members = append(members, node.Member) + } + if node.Children != nil { + collect(*node.Children) + } + } + } + collect(level) + r.prepareWorkstreamRefs(members) } -func (b *appBackend) WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams) (appwire.WorkstreamRecordSupersedeResult, error) { - return appwire.WorkstreamRecordSupersedeResult{}, appwire.ErrWorkstreamsNotYet +// workstreamObserved adds the first observed start to the caller's +// receipt, so mutations announce their write and observation in one event. +func (b *appBackend) workstreamObserved(s *workstream.Store, w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts, c workstream.Change) (workstream.Workstream, workstream.Change, error) { + if !w.StartedAt.IsZero() { + return w, c, nil + } + states := map[workstream.Job]string{} + active := false + for _, j := range w.Jobs() { + states[j] = facts[j].State + switch states[j] { + case "queued", "running", "needs_you", "done": + active = true + } + } + if !active { + return w, c, nil + } + observed, err := s.ObserveJobStates(context.Background(), w.ID, states) + if err != nil { + return w, c, appWorkstreamError(err) + } + if c.ID == "" { + c = observed + } else { + for _, part := range observed.Parts { + if !slices.Contains(c.Parts, part) { + c.Parts = append(c.Parts, part) + } + } + } + // Another reader may have won the write, so reread even with an empty receipt. + w, err = s.Get(context.Background(), w.ID) + return w, c, appWorkstreamError(err) } -func (b *appBackend) WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams) (appwire.WorkstreamGoAheadListResult, error) { - return appwire.WorkstreamGoAheadListResult{}, appwire.ErrWorkstreamsNotYet +func (b *appBackend) workstreamSpend(id string) *float64 { + if id == "" { + return nil + } + if read := b.workstreams.spend; read != nil { + return read(id) + } + a, err := b.find(id) + if err != nil { + row, ok := workstreamAgentRow(id) + if !ok { + return nil + } + b.remember(row) + a, err = b.find(id) + if err != nil { + return nil + } + } + var transcript string + b.locked(func() { transcript = a.transcript }) + if transcript == "" { + return nil + } + if row, ok := session.ReadRows([]string{transcript})[transcript]; ok { + if meta, err := session.LoadMeta(row.Dir); err != nil || meta.ID == "" { + return nil + } + return &row.Spend + } + // The ordinary chat listing deliberately omits bare conversations. Their + // freshly recorded metadata still establishes known zero spend here. + meta, err := session.LoadMeta(session.DirOf(transcript)) + if err == nil && meta.ID != "" && meta.LastUserAt.IsZero() && meta.SpentUSD == 0 { + zero := 0.0 + return &zero + } + return nil } -func (b *appBackend) WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams) (appwire.WorkstreamGoAheadGrantResult, error) { - return appwire.WorkstreamGoAheadGrantResult{}, appwire.ErrWorkstreamsNotYet +// workstreamKeepsConversation protects a deliberately bare agent from both +// app and terminal blank-chat cleanup. Its durable owner is the SQLite row; +// an unreadable owner is a reason to retain a conversation, never to delete it. +func workstreamKeepsConversation(dir string) bool { + path := home.Join("v3", "collections.db") + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return false + } else if err != nil { + return true + } + s, err := workspace.Open(path) + if err != nil { + return true + } + defer s.Close() + kept := false + err = s.Read(context.Background(), func(tx *sql.Tx, version int) error { + if version < 3 { + return nil + } + return tx.QueryRow("SELECT EXISTS(SELECT 1 FROM workstreams WHERE agent_id=?)", filepath.Base(filepath.Clean(dir))).Scan(&kept) + }) + return kept || err != nil } -func (b *appBackend) WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams) (appwire.WorkstreamGoAheadRevokeResult, error) { - return appwire.WorkstreamGoAheadRevokeResult{}, appwire.ErrWorkstreamsNotYet +// workstreamAgentRow restores a bare agent omitted by the ordinary chat +// listing. Workstream reads already obtained its id from the durable owner; +// remembering it lets agents.open use the existing opener after a restart. +func workstreamAgentRow(id string) (session.SessionRow, bool) { + if id == "" || id != filepath.Base(id) || strings.HasPrefix(id, ".") { + return session.SessionRow{}, false + } + root := session.PlacesRoot() + buckets, err := os.ReadDir(root) + if err != nil { + return session.SessionRow{}, false + } + for _, bucket := range buckets { + if !bucket.IsDir() { + continue + } + dir := filepath.Join(root, bucket.Name(), id) + meta, err := session.LoadMeta(dir) + if err != nil || meta.ID != id || !meta.LastUserAt.IsZero() { + continue + } + transcript := (session.Place{Dir: dir}).Transcript() + if _, err := os.Stat(transcript); err != nil { + continue + } + return session.SessionRow{ID: id, Dir: dir, Transcript: transcript, Workspace: meta.Workspace, Owned: meta.Owned, Title: meta.Title, Model: meta.Model, Created: meta.Created, At: meta.Created, Spend: meta.SpentUSD, Archived: meta.Archived}, true + } + return session.SessionRow{}, false } diff --git a/cmd/codeaf/appworkstreams_test.go b/cmd/codeaf/appworkstreams_test.go index 94da8f9508..7351dc9d41 100644 --- a/cmd/codeaf/appworkstreams_test.go +++ b/cmd/codeaf/appworkstreams_test.go @@ -1,142 +1,1311 @@ package main import ( + "bytes" + "context" + "encoding/json" "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" ) -// V4: the real backend implements every contract stub without touching its fields. -func TestAppWorkstreamsAreNotYetServed(t *testing.T) { - var backend appwire.Workstreamer = &appBackend{} - if backend.ServesWorkstreams() { - t.Fatal("the stub backend claims workstreams") +func workstreamPtr[T any](value T) *T { return &value } + +func workstreamBackend(t *testing.T) (*appBackend, *appRecorder, *int, appwire.Agent) { + t.Helper() + b, rec, origin, _ := collectionsBackend(t) + calls := new(int) + b.workstreams.createAgent = func(ask appwire.CreateAsk) (appwire.Agent, error) { + *calls++ + if ask.Text != "" || ask.Workspace != "" || ask.CreatedBy != "workstream" || len(ask.Attachments) != 0 { + t.Errorf("agent must be a bare default-workspace open: %+v", ask) + } + return appwire.Agent{ID: fmt.Sprintf("workstream-agent-%d", *calls)}, nil } - calls := []struct { - name string - call func() error + b.workstreams.spend = func(string) *float64 { return workstreamPtr(0.0) } + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{}, appwire.Errorf(appwire.CodeNotFound, "no Factory attached") + } + return b, rec, calls, origin +} + +func workstreamCreateForTest(t *testing.T, b *appBackend, name string, refs ...appwire.WorkstreamRef) appwire.Workstream { + t.Helper() + v, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: name, Refs: refs}) + if err != nil { + t.Fatal(err) + } + return v.Workstream +} +func workstreamJobRef(number string) appwire.WorkstreamRef { + return appwire.WorkstreamRef{Kind: appwire.RefFactoryJob, ID: number, Factory: workstreamPtr("f")} +} +func workstreamEvents(rec *appRecorder) []appwire.WorkstreamChanged { + rec.mu.Lock() + defer rec.mu.Unlock() + out := []appwire.WorkstreamChanged{} + for _, data := range rec.data { + if event, ok := data.(appwire.WorkstreamChanged); ok { + out = append(out, event) + } + } + return out +} +func workstreamCheckError(t *testing.T, err error, code string) *appwire.Error { + t.Helper() + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != code { + t.Fatalf("got %v, want %s", err, code) + } + return wire +} + +// (c): the public create/list/page/collection interfaces expose one identity, +// one Overview and one agent, including simultaneous retries with one client id. +func TestAppWorkstreamCreateIsIdempotent(t *testing.T) { + b, rec, calls, origin := workstreamBackend(t) + ask := appwire.WorkstreamCreateParams{Name: "Launch", OriginChatID: &origin.ID, ClientID: workstreamPtr("create-one"), Model: workstreamPtr("test-model")} + originalCreate := b.workstreams.createAgent + b.workstreams.createAgent = func(p appwire.CreateAsk) (appwire.Agent, error) { + if p.Model != "test-model" { + t.Errorf("model = %q", p.Model) + } + return originalCreate(p) + } + var wg sync.WaitGroup + results := make(chan appwire.WorkstreamCreateResult, 8) + errs := make(chan error, 8) + for range 8 { + wg.Add(1) + guard.Go("app/workstream-create-test", func() { + defer wg.Done() + v, err := b.WorkstreamCreate(ask) + results <- v + errs <- err + }) + } + wg.Wait() + close(results) + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + var id, agentID string + for v := range results { + if id == "" { + id, agentID = v.Workstream.ID, v.Workstream.AgentID + } + if v.Workstream.ID != id || v.Workstream.AgentID != agentID || v.Workstream.Stage != "shaping" { + t.Fatalf("create: %+v", v) + } + } + if *calls != 1 { + t.Fatalf("created %d agents", *calls) + } + rows, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil || len(rows.Workstreams) != 1 { + t.Fatalf("rows %+v, %v", rows, err) + } + collections, err := b.ListCollections(nil, "", 200) + if err != nil || len(collections.Collections) != 1 || collections.Collections[0].ID != id || !collections.Collections[0].Workstream { + t.Fatalf("collections %+v, %v", collections, err) + } + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: id}) + if err != nil || len(pages.Pages) != 1 || pages.Pages[0].Title != "Overview" || pages.Pages[0].Version != 1 { + t.Fatalf("pages %+v, %v", pages, err) + } + opened, err := b.OpenCollection(id, 1, 200, "") + if err != nil || len(opened.Members) != 2 { + t.Fatalf("members %+v, %v", opened, err) + } + ids := []string{} + for _, node := range opened.Members { + ids = append(ids, node.ID) + } + if !slices.Contains(ids, origin.ID) || !slices.Contains(ids, agentID) { + t.Fatalf("chat members: %v", ids) + } + if len(workstreamEvents(rec)) != 1 { + t.Fatalf("retry emitted: %+v", workstreamEvents(rec)) + } +} + +// (b): both doors refuse the whole request and retain both timestamps and all +// memberships. A plain collection remains free to reference the same job. +func TestAppWorkstreamOwnershipRefusal(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + a := workstreamCreateForTest(t, b, "Owner", workstreamJobRef("7")) + other := workstreamCreateForTest(t, b, "Other") + beforeEvents := len(workstreamEvents(rec)) + beforeCalls := *calls + refs := []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefConversation, ID: "extra"}}, {Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}} + _, err := b.AddToCollection(other.ID, refs) + wire := workstreamCheckError(t, err, appwire.CodeInOtherWorkstream) + want := appwire.WorkstreamTaken{Factory: "f", Job: 7, WorkstreamID: a.ID, WorkstreamName: a.Name} + if !reflect.DeepEqual(wire.Detail, want) { + t.Fatalf("detail %+v", wire.Detail) + } + _, err = b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Refused", Refs: []appwire.WorkstreamRef{{Kind: appwire.RefConversation, ID: "extra"}, workstreamJobRef("7")}}) + wire = workstreamCheckError(t, err, appwire.CodeInOtherWorkstream) + if !reflect.DeepEqual(wire.Detail, want) || *calls != beforeCalls { + t.Fatalf("preflight: %+v, %d agents", wire, *calls) + } + for _, before := range []appwire.Workstream{a, other} { + after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: before.ID}) + if err != nil || after.Workstream.UpdatedAt != before.UpdatedAt || after.Workstream.Total != before.Total { + t.Fatalf("refusal changed %+v: %+v, %v", before, after, err) + } + } + opened, err := b.OpenCollection(other.ID, 1, 200, "") + if err != nil || len(opened.Members) != 1 { + t.Fatalf("partial membership: %+v, %v", opened, err) + } + if len(workstreamEvents(rec)) != beforeEvents { + t.Fatal("refusal emitted a workstream event") + } + plain, err := b.DuplicateCollection(a.ID, "Plain copy") + if err != nil || plain.Workstream { + t.Fatalf("duplicate %+v, %v", plain, err) + } + if _, err := b.AddToCollection(plain.ID, refs); err != nil { + t.Fatal(err) + } + wire = workstreamCheckError(t, b.DeleteCollection(a.ID), appwire.CodeBadParams) + if wire.Message != "mark a workstream done instead" { + t.Fatal(wire) + } +} + +// (d), (j): job state comes from recorded Factory data; an observed transition +// is persisted and announced once. Issue numbers cannot alias document ids. +func TestAppWorkstreamStageAndMeasuredNumbers(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + state := "backlog" + reads := 0 + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + reads++ + job := api.Job{ID: 7, Num: 1107, State: state, Cost: 0, Title: "Ship", Repo: "acme/api"} + if state == "done" { + at := time.Now() + job.PR = &api.PR{MergedAt: &at} + } + return api.Queue{Jobs: []api.Job{job}}, nil + } + w := workstreamCreateForTest(t, b, "Stages", workstreamJobRef("7")) + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Stage != "shaping" || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 0 { + t.Fatalf("backlog %+v, %v", got, err) + } + encoded, err := json.Marshal(got) + if err != nil || !bytes.Contains(encoded, []byte(`"spend_usd":0`)) || !bytes.Contains(encoded, []byte(`"merged":0`)) || !bytes.Contains(encoded, []byte(`"needs_you":0`)) { + t.Fatalf("measured zero omitted: %s, %v", encoded, err) + } + before := len(workstreamEvents(rec)) + state = "queued" + func() { + b.workstreams.cacheMu.Lock() + defer b.workstreams.cacheMu.Unlock() + b.workstreams.cache = nil + }() + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Stage != "building" || got.Workstream.StartedAt.IsZero() { + t.Fatalf("queued %+v, %v", got, err) + } + if len(workstreamEvents(rec)) != before+1 { + t.Fatal("first observation did not emit exactly once") + } + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || len(workstreamEvents(rec)) != before+1 || reads != 2 { + t.Fatalf("cached read: %+v, %v, reads %d", got, err, reads) + } + state = "done" + b.workstreams.cache = nil + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Stage != "building" || got.Workstream.Merged == nil || *got.Workstream.Merged != 1 { + t.Fatalf("all merged %+v, %v", got, err) + } + done, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) + if err != nil || done.Workstream.Stage != "done" { + t.Fatalf("done %+v, %v", done, err) + } + state = "backlog" + b.workstreams.cache = nil + reopened, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}) + if err != nil || reopened.Workstream.Stage != "building" { + t.Fatalf("reopen %+v, %v", reopened, err) + } +} + +// (j): mutation results retain the same measured job and agent numbers as get. +func TestAppWorkstreamMutationNumbersMatchGet(t *testing.T) { + for _, method := range []string{"update", "mark_done", "reopen", "mark_done_again"} { + t.Run(method, func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + reads := 0 + mergedAt := time.Now() + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + reads++ + return api.Queue{Jobs: []api.Job{{ID: 7, Num: 1107, State: "needs_you", Cost: 1.25, PR: &api.PR{MergedAt: &mergedAt}}}}, nil + } + b.workstreams.spend = func(string) *float64 { return workstreamPtr(2.5) } + w := workstreamCreateForTest(t, b, "Mutation numbers", workstreamJobRef("7")) + if _, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Ready?"}); err != nil { + t.Fatal(err) + } + before := w + if method == "reopen" || method == "mark_done_again" { + done, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID, HowItWent: workstreamPtr("First report")}) + if err != nil { + t.Fatal(err) + } + before = done.Workstream + } + events := len(workstreamEvents(rec)) + var result appwire.Workstream + switch method { + case "update": + v, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Updated")}}) + result = v.Workstream + if err != nil { + t.Fatal(err) + } + case "mark_done", "mark_done_again": + v, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID, HowItWent: workstreamPtr("New report")}) + result = v.Workstream + if err != nil { + t.Fatal(err) + } + case "reopen": + v, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}) + result = v.Workstream + if err != nil { + t.Fatal(err) + } + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || !reflect.DeepEqual(result, got.Workstream) { + t.Fatalf("mutation %+v differs from get %+v: %v", result, got.Workstream, err) + } + if result.Merged == nil || *result.Merged != 1 || result.NeedsYou == nil || *result.NeedsYou != 2 || result.SpendUSD == nil || *result.SpendUSD != 3.75 { + t.Fatalf("measured numbers: %+v", result) + } + if reads != 1 { + t.Fatalf("queue reads = %d, want one cached read", reads) + } + if method == "reopen" && result.Stage != "building" { + t.Fatalf("reopen stage = %q", result.Stage) + } + if method == "mark_done_again" && (!reflect.DeepEqual(result, before) || len(workstreamEvents(rec)) != events) { + t.Fatal("marking an already done workstream changed it or emitted an event") + } + }) + } +} + +// (d), (i), (j): a mutation observes the first running job before any get, +// publishes one combined receipt, and keeps that start through Factory failure. +func TestAppWorkstreamMutationPersistsFirstStart(t *testing.T) { + for _, method := range []string{"update", "mark_done", "reopen"} { + t.Run(method, func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + b.now = func() time.Time { return time.UnixMilli(clock.Load()) } + reads := 0 + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + reads++ + if reads > 1 { + return api.Queue{}, errors.New("unreachable") + } + return api.Queue{Jobs: []api.Job{{ID: 7, State: "running", Cost: 1.25}}}, nil + } + w := workstreamCreateForTest(t, b, "First start", workstreamJobRef("7")) + events := len(workstreamEvents(rec)) + var result appwire.Workstream + switch method { + case "update": + v, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Updated before get")}}) + if err != nil { + t.Fatal(err) + } + result = v.Workstream + case "mark_done": + v, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) + if err != nil { + t.Fatal(err) + } + result = v.Workstream + case "reopen": + // Reopening an already open workstream still observes its jobs. + v, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}) + if err != nil { + t.Fatal(err) + } + result = v.Workstream + } + stage := "building" + if method == "mark_done" { + stage = "done" + } + if result.Stage != stage || result.StartedAt.IsZero() || !result.UpdatedAt.After(w.UpdatedAt) { + t.Fatalf("first observation: %+v", result) + } + changed := workstreamEvents(rec)[events:] + if len(changed) != 1 || changed[0].ID != w.ID || !reflect.DeepEqual(changed[0].Parts, []string{"workstream"}) { + t.Fatalf("mutation events: %+v", changed) + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || !reflect.DeepEqual(result, got.Workstream) || len(workstreamEvents(rec)) != events+1 || reads != 1 { + t.Fatalf("get after mutation: %+v, %v; events %d, reads %d", got, err, len(workstreamEvents(rec))-events, reads) + } + clock.Add(workstreamFactoryFresh.Milliseconds()) + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Stage != stage || !got.Workstream.StartedAt.Equal(result.StartedAt) || !got.Workstream.UpdatedAt.Equal(result.UpdatedAt) || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil || reads != 2 || len(workstreamEvents(rec)) != events+1 { + t.Fatalf("unreachable Factory lost durable observation: %+v, %v; reads %d", got, err, reads) + } + updated, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Committed while unreachable")}}) + if err != nil || updated.Workstream.Why != "Committed while unreachable" || updated.Workstream.Stage != stage || updated.Workstream.SpendUSD != nil || len(workstreamEvents(rec)) != events+2 { + t.Fatalf("unreachable Factory failed mutation: %+v, %v", updated, err) + } + }) + } +} + +// (e): marking done ends grants of all three scopes and archives member chats +// through the existing archive door, excluding the workstream's agent record. +func TestAppWorkstreamDoneEndsGoAheadsAndArchivesChats(t *testing.T) { + b, _, _, origin := workstreamBackend(t) + made, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Done", OriginChatID: &origin.ID, Refs: []appwire.WorkstreamRef{workstreamJobRef("7"), {Kind: appwire.RefConversation, ID: "second-chat"}}}) + if err != nil { + t.Fatal(err) + } + id := made.Workstream.ID + _, err = b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: id, Milestones: []appwire.WorkstreamMilestone{{ID: "m", Title: "Ship"}}, Waves: []appwire.WorkstreamWave{{N: 1, MilestoneID: workstreamPtr("m"), Items: []appwire.WorkstreamPlanItem{}}}}) + if err != nil { + t.Fatal(err) + } + for _, p := range []appwire.WorkstreamGoAheadGrantParams{ + {Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7)}, + {Scope: "wave", Wave: workstreamPtr(1)}, + {Scope: "milestone", MilestoneID: workstreamPtr("m")}, + } { + p.WorkstreamID = id + p.CapUSD = 40 + p.EndsAt = time.Now().Add(time.Hour) + if _, err := b.WorkstreamGoAheadGrant(p); err != nil { + t.Fatal(err) + } + } + var archived []string + b.workstreams.archiveChats = func(ids []string) error { archived = append(archived, ids...); return nil } + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: id, ArchiveChats: workstreamPtr(true)}); err != nil { + t.Fatal(err) + } + grants, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: id, Ended: workstreamPtr(true)}) + if err != nil || len(grants.GoAheads) != 3 { + t.Fatalf("grants %+v, %v", grants, err) + } + for _, g := range grants.GoAheads { + if g.State != "ended" || workstreamValue(g.EndReason) != "workstream_done" || g.EndedAt.IsZero() { + t.Fatalf("unended %+v", g) + } + } + if slices.Contains(archived, made.Workstream.AgentID) || len(archived) != 2 || !slices.Contains(archived, origin.ID) || !slices.Contains(archived, "second-chat") { + t.Fatalf("archived %v", archived) + } +} + +// (f): stale saves preserve the document and history; restore creates a new +// version instead of modifying an old one, with complete typed stale detail. +func TestAppWorkstreamPageVersions(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Pages") + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) + if err != nil { + t.Fatal(err) + } + id := pages.Pages[0].ID + doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Saved"}]}]}`) + saved, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: doc}) + if err != nil || saved.Page.Version != 2 { + t.Fatalf("save %+v, %v", saved, err) + } + before := len(workstreamEvents(rec)) + _, err = b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: json.RawMessage(`{}`)}) + wire := workstreamCheckError(t, err, appwire.CodeVersionConflict) + detail, ok := wire.Detail.(appwire.WorkstreamVersionConflict) + if !ok || detail.ID != id || detail.Version != 2 || detail.UpdatedAt != saved.Page.UpdatedAt || detail.UpdatedBy != "you" { + t.Fatalf("detail %+v", wire.Detail) + } + read, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: id, Markdown: workstreamPtr(true)}) + if err != nil || !bytes.Equal(read.Page.Doc, doc) || read.Page.Markdown == nil || !strings.Contains(*read.Page.Markdown, "Saved") { + t.Fatalf("read %+v, %v", read, err) + } + history, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) + if err != nil || len(history.Versions) != 2 || history.Versions[0].Version != 2 { + t.Fatalf("history %+v, %v", history, err) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("stale save or reads emitted") + } + restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id, Version: 1, BaseVersion: 2}) + if err != nil || restored.Page.Version != 3 || string(restored.Page.Doc) != `{"type":"doc","content":[]}` { + t.Fatalf("restore %+v, %v", restored, err) + } + history, err = b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) + if err != nil || len(history.Versions) != 3 || history.Versions[0].Version != 3 { + t.Fatalf("restored history %+v, %v", history, err) + } + _, err = b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeBadParams) +} + +// (g): keep/drop support undo; a replacement links both records and closes +// every later mutation of the superseded record with conflict. +func TestAppWorkstreamRecordLifecycle(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Records") + proposed, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Ship?", Source: &appwire.WorkstreamSource{Kind: "chat", ID: "chat"}}) + if err != nil || proposed.Record.State != "proposed" || proposed.Record.By != "agent" { + t.Fatalf("propose %+v, %v", proposed, err) + } + id := proposed.Record.ID + kept, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}) + if err != nil || kept.Record.State != "kept" { + t.Fatalf("keep %+v, %v", kept, err) + } + dropped, err := b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: id}) + if err != nil || dropped.Record.State != "dropped" { + t.Fatalf("drop %+v, %v", dropped, err) + } + if _, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}); err != nil { + t.Fatal(err) + } + replacement, err := b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: id, Text: "Ship it", Kind: workstreamPtr("decision")}) + if err != nil || replacement.Record.State != "kept" || replacement.Record.Kind != "decision" || workstreamValue(replacement.Record.Supersedes) != id || replacement.Superseded.State != "superseded" || workstreamValue(replacement.Superseded.SupersededBy) != replacement.Record.ID { + t.Fatalf("supersede %+v, %v", replacement, err) + } + _, err = b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeConflict) + _, err = b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeConflict) + _, err = b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: id, Text: "Again"}) + workstreamCheckError(t, err, appwire.CodeConflict) +} + +// (h): missing scope, cap or end date, and clock-dependent invalid ends, store +// nothing and emit nothing, through the backend's own checked entry point. +func TestAppWorkstreamInvalidGoAheads(t *testing.T) { + for _, name := range []string{"scope", "cap", "end", "past", "too far", "too much"} { + t.Run(name, func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Invalid", workstreamJobRef("7")) + p := appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)} + switch name { + case "scope": + p.Scope = "" + case "cap": + p.CapUSD = 0 + case "end": + p.EndsAt = time.Time{} + case "past": + p.EndsAt = time.Now().Add(-time.Second) + case "too far": + p.EndsAt = time.Now().Add(91 * 24 * time.Hour) + case "too much": + p.CapUSD = 10001 + } + before := len(workstreamEvents(rec)) + _, err := b.WorkstreamGoAheadGrant(p) + workstreamCheckError(t, err, appwire.CodeBadParams) + list, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID, Ended: workstreamPtr(true)}) + if err != nil || len(list.GoAheads) != 0 || len(workstreamEvents(rec)) != before { + t.Fatalf("invalid grant stored or emitted: %+v, %v", list, err) + } + }) + } +} + +// (j): encode the actual handler results so absence cannot pass as null or 0. +func TestAppWorkstreamUnknownNumbersAreAbsent(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Unknown", workstreamJobRef("7")) + for _, method := range []string{"get", "list"} { + var result any + var err error + if method == "get" { + result, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + } else { + result, err = b.WorkstreamList(appwire.WorkstreamListParams{}) + } + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{`"merged":`, `"needs_you":`, `"spend_usd":`} { + if bytes.Contains(data, []byte(field)) { + t.Fatalf("%s fabricated %s: %s", method, field, data) + } + } + if !bytes.Contains(data, []byte(`"total":1`)) { + t.Fatalf("total absent: %s", data) + } + } + opened, err := b.OpenCollection(w.ID, 1, 200, "") + if err != nil { + t.Fatal(err) + } + for _, node := range opened.Members { + if node.Kind == appwire.RefFactoryJob && node.Target.State != appwire.TargetUnknown { + t.Fatalf("unattached Factory fabricated target state: %+v", node.Target) + } + } +} + +func TestAppWorkstreamCreatePreflightAndCleanup(t *testing.T) { + for _, name := range []string{"name", "origin", "ref", "bounds"} { + t.Run(name, func(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + p := appwire.WorkstreamCreateParams{Name: "Refused"} + code := appwire.CodeBadParams + switch name { + case "name": + if _, err := b.CreateCollection("Refused"); err != nil { + t.Fatal(err) + } + code = appwire.CodeConflict + case "origin": + p.OriginChatID = workstreamPtr("no-chat") + code = appwire.CodeNotFound + case "ref": + p.Refs = []appwire.WorkstreamRef{{Kind: appwire.RefCollection, ID: "no-collection"}} + code = appwire.CodeNotFound + case "bounds": + p.Why = workstreamPtr(strings.Repeat("x", 4001)) + } + before := len(workstreamEvents(rec)) + _, err := b.WorkstreamCreate(p) + workstreamCheckError(t, err, code) + if *calls != 0 || len(workstreamEvents(rec)) != before { + t.Fatal("preflight created or emitted") + } + if name != "name" { + if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("preflight created store folder: %v", err) + } + } + }) + } + t.Run("peer name race closes unused agent", func(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + create := b.workstreams.createAgent + b.workstreams.createAgent = func(p appwire.CreateAsk) (appwire.Agent, error) { + if _, err := b.CreateCollection("Raced"); err != nil { + return appwire.Agent{}, err + } + return create(p) + } + closed := "" + b.workstreams.closeAgent = func(id string) error { closed = id; return nil } + _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Raced"}) + workstreamCheckError(t, err, appwire.CodeConflict) + list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil || len(list.Workstreams) != 0 || *calls != 1 || closed != "workstream-agent-1" || len(workstreamEvents(rec)) != 0 { + t.Fatalf("cleanup: %+v, %v, closed %q", list, err, closed) + } + }) + t.Run("agent refusal creates no store", func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + b.workstreams.createAgent = func(appwire.CreateAsk) (appwire.Agent, error) { + return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "agent refused") + } + _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Refused"}) + workstreamCheckError(t, err, appwire.CodeBadParams) + if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("agent refusal created store folder: %v", err) + } + if len(workstreamEvents(rec)) != 0 { + t.Fatal("agent refusal emitted") + } + }) +} + +// A fresh workstream list remains a non-migrating, non-creating read. +func TestAppWorkstreamFreshReadsDoNotCreate(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil || list.Workstreams == nil || len(list.Workstreams) != 0 { + t.Fatalf("fresh list %+v, %v", list, err) + } + _, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: "missing"}) + workstreamCheckError(t, err, appwire.CodeNotFound) + if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("fresh read made store folder: %v", err) + } +} + +// The shared hello hook must recognize the real backend's capability. +func TestAppWorkstreamHelloCarriesFeature(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + var output bytes.Buffer + server := appwire.NewServer(&output) + if err := server.Serve(context.Background(), strings.NewReader(""), appwire.Hello{}, workstreamHelloEngine{Engine: b, Workstreamer: b}); err != nil { + t.Fatal(err) + } + var frame struct { + Event string `json:"event"` + Data appwire.Hello `json:"data"` + } + if err := json.NewDecoder(&output).Decode(&frame); err != nil { + t.Fatal(err) + } + if frame.Event != appwire.EventHello || !slices.Contains(frame.Data.Features, appwire.FeatureWorkstreams) { + t.Fatalf("hello %+v", frame) + } +} + +// Only the tested capability is exposed, leaving the real backend's unrelated +// process warm-up outside this protocol test. +type workstreamHelloEngine struct { + appwire.Engine + appwire.Workstreamer +} + +// Session metadata is the recorded spend source, including a fresh bare open +// excluded from ordinary agents.list. Missing/corrupt metadata stays unknown. +func TestAppWorkstreamRecordedAgentSpend(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Spend") + b.workstreams.spend = nil + dir := t.TempDir() + transcript := filepath.Join(dir, "transcript.jsonl") + a := b.agent(w.AgentID) + b.locked(func() { a.transcript = transcript }) + if err := os.WriteFile(transcript, nil, 0600); err != nil { + t.Fatal(err) + } + if got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); err != nil || got.Workstream.SpendUSD != nil { + t.Fatalf("missing metadata: %+v, %v", got, err) + } + for _, cost := range []float64{0, 2.5} { + meta := session.Meta{ID: w.AgentID, SpentUSD: cost} + if cost > 0 { + meta.LastUserAt = time.Now() + } + data, err := json.Marshal(meta) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "meta.json"), data, 0600); err != nil { + t.Fatal(err) + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != cost { + t.Fatalf("recorded %v, got %+v, %v", cost, got, err) + } + } +} + +// (i): every mutation is observed through its public handler and produces one +// event for the changed workstream, containing every part its write changed. +func TestAppWorkstreamEveryWriteEmitsOnce(t *testing.T) { + type fixture struct { + b *appBackend + id, page, parent, record, grant string + base int + } + cases := []struct { + method string + parts []string + done bool + call func(*fixture) error }{ - {"workstream.list", func() error { - _, err := backend.WorkstreamList(appwire.WorkstreamListParams{}) + {"workstream.create", []string{"workstream", "pages", "jobs"}, false, func(f *fixture) error { + v, err := f.b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Second"}) + f.id = v.Workstream.ID return err }}, - {"workstream.get", func() error { - _, err := backend.WorkstreamGet(appwire.WorkstreamGetParams{}) + {"workstream.update", []string{"workstream"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: f.id, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("New why")}}) return err }}, - {"workstream.create", func() error { - _, err := backend.WorkstreamCreate(appwire.WorkstreamCreateParams{}) + {"workstream.mark_done", []string{"workstream", "goaheads"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: f.id}) return err }}, - {"workstream.update", func() error { - _, err := backend.WorkstreamUpdate(appwire.WorkstreamUpdateParams{}) + {"workstream.reopen", []string{"workstream"}, true, func(f *fixture) error { + _, err := f.b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: f.id}) return err }}, - {"workstream.mark_done", func() error { - _, err := backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{}) + {"workstream.plan_set", []string{"workstream", "plan"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: f.id, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}}) return err }}, - {"workstream.reopen", func() error { - _, err := backend.WorkstreamReopen(appwire.WorkstreamReopenParams{}) + {"page.create", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: f.id, Title: "New"}) return err }}, - {"workstream.plan_get", func() error { - _, err := backend.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{}) + {"page.save", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: f.page, BaseVersion: f.base, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}) return err }}, - {"workstream.plan_set", func() error { - _, err := backend.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{}) + {"page.update", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: f.page, Title: workstreamPtr("Renamed")}) return err }}, - {"page.list", func() error { - _, err := backend.WorkstreamPageList(appwire.WorkstreamPageListParams{}) + {"page.move", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: f.page}) return err }}, - {"page.get", func() error { - _, err := backend.WorkstreamPageGet(appwire.WorkstreamPageGetParams{}) + {"page.delete", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: f.parent}) return err }}, - {"page.save", func() error { - _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{}) + {"page.restore", []string{"workstream", "pages"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: f.page, Version: 1, BaseVersion: f.base}) return err }}, - {"page.create", func() error { - _, err := backend.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{}) + {"record.propose", []string{"workstream", "records"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: f.id, Kind: "decision", Text: "New"}) return err }}, - {"page.update", func() error { - _, err := backend.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{}) + {"record.keep", []string{"workstream", "records"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: f.record}) return err }}, - {"page.move", func() error { - _, err := backend.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{}) + {"record.drop", []string{"workstream", "records"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: f.record}) return err }}, - {"page.delete", func() error { - _, err := backend.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{}) + {"record.supersede", []string{"workstream", "records"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: f.record, Text: "Replaced"}) return err }}, - {"page.history", func() error { - _, err := backend.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{}) + {"goahead.grant", []string{"workstream", "goaheads"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: f.id, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 2, EndsAt: time.Now().Add(time.Hour)}) return err }}, - {"page.restore", func() error { - _, err := backend.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{}) + {"goahead.revoke", []string{"workstream", "goaheads"}, false, func(f *fixture) error { + _, err := f.b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: f.grant}) return err }}, - {"record.list", func() error { - _, err := backend.WorkstreamRecordList(appwire.WorkstreamRecordListParams{}) + {"collections.add", []string{"workstream", "jobs"}, false, func(f *fixture) error { + _, err := f.b.AddToCollection(f.id, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "8", Factory: "f"}}}) return err }}, - {"record.propose", func() error { - _, err := backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{}) + {"collections.remove", []string{"workstream", "jobs"}, false, func(f *fixture) error { + _, err := f.b.RemoveFromCollection(f.id, []appwire.Ref{{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}) return err }}, - {"record.keep", func() error { - _, err := backend.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{}) + {"collections.rename", []string{"workstream"}, false, func(f *fixture) error { _, err := f.b.RenameCollection(f.id, "Renamed"); return err }}, + {"collections.relink", []string{"workstream", "jobs"}, false, func(f *fixture) error { + _, err := f.b.RelinkCollection(f.id, appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}, appwire.Ref{Kind: appwire.RefFactoryJob, ID: "8", Factory: "f"}) return err }}, - {"record.drop", func() error { - _, err := backend.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{}) + } + for _, tc := range cases { + t.Run(tc.method, func(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}, {ID: 8, State: "backlog"}}}, nil + } + w := workstreamCreateForTest(t, b, "Events", workstreamJobRef("7")) + parent, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Parent"}) + if err != nil { + t.Fatal(err) + } + page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, ParentID: &parent.Page.ID, Title: "Child"}) + if err != nil { + t.Fatal(err) + } + saved, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: page.Page.ID, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc"}`)}) + if err != nil { + t.Fatal(err) + } + record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "What?"}) + if err != nil { + t.Fatal(err) + } + grant, err := b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + f := fixture{b: b, id: w.ID, page: page.Page.ID, parent: parent.Page.ID, record: record.Record.ID, grant: grant.GoAhead.ID, base: saved.Page.Version} + if tc.done { + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + } + before := len(workstreamEvents(rec)) + collectionsBefore := len(changedEvents(rec)) + if err := tc.call(&f); err != nil { + t.Fatal(err) + } + events := workstreamEvents(rec)[before:] + if len(events) != 1 || events[0].ID != f.id || !reflect.DeepEqual(events[0].Parts, tc.parts) { + t.Fatalf("%s: %+v, want %s %v", tc.method, events, f.id, tc.parts) + } + if strings.HasPrefix(tc.method, "collections.") && len(changedEvents(rec)) != collectionsBefore+1 { + t.Fatal("membership change lost collection.changed") + } + }) + } +} + +// (i): the read methods and idempotent mutations never manufacture a receipt. +func TestAppWorkstreamReadsAndUnchangedWritesEmitNothing(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Quiet", workstreamJobRef("7")) + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil + } + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) + if err != nil { + t.Fatal(err) + } + page := pages.Pages[0] + r, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "decision", Text: "Keep", Kept: workstreamPtr(true)}) + if err != nil { + t.Fatal(err) + } + g, err := b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + if _, err := b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: g.GoAhead.ID}); err != nil { + t.Fatal(err) + } + calls := []struct { + name string + call func() error + }{ + {"workstream.list", func() error { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); return err }}, + {"workstream.get", func() error { _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); return err }}, + {"workstream.plan_get", func() error { _, err := b.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{ID: w.ID}); return err }}, + {"page.list", func() error { + _, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) return err }}, - {"record.supersede", func() error { - _, err := backend.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{}) + {"page.get", func() error { _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: page.ID}); return err }}, + {"page.history", func() error { + _, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: page.ID}) + return err + }}, + {"record.list", func() error { + _, err := b.WorkstreamRecordList(appwire.WorkstreamRecordListParams{WorkstreamID: w.ID}) return err }}, {"goahead.list", func() error { - _, err := backend.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{}) + _, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID, Ended: workstreamPtr(true)}) + return err + }}, + {"workstream.update empty", func() error { _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID}); return err }}, + {"workstream.reopen open", func() error { _, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}); return err }}, + {"page.update same", func() error { + _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: page.ID, Title: &page.Title}) + return err + }}, + {"record.keep kept", func() error { + _, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: r.Record.ID}) return err }}, - {"goahead.grant", func() error { - _, err := backend.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{}) + {"goahead.revoke ended", func() error { + _, err := b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: g.GoAhead.ID}) return err }}, - {"goahead.revoke", func() error { - _, err := backend.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{}) + {"collections.add already", func() error { + _, err := b.AddToCollection(w.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}}) return err }}, + {"collections.remove absent", func() error { + _, err := b.RemoveFromCollection(w.ID, []appwire.Ref{{Kind: appwire.RefFactoryJob, ID: "99", Factory: "f"}}) + return err + }}, + {"collections.rename same", func() error { _, err := b.RenameCollection(w.ID, w.Name); return err }}, + } + before := len(workstreamEvents(rec)) + for _, tc := range calls { + t.Run(tc.name, func(t *testing.T) { + if err := tc.call(); err != nil { + t.Fatal(err) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("unchanged operation emitted") + } + }) + } + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + before = len(workstreamEvents(rec)) + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("mark_done again emitted") + } +} + +// B3: distinct factories are read concurrently, once per list, and reused by +// get and collections.open. A barrier tests work rather than elapsed time. +func TestAppWorkstreamFactoryBatchCacheAndCollectionTargets(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + a := workstreamCreateForTest(t, b, "One", workstreamJobRef("7")) + second := workstreamJobRef("9") + second.Factory = workstreamPtr("g") + workstreamCreateForTest(t, b, "Two", second) + entered := make(chan string, 2) + release := make(chan struct{}) + var mu sync.Mutex + calls := map[string]int{} + workstreamRecordCall := func(factory string) { + mu.Lock() + defer mu.Unlock() + calls[factory]++ } - if len(calls) != len(appwire.WorkstreamMethods) { - t.Fatal("stub test does not cover every contract method") + b.workstreams.queue = func(ctx context.Context, factory string) (api.Queue, error) { + workstreamRecordCall(factory) + entered <- factory + select { + case <-release: + case <-ctx.Done(): + return api.Queue{}, ctx.Err() + } + id := 7 + if factory == "g" { + id = 9 + } + return api.Queue{Jobs: []api.Job{{ID: id, Num: 1107, Title: "Recorded title", State: "backlog", Repo: "acme/api", Cost: 0}}}, nil } - for i, call := range calls { - if call.name != appwire.WorkstreamMethods[i] { - t.Fatalf("stub test index differs at %s", call.name) + done := make(chan error, 1) + guard.Go("app/workstream-list-test", func() { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); done <- err }) + for range 2 { + select { + case <-entered: + case <-time.After(time.Second): + close(release) + t.Fatal("factories were not read concurrently") } - t.Run(call.name, func(t *testing.T) { - err := call.call() - var wireErr *appwire.Error - if !errors.As(err, &wireErr) { - t.Fatal("stub must return an appwire.Error") + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: a.ID}); err != nil { + t.Fatal(err) + } + open, err := b.OpenCollection(a.ID, 1, 200, "") + if err != nil { + t.Fatal(err) + } + found := false + for _, node := range open.Members { + if node.Kind == appwire.RefFactoryJob { + found = true + if node.Target.State != appwire.TargetLive || node.Target.Title != "Recorded title" || node.Target.Status != "backlog" || node.Target.Repo != "acme/api" { + t.Fatalf("target %+v", node.Target) } - if wireErr.Code != appwire.CodeUnknownMethod { - t.Errorf("code = %s, want unknown_method", wireErr.Code) + } + } + mu.Lock() + defer mu.Unlock() + if !found || calls["f"] != 1 || calls["g"] != 1 { + t.Fatalf("target found %v, queue calls %v", found, calls) + } +} + +// workstreamQueueWaitContext signals when a coalesced reader selects on Done, +// so the panic cannot race ahead of the waiter entering the flight branch. +type workstreamQueueWaitContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *workstreamQueueWaitContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +// (j), B3: a guarded queue panic releases concurrent waiters, caches an +// unavailable reading for the normal window, and permits a successful retry. +func TestAppWorkstreamFactoryPanicFinishesFlight(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + b.now = func() time.Time { return time.UnixMilli(clock.Load()) } + w := workstreamCreateForTest(t, b, "Queue panic", workstreamJobRef("7")) + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var calls atomic.Int32 + b.workstreams.queue = func(ctx context.Context, _ string) (api.Queue, error) { + if calls.Add(1) == 1 { + close(entered) + select { + case <-release: + panic("queue reader fault") + case <-ctx.Done(): + return api.Queue{}, ctx.Err() } - if wireErr.Message != "workstreams: not implemented yet" { - t.Errorf("message = %s", wireErr.Message) + } + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog", Cost: 1.25}}}, nil + } + b.workstreams.job = func(context.Context, string, int) (api.Job, error) { + t.Error("unavailable queue triggered a job fallback") + return api.Job{}, errors.New("unexpected job fallback") + } + type workstreamGetRead struct { + result appwire.WorkstreamGetResult + err error + } + owner := make(chan workstreamGetRead, 1) + guard.Go("app/workstream-panic-owner-test", func() { + result, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + owner <- workstreamGetRead{result: result, err: err} + }) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("queue reader did not enter") + } + _, flight, owns := b.workstreams.workstreamQueueStart("f", b.now()) + if flight == nil || owns { + t.Fatal("queue reader did not own a shared flight") + } + ctx, cancel := context.WithTimeout(context.Background(), workstreamFactoryTimeout) + defer cancel() + waitCtx := &workstreamQueueWaitContext{Context: ctx, waiting: make(chan struct{})} + waiter := make(chan workstreamQueueRead, 1) + guard.Go("app/workstream-panic-waiter-test", func() { waiter <- b.workstreamQueue(waitCtx, "f") }) + select { + case <-waitCtx.waiting: + case <-time.After(time.Second): + t.Fatal("concurrent reader did not wait on the flight") + } + releaseOnce.Do(func() { close(release) }) + select { + case read := <-owner: + if read.err != nil { + t.Fatal(read.err) + } + encoded, err := json.Marshal(read.result) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"merged", "needs_you", "spend_usd"} { + if bytes.Contains(encoded, []byte(`"`+key+`"`)) { + t.Fatalf("panic exposed a number in %s", encoded) } - }) + } + if read.result.Workstream.Total != 1 { + t.Fatalf("panic lost total: %+v", read.result) + } + case <-time.After(time.Second): + t.Fatal("guarded queue owner did not finish") + } + select { + case <-flight: + default: + t.Fatal("panic did not close the flight") + } + select { + case read := <-waiter: + if read.err == nil || errors.Is(read.err, context.DeadlineExceeded) || !read.at.Equal(b.now()) { + t.Fatalf("waiter did not receive the cached failure: %+v", read) + } + case <-time.After(time.Second): + t.Fatal("panic stranded the concurrent waiter") + } + func() { + b.workstreams.cacheMu.Lock() + defer b.workstreams.cacheMu.Unlock() + if _, ok := b.workstreams.flights["f"]; ok { + t.Fatal("panic left an in-flight entry") + } + }() + entry, flight, owns := b.workstreams.workstreamQueueStart("f", b.now()) + if entry.err == nil || flight != nil || owns || calls.Load() != 1 { + t.Fatalf("panic did not remove and cache the failed flight: %+v, %v, %v", entry, flight, owns) + } + clock.Add((workstreamFactoryFresh - time.Millisecond).Milliseconds()) + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil || calls.Load() != 1 { + t.Fatalf("failure did not stay cached: %+v, %v; calls %d", got, err, calls.Load()) + } + clock.Add(1) + got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || calls.Load() != 2 || got.Workstream.Merged == nil || *got.Workstream.Merged != 0 || got.Workstream.NeedsYou == nil || *got.Workstream.NeedsYou != 0 || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 1.25 { + t.Fatalf("retry after failure-cache expiry: %+v, %v; calls %d", got, err, calls.Load()) + } +} + +// B3: only successful queue reads allow missing-job fallbacks, each distinct +// address is asked once, and work over the per-call allowance remains unknown. +func TestAppWorkstreamMissingJobReadsAreBounded(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + refs := []appwire.WorkstreamRef{} + for i := 1; i <= 25; i++ { + refs = append(refs, workstreamJobRef(fmt.Sprint(i))) + } + w := workstreamCreateForTest(t, b, "Missing", refs...) + queuedReads, jobReads := 0, 0 + b.workstreams.queue = func(ctx context.Context, _ string) (api.Queue, error) { + queuedReads++ + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > workstreamFactoryTimeout { + t.Error("queue read has no bounded deadline") + } + return api.Queue{}, nil + } + b.workstreams.job = func(ctx context.Context, _ string, job int) (api.Job, error) { + jobReads++ + if _, ok := ctx.Deadline(); !ok { + t.Error("job read has no deadline") + } + return api.Job{ID: job, Num: 999, State: "backlog", Cost: 0}, nil + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || queuedReads != 1 || jobReads != 20 || got.Workstream.Total != 25 || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil { + t.Fatalf("bounded fallback %+v, %v; queue %d jobs %d", got, err, queuedReads, jobReads) + } + b.workstreams.cache = nil + jobReads = 0 + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, errors.New("unreachable") } + if _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + if jobReads != 0 { + t.Fatal("unreachable queue triggered per-job reads") + } + open, err := b.OpenCollection(w.ID, 1, 200, "") + if err != nil { + t.Fatal(err) + } + for _, node := range open.Members { + if node.Kind == appwire.RefFactoryJob && (node.Target.State != appwire.TargetUnknown || node.Target.Status != "unknown") { + t.Fatalf("unavailable target %+v", node.Target) + } + } +} + +func TestAppWorkstreamPlanConflictDetailAndDoneRefusal(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Plan") + p := appwire.WorkstreamPlanSetParams{ID: w.ID, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}} + first, err := b.WorkstreamPlanSet(p) + if err != nil { + t.Fatal(err) + } + before := len(workstreamEvents(rec)) + _, err = b.WorkstreamPlanSet(p) + wire := workstreamCheckError(t, err, appwire.CodeVersionConflict) + detail, ok := wire.Detail.(appwire.WorkstreamVersionConflict) + if !ok || detail.ID != w.ID || detail.Version != 1 || detail.UpdatedAt != first.Plan.UpdatedAt || detail.UpdatedBy != "you" { + t.Fatalf("plan conflict %+v", wire) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("stale plan emitted") + } + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + p.BaseVersion = 1 + _, err = b.WorkstreamPlanSet(p) + workstreamCheckError(t, err, appwire.CodeConflict) +} + +// (c): a bare workstream agent survives the existing cleanup path and can be +// reopened after workstream.get restores it into a fresh backend's inventory. +func TestAppWorkstreamBareAgentSurvivesRestart(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + b, _, _, _ := workstreamBackend(t) + b.collectionsPath = home.Join("v3", "collections.db") + b.workstreams.spend = nil + w := workstreamCreateForTest(t, b, "Persistent") + dir := filepath.Join(session.PlacesRoot(), "project", w.AgentID) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(dir, "transcript.jsonl") + if err := os.WriteFile(transcript, nil, 0600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(dir, session.Meta{ID: w.AgentID, Workspace: t.TempDir(), Created: time.Now()}); err != nil { + t.Fatal(err) + } + a := b.agent(w.AgentID) + b.locked(func() { a.transcript = transcript; a.minted = true }) + if err := b.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dir); err != nil { + t.Fatal(err) + } + restarted, _, _, _ := workstreamBackend(t) + restarted.collectionsPath = home.Join("v3", "collections.db") + restarted.workstreams.spend = nil + got, err := restarted.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 0 { + t.Fatalf("restore %+v, %v", got, err) + } + if _, err := restarted.OpenAgent(w.AgentID); err != nil { + t.Fatalf("agent could not be reopened: %v", err) + } + listed, err := restarted.ListAgents("") + if err != nil { + t.Fatal(err) + } + if _, found := listsAgent(listed, w.AgentID); found { + t.Fatal("restoring the bare agent changed agents.list") + } +} + +// (i): chat archiving is an external owner action after the committed done +// transaction. Its failure cannot hide that change or duplicate it on retry. +func TestAppWorkstreamDoneArchiveFailureStillAnnouncesCommit(t *testing.T) { + b, rec, _, origin := workstreamBackend(t) + made, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Archive retry", OriginChatID: &origin.ID}) + if err != nil { + t.Fatal(err) + } + b.workstreams.archiveChats = func([]string) error { return appwire.Errorf(appwire.CodeBusy, "chat is busy") } + p := appwire.WorkstreamMarkDoneParams{ID: made.Workstream.ID, ArchiveChats: workstreamPtr(true)} + before := len(workstreamEvents(rec)) + _, err = b.WorkstreamMarkDone(p) + workstreamCheckError(t, err, appwire.CodeBusy) + events := workstreamEvents(rec) + if len(events) != before+1 || events[before].ID != made.Workstream.ID { + t.Fatalf("lost done notification: %+v", events) + } + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: made.Workstream.ID}) + if err != nil || got.Workstream.Stage != "done" { + t.Fatalf("done not committed: %+v, %v", got, err) + } + b.workstreams.archiveChats = func([]string) error { return nil } + if _, err := b.WorkstreamMarkDone(p); err != nil { + t.Fatal(err) + } + if len(workstreamEvents(rec)) != before+1 { + t.Fatal("archive retry duplicated workstream event") } } diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index d447661ee1..9b0c82e262 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -719,6 +719,9 @@ func v3EmptySession(dir string) bool { } func v3EmptyUnassignedSession(dir string) bool { + if workstreamKeepsConversation(dir) { + return false + } place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) if spoken || !sure || session.HasSavedTasks(dir) { diff --git a/cmd/codeaf/collections.go b/cmd/codeaf/collections.go index 70ed21b4e0..e77b1e9299 100644 --- a/cmd/codeaf/collections.go +++ b/cmd/codeaf/collections.go @@ -22,8 +22,8 @@ const collectionsUsage = collectionsSummary + ` codeaf collections show codeaf collections add|remove codeaf collections find - kinds: collection, conversation, task, process, crew, artifact (a file path) - tasks and processes need --session ; all accept --db and --json + kinds: collection, conversation, task, process, crew, factory_job, artifact (a file path) + factory_job needs --factory ; tasks and processes need --session ; all accept --db and --json organize references without moving files or starting work` func runCollections(args []string) error { return runCollectionsTo(args, os.Stdout) } @@ -32,6 +32,7 @@ func runCollectionsTo(args []string, output io.Writer) error { flags := commandFlags("collections") database := flags.String("db", home.Join("v3", "collections.db"), "collection database path; separate from learned memory") sessionID := flags.String("session", "", "owning conversation id for a task reference") + factoryID := flags.String("factory", "", "owning factory id for a factory_job reference") asJSON := flags.Bool("json", false, "print structured records") if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err @@ -63,7 +64,7 @@ func runCollectionsTo(args []string, output io.Writer) error { } var ref workspace.Ref if verb == "add" || verb == "remove" || verb == "find" { - ref = workspace.Ref{Kind: workspace.Kind(rest[len(rest)-2]), ID: rest[len(rest)-1], SessionID: *sessionID} + ref = workspace.Ref{Kind: workspace.Kind(rest[len(rest)-2]), ID: rest[len(rest)-1], SessionID: *sessionID, Factory: *factoryID} if ref.Kind == workspace.ArtifactKind { if ref.ID == "" { return fmt.Errorf("%w: an artifact reference needs a file path", workspace.ErrInvalid) @@ -86,6 +87,8 @@ func runCollectionsTo(args []string, output io.Writer) error { if verb == "add" && ref.Kind == workspace.StandingKind { return fmt.Errorf("%w: standing items are gone, so a collection cannot gain one", workspace.ErrInvalid) } + } else if *factoryID != "" { + return fmt.Errorf("%w: --factory belongs to a factory_job reference in add, remove or find", workspace.ErrInvalid) } else if *sessionID != "" { return fmt.Errorf("%w: --session belongs to a task or process reference in add, remove or find", workspace.ErrInvalid) } @@ -119,7 +122,11 @@ func runCollectionsTo(args []string, output io.Writer) error { result, err = store.Create(ctx, rest[1]) case "rename": err = store.Rename(ctx, rest[1], rest[2]) - result = workspace.Collection{ID: rest[1], Name: rest[2]} + if err == nil { + var summary workspace.Summary + summary, err = store.Describe(ctx, rest[1]) + result = summary.Collection + } case "duplicate": result, err = store.Duplicate(ctx, rest[1], rest[2]) case "delete": @@ -205,6 +212,8 @@ func writeCollectionRef(output io.Writer, ref workspace.Ref) error { var err error if ref.Kind == workspace.TaskKind || ref.Kind == workspace.ProcessKind { _, err = fmt.Fprintf(output, "%s %s conversation %s\n", ref.Kind, ref.ID, ref.SessionID) + } else if ref.Kind == workspace.FactoryJobKind { + _, err = fmt.Fprintf(output, "%s %s factory %s\n", ref.Kind, ref.ID, ref.Factory) } else { _, err = fmt.Fprintf(output, "%s %s\n", ref.Kind, ref.ID) } diff --git a/cmd/codeaf/collections_factory_job_test.go b/cmd/codeaf/collections_factory_job_test.go new file mode 100644 index 0000000000..233234d8a0 --- /dev/null +++ b/cmd/codeaf/collections_factory_job_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// Part A (A2): add/remove/find accept --factory, show names it readably, and +// omitting or misusing the flag is refused before opening storage. +func TestPartACollectionsCLIFactoryJob(t *testing.T) { + db := filepath.Join(t.TempDir(), "collections.db") + c := collectionCreate(t, db, "Jobs") + var out bytes.Buffer + if err := runCollectionsTo([]string{"add", c.ID, "factory_job", "7", "--db", db}, &out); err == nil || !strings.Contains(err.Error(), "factory") { + t.Fatalf("missing factory: %v", err) + } + collectionCommand(t, db, "add", c.ID, "factory_job", "7", "--factory", "f") + if err := runCollectionsTo([]string{"show", c.ID, "--db", db}, &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "factory_job 7 factory f") { + t.Fatalf("show: %q", out.String()) + } + var refs []workspace.Ref + if err := json.Unmarshal([]byte(collectionCommand(t, db, "show", c.ID)), &refs); err != nil || len(refs) != 1 || refs[0].Factory != "f" { + t.Fatalf("refs: %+v %v", refs, err) + } + var parents []workspace.Collection + if err := json.Unmarshal([]byte(collectionCommand(t, db, "find", "factory_job", "7", "--factory", "f")), &parents); err != nil || len(parents) != 1 || parents[0].ID != c.ID { + t.Fatalf("find: %+v %v", parents, err) + } + collectionCommand(t, db, "remove", c.ID, "factory_job", "7", "--factory", "f") + if got := collectionCommand(t, db, "show", c.ID); got != "[]\n" { + t.Fatalf("remove: %s", got) + } + out.Reset() + if err := runCollectionsTo([]string{"add", c.ID, "conversation", "chat", "--factory", "f", "--db", db}, &out); err == nil { + t.Fatal("factory on conversation accepted") + } +} + +func TestPartACollectionRefConversionsRetainFactory(t *testing.T) { + r := appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"} + if got := appWireRef(appStoreRef(r)); got != r { + t.Fatalf("round trip: %+v", got) + } +} diff --git a/docs/changes/unreleased/2270-app-workstreams-store.md b/docs/changes/unreleased/2270-app-workstreams-store.md new file mode 100644 index 0000000000..1966231869 --- /dev/null +++ b/docs/changes/unreleased/2270-app-workstreams-store.md @@ -0,0 +1,27 @@ +--- +kind: added +title: codeaf app serves persistent workstreams with pages, records and go-aheads +pr: 2270 +surface: [app] +invalidates: + - Workstream methods previously answered unknown_method; they now serve persistent state and announce the workstreams feature. +--- + +Workstreams share collection identity, preserve page history, version plans, +and keep decisions, questions and bounded go-aheads in SQLite. Creating one +opens one bare agent without a first model turn; a repeated client id reuses it. +Factory jobs belong to at most one workstream, and changes notify watching apps. +List/get derive stage and numbers from bounded Factory reads and recorded agent +spend. Unknown readings are omitted; measured zero remains present. Mark done +ends active go-aheads and can archive member chats while keeping the agent record. +Go-aheads and wave auto-start settings are stored; Factory enforcement and +automatic starts are separate work. + +Update, mark done and reopen results include the same available live numbers as +get. Single-line fields reject control characters; prose permits newline and +tab only. Repeating mark done preserves the original done time and report. + +Faults while reading a queue release concurrent readers, cache an unavailable +result and permit retries after the cache window. Update, mark done and reopen +persist their first observed start, return the refreshed row and announce one +combined change. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index ebaa8eb44c..2a7927952d 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -147,6 +147,18 @@ decides which combinations are accepted. | `collections.remove` | `{id, refs}` | `CollectionRemoveResult` | | `collections.rename` | `in.collectionNameParams` | `CollectionResult` | +Collection refs include `factory_job`, with a canonical positive decimal job `id` +and a required `factory` field. The pair addresses the same job as `factory.job`. +Other ref kinds have no `factory`. Collection results carry `workstream: true` +when their id is a workstream. `collections.delete` refuses these collections +with `bad_params` and the exact message `mark a workstream done instead`; +duplicating one makes a plain collection. A job can belong to only one workstream: +adding a job held by another returns `in_other_workstream` with factory, integer +job, workstream_id and workstream_name detail, and changes nothing in the batch. +Membership changes and renames send `workstream.changed` as well as +`collection.changed`. Factory job targets carry the recorded title, state word +and repository; an unavailable Factory produces an `unknown` target and status. + ## competence methods | method | params | result | @@ -1493,10 +1505,34 @@ read because it has said nothing. Engines that serve it announce ## Workstreams (v0 proposal) -The Workstreams plan, WP-0, registers 25 methods and `workstream.changed` with their wire types. -The `workstreams` feature is announced only by an engine that serves them. -This build does not announce it and answers `unknown_method` with -`workstreams: not implemented yet`; the store lands in WP-1. +The Workstreams plan, WP-1, serves 25 methods backed by the collections SQLite +store and announces `workstreams` in `hello.features`. Older engines without +this feature must not be called; their methods may answer `unknown_method`. +Pages, version history, records, plans, settings and go-aheads are engine-owned +SQLite state. Markdown is a page export. Every committed change sends one +`workstream.changed` for its id and changed parts; unchanged writes emit nothing. +List/get may persist and announce the first observed job leaving Backlog. +Creation checks refs and ownership before opening one bare agent conversation, +with no first message or model turn. Repeating a nonempty client_id returns the +first workstream. Origin and agent conversations are collection members. +Mark done ends active go-aheads, and optional chat archiving excludes the agent's +record. Reopen keeps the workstream in Building. Recorded Factory facts and agent +spend feed the numbers; unavailable readings remain absent, while measured zero +is present. Queue reads have a three-second call deadline and a ten-second cache +of at most 32 factories. Distinct factories are read concurrently, up to eight +at a time; at most 20 missing jobs get individual reads per call. Further jobs +remain unknown. A Factory connection that cannot be read stays unknown. +Update, mark_done and reopen results use the same bounded, cached facts and agent +spend as get. They persist the first observed start and return the refreshed row, +combining the observation with the mutation's single `workstream.changed` event. +A fault while reading a queue releases waiting readers and caches an unavailable +result for the normal window before a retry. Repeating mark_done preserves the +original done_at and how_it_went. +Client IDs, page/milestone/wave/draft titles and source labels reject control +characters. Why, how_it_went, record text and draft text allow newline and tab +only; other control characters return `bad_params`. Names use collection rules. +Go-aheads are stored with scope, cap and end bounds; expiry is derived on read. +Factory enforcement and automatic wave starts remain separate capabilities. A workstream is a collection, sharing its id, naming rules and membership methods. Its jobs use `factory_job` refs with `factory`; a job in another workstream is refused as `in_other_workstream`. Versioned page and plan writes refuse stale bases with `version_conflict`. diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go index 579d05e7a6..4f0d750963 100644 --- a/internal/appwire/collections.go +++ b/internal/appwire/collections.go @@ -6,6 +6,7 @@ import ( "strings" "time" "unicode" + "unicode/utf8" ) // WAVE 9: COLLECTIONS (PROTOCOL.md, V75). A collection is a hand-picked list @@ -31,6 +32,7 @@ const ( RefArtifact = "artifact" RefCrew = "crew" RefProcess = "process" + RefFactoryJob = "factory_job" RefStanding = "standing" ) @@ -60,9 +62,10 @@ const ( // Ref is one reference: a task and a process carry their agent as SessionID, // and an artifact is a clean absolute path. type Ref struct { - Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,standing"` + Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,standing,factory_job"` ID string `json:"id"` SessionID string `json:"session_id,omitempty"` + Factory string `json:"factory,omitempty"` } // Member is a reference as a collection holds it, with when it was added; @@ -78,6 +81,7 @@ type Member struct { // about a reference, whether it directly holds it. type Collection struct { ID string `json:"id"` + Workstream bool `json:"workstream,omitempty"` Name string `json:"name"` CreatedAt *time.Time `json:"created_at,omitempty"` Count int `json:"count"` @@ -246,12 +250,19 @@ func withFeature(hello Hello, feature string) Hello { // plainText is non-empty text of at most limit bytes with no control // characters and no surrounding space. func plainText(s string, limit int) bool { - return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0 + return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && singleLineText(s) +} + +func singleLineText(s string) bool { + return strings.IndexFunc(s, unicode.IsControl) < 0 } // CheckRef refuses a malformed reference as bad_params. adding refuses the // legacy standing kind, which can be found and removed and never added. func CheckRef(r Ref, adding bool) error { + if r.Kind != RefFactoryJob && r.Factory != "" { + return Errorf(CodeBadParams, "only a factory_job reference has a factory") + } if !plainText(r.ID, collectionIDMax) { return Errorf(CodeBadParams, "a reference needs an id without surrounding space or control characters") } @@ -263,6 +274,11 @@ func CheckRef(r Ref, adding bool) error { return Errorf(CodeBadParams, "only a task or a process reference has a session_id") } switch r.Kind { + case RefFactoryJob: + n, err := strconv.ParseUint(r.ID, 10, 63) + if err != nil || n == 0 || strconv.FormatUint(n, 10) != r.ID || (!plainText(r.Factory, collectionIDMax) || !utf8.ValidString(r.Factory)) { + return Errorf(CodeBadParams, "a factory_job needs a factory and a positive canonical job number") + } case RefCollection, RefConversation, RefCrew, RefProcess: case RefTask: n, err := strconv.ParseUint(r.ID, 10, 64) diff --git a/internal/appwire/collections_factory_job_test.go b/internal/appwire/collections_factory_job_test.go new file mode 100644 index 0000000000..1a888f545a --- /dev/null +++ b/internal/appwire/collections_factory_job_test.go @@ -0,0 +1,41 @@ +package appwire + +import ( + "encoding/json" + "strings" + "testing" +) + +// Part A (A2): the wire validator and optional fields match store ref identity. +func TestPartACollectionFactoryJobRefs(t *testing.T) { + for _, tc := range []struct { + r Ref + ok bool + }{ + {Ref{Kind: RefFactoryJob, ID: "7", Factory: "f"}, true}, + {Ref{Kind: RefFactoryJob, ID: "7"}, false}, + {Ref{Kind: RefFactoryJob, ID: "0", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "07", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "+7", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: " f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: strings.Repeat("x", 4097)}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: "f", SessionID: "chat"}, false}, + {Ref{Kind: RefConversation, ID: "chat", Factory: "f"}, false}, + } { + for _, adding := range []bool{false, true} { + if err := CheckRef(tc.r, adding); (err == nil) != tc.ok { + t.Errorf("%+v adding %v: %v", tc.r, adding, err) + } + } + } + raw, err := json.Marshal(Member{Ref: Ref{Kind: RefFactoryJob, ID: "7", Factory: "f"}}) + if err != nil || !strings.Contains(string(raw), `"factory":"f"`) { + t.Fatalf("factory: %s %v", raw, err) + } + for _, flag := range []bool{false, true} { + raw, err := json.Marshal(Collection{ID: "c", Name: "Name", Workstream: flag}) + if err != nil || strings.Contains(string(raw), `"workstream":true`) != flag { + t.Fatalf("flag: %s %v", raw, err) + } + } +} diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go index 983ae6b33a..6a7191bb17 100644 --- a/internal/appwire/workstreams.go +++ b/internal/appwire/workstreams.go @@ -15,7 +15,7 @@ const ( CodeVersionConflict = "version_conflict" ) -// ErrWorkstreamsNotYet keeps the registered contract unavailable until WP-1. +// ErrWorkstreamsNotYet is the capability refusal for a shapes-only engine. var ErrWorkstreamsNotYet = Errorf(CodeUnknownMethod, "workstreams: not implemented yet") // WorkstreamMethods follows the contract index order. @@ -507,16 +507,32 @@ type Workstreamer interface { } // workstreamCall preserves the params/result types used by the schema generator. -// Bounds and storage behavior land in WP-1; this door only delegates. -func workstreamCall[P, R any](call func(Workstreamer, P) (R, error)) method { - return typed(func(e Engine, p P) (*R, error) { +// Unsupported engines retain their capability refusal before parameter checks. +func workstreamCall[P interface{ Check() error }, R any](call func(Workstreamer, P) (R, error)) method { + m := typed(func(e Engine, p P) (*R, error) { w, ok := e.(Workstreamer) if !ok { return nil, Errorf(CodeUnknownMethod, "this engine does not serve workstreams") } + if !w.ServesWorkstreams() { + return nil, ErrWorkstreamsNotYet + } + if err := p.Check(); err != nil { + return nil, err + } result, err := call(w, p) return &result, err }) + delegate := m.call + m.call = func(e Engine, raw json.RawMessage) (any, error) { + if w, ok := e.(Workstreamer); ok && w.ServesWorkstreams() { + if err := workstreamRequired(raw, m.params); err != nil { + return nil, err + } + } + return delegate(e, raw) + } + return m } func init() { diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go new file mode 100644 index 0000000000..c549a4312e --- /dev/null +++ b/internal/appwire/workstreams_check.go @@ -0,0 +1,372 @@ +package appwire + +import ( + "bytes" + "encoding/json" + "math" + "reflect" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// workstreamRequired distinguishes a missing required zero-valued field from +// an explicit zero, including plan version 0 and false wake flags. Editor JSON +// stays opaque; unknown additive fields stay ignored by the typed decoder. +func workstreamRequired(raw json.RawMessage, shape reflect.Type) error { + if shape == reflect.TypeFor[json.RawMessage]() || shape == reflect.TypeFor[time.Time]() { + return nil + } + if shape.Kind() == reflect.Pointer { + shape = shape.Elem() + } + switch shape.Kind() { + case reflect.Struct: + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { + return Errorf(CodeBadParams, "workstream params must be an object") + } + for i := 0; i < shape.NumField(); i++ { + field := shape.Field(i) + tag := field.Tag.Get("json") + name, _, _ := strings.Cut(tag, ",") + value, exists := fields[name] + if !exists { + if !strings.Contains(tag, ",omitempty") { + return Errorf(CodeBadParams, "%s is required", name) + } + continue + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return Errorf(CodeBadParams, "%s must not be null", name) + } + if err := workstreamRequired(value, field.Type); err != nil { + return err + } + } + case reflect.Slice: + var values []json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil { + return Errorf(CodeBadParams, "expected an array") + } + for _, value := range values { + if err := workstreamRequired(value, shape.Elem()); err != nil { + return err + } + } + } + return nil +} + +func workstreamID(id string) error { + if !utf8.ValidString(id) || !plainText(id, collectionIDMax) { + return Errorf(CodeBadParams, "a workstream target needs a nonempty id without surrounding space or control characters") + } + return nil +} + +func workstreamOptionalID(id *string) error { + if id == nil { + return nil + } + return workstreamID(*id) +} + +func workstreamText(text string, min, max int, field string) error { + if !utf8.ValidString(text) || len(text) < min || len(text) > max || (min > 0 && strings.TrimSpace(text) == "") { + return Errorf(CodeBadParams, "%s is %d to %d bytes", field, min, max) + } + if strings.IndexFunc(text, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\t' }) >= 0 { + return Errorf(CodeBadParams, "%s allows only newline and tab control characters", field) + } + return nil +} + +func workstreamLine(text string, min, max int, field string) error { + if !singleLineText(text) { + return Errorf(CodeBadParams, "%s must not contain control characters", field) + } + return workstreamText(text, min, max, field) +} + +func workstreamOptionalLine(text *string, min, max int, field string) error { + if text == nil { + return nil + } + return workstreamLine(*text, min, max, field) +} + +func workstreamOptionalText(text *string, min, max int, field string) error { + if text == nil { + return nil + } + return workstreamText(*text, min, max, field) +} + +func workstreamEnum(word string, words ...string) error { + for _, allowed := range words { + if word == allowed { + return nil + } + } + return Errorf(CodeBadParams, "invalid workstream value %q", word) +} + +func workstreamOptionalEnum(word *string, words ...string) error { + if word == nil { + return nil + } + return workstreamEnum(*word, words...) +} + +// workstreamChecks keeps each family of checks ordered and returns the first refusal. +func workstreamChecks(checks ...error) error { + for _, err := range checks { + if err != nil { + return err + } + } + return nil +} + +func workstreamDoc(doc json.RawMessage, required bool) error { + if len(doc) > workstream.DocMax { + return Errorf(CodeTooLarge, "encoded document exceeds 1 MiB") + } + if len(doc) == 0 && !required { + return nil + } + trimmed := bytes.TrimSpace(doc) + if !utf8.Valid(doc) || !json.Valid(doc) || len(trimmed) == 0 || trimmed[0] != '{' { + return Errorf(CodeBadParams, "doc must be an editor JSON object") + } + return nil +} + +func (p WorkstreamRef) Check() error { + if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.SessionID), workstreamOptionalID(p.Factory)); err != nil { + return err + } + if p.Factory != nil && p.Kind != RefFactoryJob { + return Errorf(CodeBadParams, "only a factory_job reference has a factory") + } + if p.SessionID != nil && p.Kind != RefTask && p.Kind != RefProcess { + return Errorf(CodeBadParams, "only a task or process reference has a session_id") + } + return CheckRef(Ref{Kind: p.Kind, ID: p.ID, SessionID: workstreamString(p.SessionID), Factory: workstreamString(p.Factory)}, true) +} + +func workstreamString(p *string) string { + if p == nil { + return "" + } + return *p +} + +func (p WorkstreamSource) Check() error { + if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Label, 0, 200, "source label")); err != nil { + return err + } + switch p.Kind { + case "chat", "page", "url": + case "message": + if p.ChatID == nil { + return Errorf(CodeBadParams, "message source requires chat_id") + } + if err := workstreamID(*p.ChatID); err != nil { + return err + } + case "factory_job": + if err := CheckRef(Ref{Kind: RefFactoryJob, ID: p.ID, Factory: workstreamString(p.Factory)}, true); err != nil { + return err + } + default: + return Errorf(CodeBadParams, "unknown source kind %q", p.Kind) + } + return workstreamChecks(workstreamOptionalID(p.ChatID), workstreamOptionalID(p.Factory)) +} + +func workstreamSource(p *WorkstreamSource) error { + if p == nil { + return nil + } + return p.Check() +} + +func (p WorkstreamListParams) Check() error { return nil } +func (p WorkstreamGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamReopenParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamPlanGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamPageListParams) Check() error { return workstreamID(p.WorkstreamID) } +func (p WorkstreamPageGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamPageDeleteParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamRecordKeepParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamRecordDropParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamGoAheadRevokeParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamGoAheadListParams) Check() error { return workstreamID(p.WorkstreamID) } + +func (p WorkstreamCreateParams) Check() error { + if !utf8.ValidString(p.Name) { + return Errorf(CodeBadParams, "name must be valid UTF-8") + } + if _, err := collectionName(p.Name); err != nil { + return err + } + if err := workstreamChecks(workstreamOptionalText(p.Why, 0, 4000, "why"), workstreamOptionalLine(p.ClientID, 0, 128, "client_id"), workstreamOptionalID(p.OriginChatID), workstreamOptionalID(p.Model)); err != nil { + return err + } + if len(p.Refs) > CollectionRefsMax { + return Errorf(CodeBadParams, "refs takes at most %d references", CollectionRefsMax) + } + for _, r := range p.Refs { + if err := r.Check(); err != nil { + return err + } + } + return nil +} + +func (p WorkstreamUpdateParams) Check() error { + if p.Patch.Name != nil { + if !utf8.ValidString(*p.Patch.Name) { + return Errorf(CodeBadParams, "name must be valid UTF-8") + } + if _, err := collectionName(*p.Patch.Name); err != nil { + return err + } + } + if p.Patch.BudgetUSD != nil && (math.IsNaN(*p.Patch.BudgetUSD) || math.IsInf(*p.Patch.BudgetUSD, 0) || *p.Patch.BudgetUSD < 0) { + return Errorf(CodeBadParams, "budget_usd must be finite and nonnegative") + } + return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.Patch.Why, 0, 4000, "why"), workstreamOptionalEnum(p.Patch.Autonomy, "propose", "small", "off")) +} +func (p WorkstreamMarkDoneParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.HowItWent, 0, 4000, "how_it_went")) +} + +func (p WorkstreamPlanSetParams) Check() error { + if err := workstreamID(p.ID); err != nil { + return err + } + if p.BaseVersion < 0 || p.Milestones == nil || p.Waves == nil { + return Errorf(CodeBadParams, "plan requires nonnegative base_version and milestones and waves arrays") + } + for _, m := range p.Milestones { + if m.ID != "" { + if err := workstreamID(m.ID); err != nil { + return err + } + } + if err := workstreamLine(m.Title, 1, 200, "milestone title"); err != nil { + return err + } + } + last := 0 + for _, w := range p.Waves { + if w.N <= last || w.Items == nil { + return Errorf(CodeBadParams, "waves must be positive, unique, ascending and have items arrays") + } + last = w.N + if err := workstreamChecks(workstreamOptionalID(w.MilestoneID), workstreamOptionalLine(w.Title, 0, 200, "wave title")); err != nil { + return err + } + for _, item := range w.Items { + if item.Job != nil && *item.Job <= 0 { + return Errorf(CodeBadParams, "plan job must be positive") + } + if err := workstreamChecks(workstreamOptionalID(item.Factory), workstreamOptionalID(item.DraftID)); err != nil { + return err + } + switch item.Kind { + case "job": + if item.Job == nil || *item.Job <= 0 || item.Factory == nil { + return Errorf(CodeBadParams, "plan job requires factory and positive job") + } + if err := workstreamID(*item.Factory); err != nil { + return err + } + case "draft": + if item.DraftID == nil || item.Title == nil { + return Errorf(CodeBadParams, "draft requires draft_id and title") + } + if err := workstreamChecks(workstreamID(*item.DraftID), workstreamLine(*item.Title, 1, 200, "draft title")); err != nil { + return err + } + if item.Text != nil { + if err := workstreamText(*item.Text, 0, len(*item.Text), "draft text"); err != nil { + return err + } + } + default: + return Errorf(CodeBadParams, "unknown plan item kind %q", item.Kind) + } + } + } + return nil +} + +func (p WorkstreamPageSaveParams) Check() error { + if p.BaseVersion < 0 { + return Errorf(CodeBadParams, "base_version must be nonnegative") + } + return workstreamChecks(workstreamID(p.ID), workstreamDoc(p.Doc, true)) +} +func (p WorkstreamPageCreateParams) Check() error { + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID), workstreamLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent"), workstreamDoc(p.Doc, false)) +} +func (p WorkstreamPageUpdateParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent")) +} +func (p WorkstreamPageMoveParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID)) +} +func (p WorkstreamPageHistoryParams) Check() error { + _, err := bounded("limit", p.Limit, 1, 200, 50) + return workstreamChecks(workstreamID(p.ID), err) +} +func (p WorkstreamPageRestoreParams) Check() error { + if p.Version < 1 || p.BaseVersion < 0 { + return Errorf(CodeBadParams, "restore requires positive version and nonnegative base_version") + } + return workstreamID(p.ID) +} +func (p WorkstreamRecordListParams) Check() error { + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalEnum(p.Kind, "decision", "question"), workstreamOptionalEnum(p.State, "proposed", "kept", "dropped", "superseded")) +} +func (p WorkstreamRecordProposeParams) Check() error { + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamEnum(p.Kind, "decision", "question"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) +} +func (p WorkstreamRecordSupersedeParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalEnum(p.Kind, "decision", "question"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) +} +func (p WorkstreamGoAheadGrantParams) Check() error { + if err := workstreamID(p.WorkstreamID); err != nil { + return err + } + if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || p.EndsAt.IsZero() { + return Errorf(CodeBadParams, "go-ahead requires cap_usd greater than 0 and at most %d, and ends_at", workspace.WorkstreamGoAheadCapMax) + } + switch p.Scope { + case "job": + if p.Factory == nil || p.Job == nil || *p.Job <= 0 || p.Wave != nil || p.MilestoneID != nil { + return Errorf(CodeBadParams, "job scope requires factory and job only") + } + return workstreamID(*p.Factory) + case "wave": + if p.Wave == nil || *p.Wave <= 0 || p.Factory != nil || p.Job != nil || p.MilestoneID != nil { + return Errorf(CodeBadParams, "wave scope requires wave only") + } + case "milestone": + if p.MilestoneID == nil || p.Factory != nil || p.Job != nil || p.Wave != nil { + return Errorf(CodeBadParams, "milestone scope requires milestone_id only") + } + return workstreamID(*p.MilestoneID) + default: + return Errorf(CodeBadParams, "unknown go-ahead scope %q", p.Scope) + } + return nil +} diff --git a/internal/appwire/workstreams_check_test.go b/internal/appwire/workstreams_check_test.go new file mode 100644 index 0000000000..117f128e14 --- /dev/null +++ b/internal/appwire/workstreams_check_test.go @@ -0,0 +1,137 @@ +package appwire + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +// B1: malformed requests are refused at the real typed dispatch door, before +// the serving fake (which always answers unknown_method) can receive them. +func TestWorkstreamBoundsBeforeDelegation(t *testing.T) { + cases := []struct{ method, params, code string }{ + {"workstream.get", `{}`, CodeBadParams}, + {"workstream.create", `{"name":""}`, CodeBadParams}, + {"workstream.create", `{"name":"x","client_id":"` + strings.Repeat("a", 129) + `"}`, CodeBadParams}, + {"workstream.create", `{"name":"x","why":"` + strings.Repeat("a", 4001) + `"}`, CodeBadParams}, + {"workstream.create", `{"name":"x","refs":[{"kind":"factory_job","id":"01","factory":"f"}]}`, CodeBadParams}, + {"workstream.create", `{"name":"x","refs":[` + strings.Repeat(`{"kind":"conversation","id":"c"},`, 200) + `{"kind":"conversation","id":"c"}]}`, CodeBadParams}, + {"workstream.update", `{"id":"x"}`, CodeBadParams}, + {"workstream.update", `{"id":"x","patch":{"autonomy":"auto"}}`, CodeBadParams}, + {"workstream.update", `{"id":"x","patch":{"wake":{"morning":false}}}`, CodeBadParams}, + {"workstream.mark_done", `{"id":"x","how_it_went":"` + strings.Repeat("a", 4001) + `"}`, CodeBadParams}, + {"workstream.plan_set", `{"id":"x","milestones":[],"waves":[]}`, CodeBadParams}, + {"workstream.plan_set", `{"id":"x","base_version":-1,"milestones":[],"waves":[]}`, CodeBadParams}, + {"workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":0,"auto_start":false,"items":[]}]}`, CodeBadParams}, + {"page.create", `{"workstream_id":"x","title":"x","owner":"other"}`, CodeBadParams}, + {"page.create", `{"workstream_id":"x","title":"x","doc":[]}`, CodeBadParams}, + {"page.update", `{"id":"x","title":""}`, CodeBadParams}, + {"page.update", `{"id":"x","title":"` + strings.Repeat("t", 201) + `"}`, CodeBadParams}, + {"page.save", `{"id":"x","base_version":1}`, CodeBadParams}, + {"page.save", `{"id":"x","base_version":1,"doc":{"text":"` + strings.Repeat("a", 1<<20) + `"}}`, CodeTooLarge}, + {"page.move", `{"id":"x","parent_id":""}`, CodeBadParams}, + {"page.history", `{"id":"x","limit":0}`, CodeBadParams}, + {"page.history", `{"id":"x","limit":201}`, CodeBadParams}, + {"page.restore", `{"id":"x","version":0,"base_version":1}`, CodeBadParams}, + {"record.list", `{"workstream_id":"x","state":"active"}`, CodeBadParams}, + {"record.propose", `{"workstream_id":"x","kind":"decision","text":""}`, CodeBadParams}, + {"record.propose", `{"workstream_id":"x","kind":"decision","text":"` + strings.Repeat("t", 2001) + `"}`, CodeBadParams}, + {"record.propose", `{"workstream_id":"x","kind":"question","text":"x","source":{"kind":"message","id":"m"}}`, CodeBadParams}, + {"record.supersede", `{"id":"x","text":"x","kind":"other"}`, CodeBadParams}, + {"goahead.grant", `{"workstream_id":"x","scope":"wave","wave":1,"ends_at":"2099-01-01T00:00:00Z"}`, CodeBadParams}, + {"goahead.grant", `{"workstream_id":"x","scope":"wave","wave":1,"cap_usd":10001,"ends_at":"2099-01-01T00:00:00Z"}`, CodeBadParams}, + } + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for _, tc := range cases { + t.Run(tc.method+tc.params[:min(len(tc.params), 55)], func(t *testing.T) { + _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) + wire, ok := err.(*Error) + if !ok || wire.Code != tc.code { + t.Fatalf("got %v, want %s", err, tc.code) + } + }) + } +} + +func TestWorkstreamBoundsPreserveExplicitZeroAndFalse(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for method, params := range map[string]string{ + "workstream.plan_set": `{"id":"x","base_version":0,"milestones":[],"waves":[]}`, + "workstream.update": `{"id":"x","patch":{"why":"","budget_usd":0,"wake":{"morning":false,"related_issues":false}}}`, + "page.create": `{"workstream_id":"x","title":"x","read_by_jobs":false}`, + } { + _, err := methods[method].call(fake, json.RawMessage(params)) + if err != ErrWorkstreamsNotYet { + t.Fatalf("%s did not reach the fake: %v", method, err) + } + } +} + +func TestWorkstreamControlCharactersBeforeDelegation(t *testing.T) { + cases := []struct { + name, method, params string + prose bool + }{ + {"name", "workstream.create", `{"name":$text}`, false}, + {"rename", "workstream.update", `{"id":"x","patch":{"name":$text}}`, false}, + {"client_id", "workstream.create", `{"name":"x","client_id":$text}`, false}, + {"page title", "page.create", `{"workstream_id":"x","title":$text}`, false}, + {"updated page title", "page.update", `{"id":"x","title":$text}`, false}, + {"milestone title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[{"id":"","title":$text}],"waves":[]}`, false}, + {"wave title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"title":$text,"auto_start":false,"items":[]}]}`, false}, + {"draft title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":$text}]}]}`, false}, + {"source label", "record.propose", `{"workstream_id":"x","kind":"decision","text":"x","source":{"kind":"chat","id":"c","label":$text}}`, false}, + {"replacement source label", "record.supersede", `{"id":"x","text":"x","source":{"kind":"chat","id":"c","label":$text}}`, false}, + {"why", "workstream.create", `{"name":"x","why":$text}`, true}, + {"updated why", "workstream.update", `{"id":"x","patch":{"why":$text}}`, true}, + {"how_it_went", "workstream.mark_done", `{"id":"x","how_it_went":$text}`, true}, + {"record text", "record.propose", `{"workstream_id":"x","kind":"question","text":$text}`, true}, + {"replacement text", "record.supersede", `{"id":"x","text":$text}`, true}, + {"draft text", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft","text":$text}]}]}`, true}, + } + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for _, tc := range cases { + for control := rune(0); control <= 0x9f; control++ { + if control > 0x1f && control < 0x7f { + continue + } + t.Run(fmt.Sprintf("%s/U+%04X", tc.name, control), func(t *testing.T) { + value, err := json.Marshal("before" + string(control) + "after") + if err != nil { + t.Fatal(err) + } + params := strings.ReplaceAll(tc.params, "$text", string(value)) + _, err = methods[tc.method].call(fake, json.RawMessage(params)) + if tc.prose && (control == '\n' || control == '\t') { + if err != ErrWorkstreamsNotYet { + t.Fatalf("allowed prose did not reach the fake: %v", err) + } + return + } + wire, ok := err.(*Error) + if !ok || wire.Code != CodeBadParams { + t.Fatalf("got %v, want %s before delegation", err, CodeBadParams) + } + }) + } + } +} + +func TestWorkstreamTextFieldsPreserveAllowedContent(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for _, tc := range []struct{ method, params string }{ + {"workstream.create", `{"name":"Launch","client_id":"","why":""}`}, + {"workstream.create", `{"name":"Launch","client_id":" retry ","why":"first\n\tsecond"}`}, + {"page.create", `{"workstream_id":"x","title":" Résumé 界 "}`}, + {"record.propose", `{"workstream_id":"x","kind":"question","text":"first\n\tsecond","source":{"kind":"chat","id":"c","label":" Label "}}`}, + {"workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"title":"","auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":" Draft ","text":""}]}]}`}, + } { + t.Run(tc.method+tc.params, func(t *testing.T) { + _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) + if err != ErrWorkstreamsNotYet { + t.Fatalf("allowed content did not reach the fake: %v", err) + } + }) + } +} diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 0b878f3181..825d277aff 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -3,7 +3,7 @@ ## How do I group chats in logical folders `codeaf collections` is a local command for organizing references to conversations, -tasks and files. A collection is a logical folder with a stable ID +tasks, Factory jobs and files. A collection is a logical folder with a stable ID and name. Several collections can reference the same record. Collections can contain other collections, including one child shared by several parents; circular membership is refused. Renaming a collection preserves its ID. @@ -71,7 +71,8 @@ belong to collections. ## Adding, removing and finding a chat or work reference Use `codeaf collections add `. -The kinds are `collection`, `conversation`, `task`, `process`, `crew` and `artifact`. There is no kind +The kinds are `collection`, `conversation`, `task`, `process`, `crew`, +`factory_job` and `artifact`. There is no kind for an automation. `standing` named a standing item, which codeaf no longer has: a row that already holds one can still be found and removed, and adding a new one is refused with `standing items are gone, so a collection cannot gain one`. @@ -100,6 +101,21 @@ something that was never there is not an error. Automatic organization, inherited instructions and communication between conversations are not implemented by these commands. +## Adding, finding and removing a Factory job + +Use `codeaf collections add factory_job --factory +`. The job number must be a positive decimal without a sign or leading +zeroes. The factory ID is required: the same number in two factories names two +different jobs. Use the same `--factory` with `find` and `remove`. Other kinds +refuse a factory ID. `show` prints `factory_job factory `; +`--json` includes the `factory` field. + +A plain collection can reference a job alongside any other records. A job can +belong to only one workstream collection. Adding a job already in another +workstream answers `that job belongs to another workstream` and leaves every +reference in that add unchanged. These commands retain references without asking +the Factory whether the job is available and never start a job. + ## Deleting or duplicating a collection `codeaf collections delete ` deletes the collection, its @@ -108,3 +124,48 @@ memberships, and every membership naming it in another collection, answering referenced is deleted, moved or stopped. `codeaf collections duplicate ` makes a new collection holding the same references, in the same order; the new name follows the same uniqueness rule as `create`. + +A workstream collection refuses deletion with `mark a workstream done instead`. +Duplicating one makes a plain collection with the same references; it creates no +workstream and claims no jobs. + +## Workstreams in an app client: stages and agent chats + +An app client can create a workstream backed by a collection with the same ID. +It includes one agent conversation, an optional origin chat, and the references +you choose. Creation opens the agent without sending a first message or running +a model. Repeating a nonempty client ID returns the original workstream. +Names follow collection naming rules; the client ID is at most 128 bytes and a +create takes at most 200 explicit references. + +A workstream starts in Shaping. Observing a member job queued, running, needing +you or done changes it to Building, which persists even if jobs return to Backlog. +Only marking the workstream done gives Done; all jobs merged does not. Reopening +returns it to Building. Marking done can archive its member chats while keeping +the agent's record. Collection rename and membership commands use the same store. + +## Workstream pages, records, plans and go-aheads + +Pages, page history, plans, decisions, questions and settings live in SQLite. +Markdown is an export, not a second store. A new workstream has an Overview page; +it can be renamed but cannot move or be deleted. Other pages can form a tree; +deleting a page deletes its descendants. Saving a stale page or plan refuses +without changing it. Restoring a page creates a new version. Page titles take +1–200 bytes and editor documents at most 1 MiB; record text takes 1–2000 bytes. +Client IDs, titles and source labels refuse control characters. Why, how it went, +record text and draft text allow newline and tab, and refuse other control +characters. Malformed app requests return `bad_params`. + +Records can be proposed, kept, dropped and replaced. Keep can undo a drop; +a replaced record cannot change again. Plans store milestones, ordered waves +and job or draft items. Wave auto-start is stored without starting jobs. + +A go-ahead requires a member job, a planned wave or milestone, a positive cap +at most $10,000 and an end in the future at most 90 days away. Expired grants +read as ended. Marking done ends every still-active grant; granting on a done +workstream is refused. These are stored permissions; Factory enforcement is a +separate capability. Workstream numbers use recorded job facts and agent spend. +Updating, marking done and reopening persist the first observed start and return +those numbers using the same reads as getting the workstream. +Unavailable readings stay absent; a measured zero is present. Budgets and autonomy +settings do not themselves start work. diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go new file mode 100644 index 0000000000..f61626e0d5 --- /dev/null +++ b/internal/workspace/schema3_test.go @@ -0,0 +1,212 @@ +package workspace + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// Part A (a): this literal is schema 2, independent of the schema being written. +const schemaTwoDDL = ` +CREATE TABLE collections (seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,created_at INTEGER NOT NULL DEFAULT 0); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT,collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process')), + ref_id TEXT NOT NULL,session_id TEXT NOT NULL,target_collection TEXT REFERENCES collections(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL DEFAULT 0, + CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) OR (kind!='collection' AND target_collection IS NULL)), + UNIQUE(collection_id,kind,ref_id,session_id)); +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_target ON memberships(target_collection); +PRAGMA application_id=1095123788; PRAGMA user_version=2; +INSERT INTO collections(seq,id,name,created_at) VALUES (4,'aaaa','Product',1234),(9,'bbbb','Nested',2345); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) VALUES + (2,'aaaa','conversation','chat','',NULL,4567),(6,'aaaa','task','3','chat',NULL,5678), + (11,'aaaa','collection','bbbb','','bbbb',6789); +` + +func writeSchemaTwo(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "two.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(schemaTwoDDL); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + return path +} + +func TestPartASchemaTwoReadsWithoutWritingAndMigratesAllRows(t *testing.T) { + ctx := context.Background() + path := writeSchemaTwo(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + wantC, wantM := legacyRows(t, s.db) + list, err := s.List(ctx, &Ref{Kind: ConversationKind, ID: "chat"}) + if err != nil || len(list) != 2 || list[0].CreatedAt != 1234 || list[0].LastAddedAt != 6789 || !*list[0].Holds { + t.Fatalf("schema 2 read: %+v, %v", list, err) + } + tree, err := s.Tree(ctx, "aaaa", 0, 20, "") + if err != nil || len(tree.Members) != 3 || tree.Members[0].AddedAt != 6789 { + t.Fatalf("tree: %+v, %v", tree, err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("read changed schema 2") + } + if err := s.Rename(ctx, "aaaa", "Products"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 3 { + t.Fatal("first write did not migrate to 3") + } + gotC, gotM := legacyRows(t, s.db) + wantC[0] = strings.Replace(wantC[0], "Product|", "Products|", 1) + if !reflect.DeepEqual(gotC, wantC) || !reflect.DeepEqual(gotM, wantM) { + t.Fatalf("migration lost rows: %v %v", gotC, gotM) + } + rows, err := s.db.Query("SELECT seq,created_at FROM collections ORDER BY seq") + if err != nil { + t.Fatal(err) + } + var collectionTimes [][2]int64 + for rows.Next() { + var seq, at int64 + if err := rows.Scan(&seq, &at); err != nil { + t.Fatal(err) + } + collectionTimes = append(collectionTimes, [2]int64{seq, at}) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(collectionTimes, [][2]int64{{4, 1234}, {9, 2345}}) { + t.Fatalf("collection times changed: %+v", collectionTimes) + } + rows, err = s.db.Query("SELECT seq,added_at FROM memberships ORDER BY seq") + if err != nil { + t.Fatal(err) + } + var memberTimes [][2]int64 + for rows.Next() { + var seq, at int64 + if err := rows.Scan(&seq, &at); err != nil { + t.Fatal(err) + } + memberTimes = append(memberTimes, [2]int64{seq, at}) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(memberTimes, [][2]int64{{2, 4567}, {6, 5678}, {11, 6789}}) { + t.Fatalf("membership times changed: %+v", memberTimes) + } + for _, name := range []string{"workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} { + var n int + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n); err != nil || n != 1 { + t.Fatalf("missing %s: %v", name, err) + } + } + if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "17", Factory: "factory-a"}); err != nil { + t.Fatal(err) + } +} + +func TestPartASchemaTwoMigrationRollsBackWithFailedWrite(t *testing.T) { + path := writeSchemaTwo(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + if err := s.Rename(context.Background(), "aaaa", "Nested"); err != ErrNameTaken { + t.Fatalf("want name conflict: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) || userVersion(t, s) != 2 { + t.Fatal("failed first write changed file") + } +} + +func TestPartAFactoryJobRefValidation(t *testing.T) { + for _, tc := range []struct { + ref Ref + ok bool + }{ + {Ref{Kind: FactoryJobKind, ID: "17", Factory: "f"}, true}, + {Ref{Kind: FactoryJobKind, ID: "0", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "017", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "+17", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17", Factory: " f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17", Factory: "f", SessionID: "chat"}, false}, + {Ref{Kind: ConversationKind, ID: "chat", Factory: "f"}, false}, + } { + if err := tc.ref.Validate(); (err == nil) != tc.ok { + t.Errorf("%+v: %v", tc.ref, err) + } + } +} + +// Part A (a): deleted sequence numbers remain retired across both migrations, +// preserving the monotonic keys used by existing listing and member cursors. +func TestPartAMigrationPreservesSequenceHighWatermarks(t *testing.T) { + for _, tc := range []struct { + name string + fixture func(*testing.T) string + }{ + {"schema 1", writeLegacyStore}, {"schema 2", writeSchemaTwo}, + } { + t.Run(tc.name, func(t *testing.T) { + s := openTestStore(t, tc.fixture(t)) + ctx := context.Background() + if _, err := s.db.Exec(`INSERT INTO collections(seq,id,name) VALUES (100,'gone','Gone'); +DELETE FROM collections WHERE id='gone'; +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id) VALUES (200,'aaaa','conversation','gone',''); +DELETE FROM memberships WHERE ref_id='gone';`); err != nil { + t.Fatal(err) + } + c, err := s.Create(ctx, "After") + if err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, Ref{Kind: FactoryJobKind, ID: "1", Factory: "f"}); err != nil { + t.Fatal(err) + } + var collectionSeq, memberSeq int64 + if err := s.db.QueryRow("SELECT seq FROM collections WHERE id=?", c.ID).Scan(&collectionSeq); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT seq FROM memberships WHERE collection_id=?", c.ID).Scan(&memberSeq); err != nil { + t.Fatal(err) + } + if collectionSeq <= 100 || memberSeq <= 200 { + t.Fatalf("reused retired sequences: collection %d member %d", collectionSeq, memberSeq) + } + }) + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index 6319953420..6f2a5c33bc 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -20,13 +20,10 @@ import ( sqlite3 "modernc.org/sqlite/lib" ) -// schemaVersion is the schema this build writes. Schema 2 added when a -// collection was made and when each membership was added, the crew and -// process kinds, and deletes that cascade. legacySchemaVersion is the one -// older schema this build still reads as it is and migrates on its first -// write; anything else claiming to be ours is refused, never misread. +// schemaVersion is the schema this build writes. Schemas 1 and 2 remain +// readable without migration; the first successful write upgrades them to 3. const ( - schemaVersion = 2 + schemaVersion = 3 legacySchemaVersion = 1 ) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. @@ -43,6 +40,7 @@ const ( schemaBlank schemaState = iota schemaReady schemaLegacy + schemaTwo schemaRefused ) @@ -51,13 +49,15 @@ func (s schemaState) version() int { switch s { case schemaReady: return schemaVersion + case schemaTwo: + return 2 case schemaLegacy: return legacySchemaVersion } return 0 } -// Store owns collection metadata only. Transcripts, task checkpoints, standing +// Store owns collection and workstream metadata. Transcripts, task checkpoints, standing // items and artifacts retain their existing owners and persistence formats. // // THE SCHEMA IS BUILT BY THE FIRST WRITE THAT NEEDS IT, never by Open. Opening @@ -66,7 +66,7 @@ func (s schemaState) version() int { // lock to do it — which is what put a plain read behind whichever peer happened // to be adding a membership. `version` is that question already answered (0 // while the file is blank), and `schemaMu` keeps two goroutines on one handle -// from both answering it. The same law covers migration: a schema-1 file is +// from both answering it. The same law covers migration: a schema-1 or schema-2 file is // read as it stands and rebuilt only by the first write. type Store struct { db *sql.DB @@ -174,10 +174,16 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := q.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return schemaBlank, err } - if app == applicationID && (version == schemaVersion || version == legacySchemaVersion) { + if app == applicationID && (version == schemaVersion || version == 2 || version == legacySchemaVersion) { state := schemaReady collections := "SELECT seq,id,name,created_at FROM collections LIMIT 0" memberships := "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at FROM memberships LIMIT 0" + if version == 2 { + state = schemaTwo + } + if version == schemaVersion { + memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships LIMIT 0" + } if version == legacySchemaVersion { state = schemaLegacy collections = "SELECT seq,id,name FROM collections LIMIT 0" @@ -191,6 +197,13 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if _, err := q.ExecContext(ctx, memberships); err != nil { return state, err } + if version == schemaVersion { + for _, query := range workstreamSchemaReads { + if _, err := q.ExecContext(ctx, query); err != nil { + return state, err + } + } + } return state, nil } if app != 0 || version != 0 { @@ -205,7 +218,7 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { return schemaBlank, nil } -// schemaTables is schema 2's two tables under the names given, so the same +// schemaTables builds the collection tables under the names given, so the same // words build a fresh store and the copies a migration fills. // // EVERY DELETE CASCADES: a deleted collection takes its own memberships and @@ -222,24 +235,26 @@ CREATE TABLE %[1]s ( CREATE TABLE %[2]s ( seq INTEGER PRIMARY KEY AUTOINCREMENT, collection_id TEXT NOT NULL REFERENCES %[1]s(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process')), + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process','factory_job')), ref_id TEXT NOT NULL, session_id TEXT NOT NULL, target_collection TEXT REFERENCES %[1]s(id) ON DELETE CASCADE, added_at INTEGER NOT NULL DEFAULT 0, + factory TEXT NOT NULL DEFAULT '', + CHECK ((kind='factory_job' AND length(factory)>0) OR (kind!='factory_job' AND factory='')), CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) OR (kind!='collection' AND target_collection IS NULL)), - UNIQUE(collection_id,kind,ref_id,session_id) + UNIQUE(collection_id,kind,ref_id,session_id,factory) ); `, collections, memberships) } const schemaIndexes = ` -CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id,factory); CREATE INDEX memberships_target ON memberships(target_collection); ` -// migrateLegacy rebuilds a schema-1 file as schema 2 inside the caller's +// migrateLegacy rebuilds a schema-1 or schema-2 file as schema 3 inside the caller's // immediate transaction: either every row arrives in the new tables, in the // same order under the same sequence numbers, or the file is left exactly as // it was. A row from schema 1 says nothing of when it was made, so its @@ -248,64 +263,77 @@ CREATE INDEX memberships_target ON memberships(target_collection); // THE TABLES ARE COPIED, NOT ALTERED, because SQLite cannot change a CHECK or // a foreign key in place. The old memberships go first so dropping the old // collections deletes nothing anything still points at, and renaming the copy -// carries the foreign keys that name it along. -func migrateLegacy(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, schemaTables("collections_v2", "memberships_v2")+` -INSERT INTO collections_v2(seq,id,name,created_at) SELECT seq,id,name,0 FROM collections ORDER BY seq; -INSERT INTO memberships_v2(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) - SELECT seq,collection_id,kind,ref_id,session_id,target_collection,0 FROM memberships ORDER BY seq; +// carries the foreign keys that name it along. Sequence high-water marks also +// survive deleted rows so migration cannot reuse a sequence named by an older +// page cursor. +func migrateLegacy(ctx context.Context, tx *sql.Tx, version int) error { + created, added := "0", "0" + if version >= 2 { + created, added = "created_at", "added_at" + } + _, err := tx.ExecContext(ctx, schemaTables("collections_v3", "memberships_v3")+fmt.Sprintf(` +INSERT INTO collections_v3(seq,id,name,created_at) SELECT seq,id,name,%s FROM collections ORDER BY seq; +INSERT INTO memberships_v3(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) + SELECT seq,collection_id,kind,ref_id,session_id,target_collection,%s FROM memberships ORDER BY seq; +UPDATE sqlite_sequence SET seq=max(seq,coalesce((SELECT seq FROM sqlite_sequence WHERE name='collections'),0)) + WHERE name='collections_v3'; +INSERT INTO sqlite_sequence(name,seq) SELECT 'collections_v3',seq FROM sqlite_sequence + WHERE name='collections' AND NOT EXISTS(SELECT 1 FROM sqlite_sequence WHERE name='collections_v3'); +UPDATE sqlite_sequence SET seq=max(seq,coalesce((SELECT seq FROM sqlite_sequence WHERE name='memberships'),0)) + WHERE name='memberships_v3'; +INSERT INTO sqlite_sequence(name,seq) SELECT 'memberships_v3',seq FROM sqlite_sequence + WHERE name='memberships' AND NOT EXISTS(SELECT 1 FROM sqlite_sequence WHERE name='memberships_v3'); DROP TABLE memberships; DROP TABLE collections; -ALTER TABLE collections_v2 RENAME TO collections; -ALTER TABLE memberships_v2 RENAME TO memberships; -`+schemaIndexes) +ALTER TABLE collections_v3 RENAME TO collections; +ALTER TABLE memberships_v3 RENAME TO memberships; +`, created, added)+schemaIndexes+workstreamSchema) return err } -// ensureSchema serializes initialization on one handle and then takes SQLite's -// immediate transaction so separate processes also agree which one builds it, -// or which one migrates a schema-1 file to this one. -func (s *Store) ensureSchema(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return storeError(err) - } - s.schemaMu.Lock() - defer s.schemaMu.Unlock() - if s.version == schemaVersion { - return nil +// beginWrite ensures schema and data changes share one immediate transaction. +// A failed first write rolls back the migration as well as the requested change. +func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { + if !create { + version, err := s.readyForRead(ctx) + if err != nil { + return nil, err + } + if version == 0 { + return nil, ErrNotFound + } } tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return storeError(err) + return nil, storeError(err) } - defer tx.Rollback() state, err := inspectSchema(ctx, tx) - if err != nil { - return storeError(err) - } - switch state { - case schemaReady: - if err := tx.Commit(); err != nil { - return storeError(err) + if err == nil { + switch state { + case schemaReady: + case schemaLegacy, schemaTwo: + err = migrateLegacy(ctx, tx, state.version()) + case schemaBlank: + if !create { + err = ErrNotFound + } else { + _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes+workstreamSchema) + } } - s.version = schemaVersion - return nil - case schemaLegacy: - err = migrateLegacy(ctx, tx) - default: - _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes) } - if err != nil { - return storeError(err) - } - if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)); err != nil { - return storeError(err) + if err == nil && state != schemaReady { + _, err = tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)) } - if err := tx.Commit(); err != nil { - return storeError(err) + if err != nil { + _ = tx.Rollback() + return nil, storeError(err) } - s.version = schemaVersion - return nil + return tx, nil +} + +// ensureSchema is used by bulk fixtures that initialize without adding rows. +func (s *Store) ensureSchema(ctx context.Context) error { + return s.Write(ctx, func(*sql.Tx) error { return nil }) } // readyForRead answers the schema a read goes through, and 0 for a blank @@ -329,26 +357,10 @@ func (s *Store) readyForRead(ctx context.Context) (int, error) { return s.version, nil } -// readyForWrite is readyForRead for a change to a collection that must -// already exist: false on a blank file, which such a change cannot find -// anything in and must leave blank, and a schema-1 file migrated first. -func (s *Store) readyForWrite(ctx context.Context) (bool, error) { - version, err := s.readyForRead(ctx) - if err != nil || version == 0 { - return false, err - } - if version != schemaVersion { - if err := s.ensureSchema(ctx); err != nil { - return false, err - } - } - return true, nil -} - // columns are the expressions a read uses for created_at and added_at: the // columns themselves at schema 2, and the unknown 0 on a schema-1 file. func columns(version int) (created, added string) { - if version >= schemaVersion { + if version >= 2 { return "c.created_at", "m.added_at" } return "0", "0" @@ -409,19 +421,13 @@ func (s *Store) Create(ctx context.Context, name string) (Collection, error) { if err != nil { return Collection{}, storeError(err) } - if err := s.ensureSchema(ctx); err != nil { - return Collection{}, storeError(err) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, true) if err != nil { return Collection{}, storeError(err) } defer tx.Rollback() - if err := nameTaken(ctx, tx, name, ""); err != nil { - return Collection{}, storeError(err) - } c := Collection{ID: id, Name: name} - if _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", c.ID, c.Name, s.millis()); err != nil { + if err := CreateIn(ctx, tx, id, name, s.millis()); err != nil { return Collection{}, storeError(err) } if err := tx.Commit(); err != nil { @@ -434,25 +440,15 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { if err := ValidateName(name); err != nil { return storeError(err) } - ready, err := s.readyForWrite(ctx) - if err != nil { - return storeError(err) - } - if !ready { - return storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return storeError(err) } defer tx.Rollback() - if err := requireCollection(ctx, tx, id); err != nil { - return storeError(err) - } - if err := nameTaken(ctx, tx, name, id); err != nil { + if err := RenameIn(ctx, tx, id, name); err != nil { return storeError(err) } - if _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id); err != nil { + if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { return storeError(err) } return storeError(tx.Commit()) @@ -463,14 +459,7 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { // first, because each of them changed too. The records any of it referenced // are untouched. func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { - ready, err := s.readyForWrite(ctx) - if err != nil { - return nil, storeError(err) - } - if !ready { - return nil, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, storeError(err) } @@ -478,6 +467,13 @@ func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { if err := requireCollection(ctx, tx, id); err != nil { return nil, storeError(err) } + var isWorkstream bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstreams WHERE id=?)", id).Scan(&isWorkstream); err != nil { + return nil, storeError(err) + } + if isWorkstream { + return nil, ErrWorkstreamDelete + } rows, err := tx.QueryContext(ctx, `SELECT c.id FROM collections c WHERE c.id!=? AND EXISTS( SELECT 1 FROM memberships m WHERE m.collection_id=c.id AND m.kind='collection' AND m.ref_id=?) ORDER BY c.seq`, id, id) if err != nil { @@ -495,6 +491,11 @@ func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { if err := rows.Close(); err != nil { return nil, storeError(err) } + for _, parent := range parents { + if err := touchWorkstream(ctx, tx, parent, s.millis()); err != nil { + return nil, storeError(err) + } + } if _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", id); err != nil { return nil, storeError(err) } @@ -511,18 +512,11 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, err if err := ValidateName(name); err != nil { return Collection{}, storeError(err) } - ready, err := s.readyForWrite(ctx) - if err != nil { - return Collection{}, storeError(err) - } - if !ready { - return Collection{}, storeError(ErrNotFound) - } copyID, err := newID() if err != nil { return Collection{}, storeError(err) } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return Collection{}, storeError(err) } @@ -536,8 +530,8 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, err if _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", copyID, name, s.millis()); err != nil { return Collection{}, storeError(err) } - if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at) - SELECT ?,kind,ref_id,session_id,target_collection,added_at FROM memberships WHERE collection_id=? ORDER BY seq`, copyID, id); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at,factory) + SELECT ?,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships WHERE collection_id=? ORDER BY seq`, copyID, id); err != nil { return Collection{}, storeError(err) } if err := tx.Commit(); err != nil { @@ -547,27 +541,30 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, err } func (s *Store) Collections(ctx context.Context) ([]Collection, error) { - version, err := s.readyForRead(ctx) - if err != nil { - return nil, storeError(err) - } - if version == 0 { - return make([]Collection, 0), nil - } - rows, err := s.db.QueryContext(ctx, "SELECT id,name FROM collections ORDER BY seq") + out := make([]Collection, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT c.id,c.name,"+workstreamColumn(version)+" FROM collections c ORDER BY seq") + if err != nil { + return err + } + defer rows.Close() + out, err = readCollections(rows) + return err + }) if err != nil { - return nil, storeError(err) + return nil, err } - defer rows.Close() - result, err := readCollections(rows) - return result, storeError(err) + return out, nil } func readCollections(rows *sql.Rows) ([]Collection, error) { result := make([]Collection, 0) for rows.Next() { var c Collection - if err := rows.Scan(&c.ID, &c.Name); err != nil { + if err := rows.Scan(&c.ID, &c.Name, &c.Workstream); err != nil { return nil, err } result = append(result, c) @@ -610,22 +607,36 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb return nil, nil, storeError(fmt.Errorf("%w: added_at cannot be negative", ErrInvalid)) } } - ready, err := s.readyForWrite(ctx) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, nil, storeError(err) } - if !ready { - return nil, nil, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + defer tx.Rollback() + added, already, err := AddRefsIn(ctx, tx, id, refs, s.millis()) if err != nil { return nil, nil, storeError(err) } - defer tx.Rollback() + if err := tx.Commit(); err != nil { + return nil, nil, storeError(err) + } + return added, already, nil +} + +// AddRefsIn applies collection rules inside a transaction obtained from Write. +// It is shared with workstream creation so memberships and metadata commit together. +func AddRefsIn(ctx context.Context, tx *sql.Tx, id string, refs []Addition, now int64) ([]Member, []Ref, error) { + for _, ref := range refs { + if err := ref.Validate(); err != nil { + return nil, nil, err + } + if ref.AddedAt < 0 { + return nil, nil, ErrInvalid + } + } if err := requireCollection(ctx, tx, id); err != nil { return nil, nil, storeError(err) } - now := s.millis() + added, already := make([]Member, 0, len(refs)), make([]Ref, 0) for _, ref := range refs { var target any @@ -635,12 +646,15 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb } target = ref.ID } + if err := checkWorkstreamJob(ctx, tx, id, ref.Ref); err != nil { + return nil, nil, err + } at := ref.AddedAt if at == 0 { at = now } - result, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at) - VALUES (?,?,?,?,?,?) ON CONFLICT(collection_id,kind,ref_id,session_id) DO NOTHING`, id, ref.Kind, ref.ID, ref.SessionID, target, at) + result, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at,factory) + VALUES (?,?,?,?,?,?,?) ON CONFLICT(collection_id,kind,ref_id,session_id,factory) DO NOTHING`, id, ref.Kind, ref.ID, ref.SessionID, target, at, ref.Factory) if err != nil { return nil, nil, storeError(err) } @@ -654,8 +668,10 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb added = append(added, Member{Ref: ref.Ref, AddedAt: at}) } } - if err := tx.Commit(); err != nil { - return nil, nil, storeError(err) + if len(added) > 0 { + if err := touchWorkstream(ctx, tx, id, now); err != nil { + return nil, nil, err + } } return added, already, nil } @@ -701,14 +717,7 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo if err := to.Validate(); err != nil { return Member{}, false, storeError(err) } - ready, err := s.readyForWrite(ctx) - if err != nil { - return Member{}, false, storeError(err) - } - if !ready { - return Member{}, false, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return Member{}, false, storeError(err) } @@ -717,8 +726,8 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo return Member{}, false, storeError(err) } membership := func(ref Ref) (seq, at int64, err error) { - err = tx.QueryRowContext(ctx, "SELECT seq,added_at FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=?", - id, ref.Kind, ref.ID, ref.SessionID).Scan(&seq, &at) + err = tx.QueryRowContext(ctx, "SELECT seq,added_at FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? AND factory=?", + id, ref.Kind, ref.ID, ref.SessionID, ref.Factory).Scan(&seq, &at) return seq, at, err } seq, at, err := membership(from) @@ -735,6 +744,9 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo if _, err := tx.ExecContext(ctx, "DELETE FROM memberships WHERE seq=?", seq); err != nil { return Member{}, false, storeError(err) } + if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { + return Member{}, false, err + } if err := tx.Commit(); err != nil { return Member{}, false, storeError(err) } @@ -742,6 +754,9 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo } else if !errors.Is(err, sql.ErrNoRows) { return Member{}, false, storeError(err) } + if err := checkWorkstreamJob(ctx, tx, id, to); err != nil { + return Member{}, false, err + } var target any if to.Kind == CollectionKind { if err := nestable(ctx, tx, id, to.ID); err != nil { @@ -749,10 +764,13 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo } target = to.ID } - if _, err := tx.ExecContext(ctx, "UPDATE memberships SET kind=?,ref_id=?,session_id=?,target_collection=? WHERE seq=?", - to.Kind, to.ID, to.SessionID, target, seq); err != nil { + if _, err := tx.ExecContext(ctx, "UPDATE memberships SET kind=?,ref_id=?,session_id=?,target_collection=?,factory=? WHERE seq=?", + to.Kind, to.ID, to.SessionID, target, to.Factory, seq); err != nil { return Member{}, false, storeError(err) } + if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { + return Member{}, false, err + } if err := tx.Commit(); err != nil { return Member{}, false, storeError(err) } @@ -774,14 +792,7 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member return nil, storeError(err) } } - ready, err := s.readyForWrite(ctx) - if err != nil { - return nil, storeError(err) - } - if !ready { - return nil, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, storeError(err) } @@ -792,8 +803,8 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member removed := make([]Member, 0, len(refs)) for _, ref := range refs { var at int64 - err := tx.QueryRowContext(ctx, "DELETE FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? RETURNING added_at", - id, ref.Kind, ref.ID, ref.SessionID).Scan(&at) + err := tx.QueryRowContext(ctx, "DELETE FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? AND factory=? RETURNING added_at", + id, ref.Kind, ref.ID, ref.SessionID, ref.Factory).Scan(&at) if errors.Is(err, sql.ErrNoRows) { continue } @@ -802,6 +813,11 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member } removed = append(removed, Member{Ref: ref, AddedAt: at}) } + if len(removed) > 0 { + if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { + return nil, err + } + } if err := tx.Commit(); err != nil { return nil, storeError(err) } @@ -809,55 +825,56 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member } func (s *Store) Members(ctx context.Context, id string) ([]Ref, error) { - version, err := s.readyForRead(ctx) - if err != nil { - return nil, storeError(err) - } - if version == 0 { - return nil, storeError(ErrNotFound) - } - var exists bool - if err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM collections WHERE id=?)", id).Scan(&exists); err != nil { - return nil, storeError(err) - } - if !exists { - return nil, storeError(ErrNotFound) - } - rows, err := s.db.QueryContext(ctx, "SELECT kind,ref_id,session_id FROM memberships WHERE collection_id=? ORDER BY seq", id) - if err != nil { - return nil, storeError(err) - } - defer rows.Close() - result := make([]Ref, 0) - for rows.Next() { - var ref Ref - if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID); err != nil { - return nil, storeError(err) + out := make([]Ref, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return ErrNotFound + } + if err := requireCollection(ctx, tx, id); err != nil { + return err } - result = append(result, ref) + rows, err := tx.QueryContext(ctx, "SELECT kind,ref_id,session_id,"+factoryColumn(version)+" FROM memberships WHERE collection_id=? ORDER BY seq", id) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var ref Ref + if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID, &ref.Factory); err != nil { + return err + } + out = append(out, ref) + } + return rows.Err() + }) + if err != nil { + return nil, err } - return result, storeError(rows.Err()) + return out, nil } func (s *Store) CollectionsFor(ctx context.Context, ref Ref) ([]Collection, error) { if err := ref.Validate(); err != nil { return nil, storeError(err) } - version, err := s.readyForRead(ctx) - if err != nil { - return nil, storeError(err) - } - if version == 0 { - return make([]Collection, 0), nil - } - rows, err := s.db.QueryContext(ctx, `SELECT c.id,c.name FROM collections c JOIN memberships m ON m.collection_id=c.id - WHERE m.kind=? AND m.ref_id=? AND m.session_id=? ORDER BY c.seq`, ref.Kind, ref.ID, ref.SessionID) + out := make([]Collection, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return nil + } + rows, err := tx.QueryContext(ctx, `SELECT c.id,c.name,`+workstreamColumn(version)+` FROM collections c JOIN memberships m ON m.collection_id=c.id + WHERE m.kind=? AND m.ref_id=? AND m.session_id=? AND `+factoryColumn(version)+`=? ORDER BY c.seq`, ref.Kind, ref.ID, ref.SessionID, ref.Factory) + if err != nil { + return err + } + defer rows.Close() + out, err = readCollections(rows) + return err + }) if err != nil { - return nil, storeError(err) + return nil, err } - defer rows.Close() - result, err := readCollections(rows) - return result, storeError(err) + return out, nil } // storeError replaces SQLite's lock account with the stable sentence exposed by diff --git a/internal/workspace/views.go b/internal/workspace/views.go index 1f235ad2c8..3c8e78b4a3 100644 --- a/internal/workspace/views.go +++ b/internal/workspace/views.go @@ -136,18 +136,14 @@ func (s *Store) ListPage(ctx context.Context, holds *Ref, after string, limit in } from = c.S } - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Page{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Page{Collections: make([]Summary, 0)}, nil } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Page{}, storeError(err) - } - defer tx.Rollback() var page Page if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM collections").Scan(&page.Total); err != nil { return Page{}, storeError(err) @@ -183,18 +179,14 @@ func (s *Store) List(ctx context.Context, holds *Ref) ([]Summary, error) { // Describe is one collection as List says it. func (s *Store) Describe(ctx context.Context, id string) (Summary, error) { - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Summary{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Summary{}, storeError(ErrNotFound) } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Summary{}, storeError(err) - } - defer tx.Rollback() out, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Summary{}, storeError(err) @@ -212,8 +204,8 @@ func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only st held := "0" args := []any{} if holds != nil { - held = "EXISTS(SELECT 1 FROM memberships h WHERE h.collection_id=c.id AND h.kind=? AND h.ref_id=? AND h.session_id=?)" - args = append(args, holds.Kind, holds.ID, holds.SessionID) + held = "EXISTS(SELECT 1 FROM memberships h WHERE h.collection_id=c.id AND h.kind=? AND h.ref_id=? AND h.session_id=? AND " + factoryColumn(version) + "=?)" + args = append(args, holds.Kind, holds.ID, holds.SessionID, holds.Factory) } var where []string if only != "" { @@ -232,8 +224,8 @@ func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only st rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT c.seq,c.id,c.name,%s, (SELECT count(*) FROM memberships m WHERE m.collection_id=c.id), (SELECT coalesce(max(%s),0) FROM memberships m WHERE m.collection_id=c.id), - %s -FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, clause), args...) + %s, %s +FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, workstreamColumn(version), clause), args...) if err != nil { return nil, err } @@ -242,7 +234,7 @@ FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, clause), ar for rows.Next() { var c Summary var h bool - if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h); err != nil { + if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h, &c.Workstream); err != nil { return nil, err } if holds != nil { @@ -320,18 +312,14 @@ func (s *Store) Tree(ctx context.Context, id string, depth, limit int, after str } start = &levelAfter{added: c.A, seq: c.S} } - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Tree{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Tree{}, storeError(ErrNotFound) } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Tree{}, storeError(err) - } - defer tx.Rollback() top, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Tree{}, storeError(err) @@ -415,14 +403,14 @@ func levelOf(ctx context.Context, tx *sql.Tx, version int, id string, limit int, // A schema-1 file knows no moments, so its order is insertion alone; a // constant in ORDER BY would be read as a column number. order := "m.seq DESC" - if version >= schemaVersion { + if version >= 2 { order = "m.added_at DESC, m.seq DESC" } args = append(args, limit+1) - rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT m.seq,m.kind,m.ref_id,m.session_id,%s,coalesce(c.name,''),c.id IS NOT NULL, + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT m.seq,m.kind,m.ref_id,m.session_id,%s,%s,coalesce(c.name,''),c.id IS NOT NULL, CASE WHEN m.kind='collection' THEN (SELECT count(*) FROM memberships x WHERE x.collection_id=m.ref_id) ELSE 0 END FROM memberships m LEFT JOIN collections c ON m.kind='collection' AND c.id=m.ref_id -WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, from, order), args...) +WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, factoryColumn(version), from, order), args...) if err != nil { return nil, false, err } @@ -434,7 +422,7 @@ WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, from, order), args...) break } var n Node - if err := rows.Scan(&n.seq, &n.Kind, &n.ID, &n.SessionID, &n.AddedAt, &n.Name, &n.Found, &n.Count); err != nil { + if err := rows.Scan(&n.seq, &n.Kind, &n.ID, &n.SessionID, &n.AddedAt, &n.Factory, &n.Name, &n.Found, &n.Count); err != nil { return nil, false, err } nodes = append(nodes, n) @@ -467,18 +455,14 @@ type Expansion struct { // always say what it is not showing. func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, error) { depth = max(1, min(depth, ExpandDepthMax)) - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Expansion{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Expansion{}, storeError(ErrNotFound) } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Expansion{}, storeError(err) - } - defer tx.Rollback() top, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Expansion{}, storeError(err) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 647c004f46..4e2dde954d 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -22,8 +22,9 @@ const ( // CrewKind names a crew by its id; ProcessKind names a process a // conversation started, by its id, and like a task carries that // conversation as its session. Both arrived with schema 2. - CrewKind Kind = "crew" - ProcessKind Kind = "process" + CrewKind Kind = "crew" + ProcessKind Kind = "process" + FactoryJobKind Kind = "factory_job" // StandingKind is an older build's reference to a standing item. Standing // items are gone (automations replaced them), so the collections command // refuses a new one, but a row that already names one is still a row: it @@ -57,11 +58,13 @@ type Ref struct { Kind Kind `json:"kind"` ID string `json:"id"` SessionID string `json:"session_id,omitempty"` + Factory string `json:"factory,omitempty"` } type Collection struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + Workstream bool `json:"workstream,omitempty"` } // Member is a reference as one collection holds it, with when it was added in @@ -71,12 +74,17 @@ type Member struct { AddedAt int64 `json:"added_at,omitempty"` } -// key is a reference's identity, the three columns the uniqueness rule reads. -func (r Ref) key() string { return string(r.Kind) + "\x00" + r.ID + "\x00" + r.SessionID } +// key includes the factory because job numbers are local to one factory. +func (r Ref) key() string { + return string(r.Kind) + "\x00" + r.ID + "\x00" + r.SessionID + "\x00" + r.Factory +} // Validate checks the reference shape without asking an execution owner to // open it. A temporarily unavailable record must remain discoverable here. func (r Ref) Validate() error { + if r.Kind != FactoryJobKind && r.Factory != "" { + return fmt.Errorf("%w: only a factory_job reference has a factory", ErrInvalid) + } if !validText(r.ID, 4096) { return fmt.Errorf("%w: record id is empty, too long or contains control characters", ErrInvalid) } @@ -84,6 +92,11 @@ func (r Ref) Validate() error { return fmt.Errorf("%w: only a task or a process reference has a session id", ErrInvalid) } switch r.Kind { + case FactoryJobKind: + n, err := strconv.ParseUint(r.ID, 10, 63) + if err != nil || n == 0 || strconv.FormatUint(n, 10) != r.ID || !validText(r.Factory, 4096) { + return fmt.Errorf("%w: a factory_job needs a factory and a positive canonical job number", ErrInvalid) + } case CollectionKind, ConversationKind, StandingKind, CrewKind: case ProcessKind: if !validText(r.SessionID, 1024) { diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go new file mode 100644 index 0000000000..46de35b2f5 --- /dev/null +++ b/internal/workspace/workstreams.go @@ -0,0 +1,233 @@ +package workspace + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "time" +) + +// These bounds are shared by SQLite constraints and the domain validator. +const ( + WorkstreamGoAheadCapMax = 10000 + WorkstreamGoAheadDurationMax = 90 * 24 * time.Hour +) + +var ErrWorkstreamDelete = errors.New("mark a workstream done instead") + +// WorkstreamTaken identifies the existing owner so adapters can explain an +// atomic refusal without another read that could race a rename. +type WorkstreamTaken struct { + Factory string + Job int + WorkstreamID string + WorkstreamName string +} + +func (e *WorkstreamTaken) Error() string { return "that job belongs to another workstream" } + +// Write runs fn in the store's immediate transaction, including any schema +// upgrade. Returning an error rolls back everything. The callback must use tx +// rather than reenter the Store, which owns a single database connection. +func (s *Store) Write(ctx context.Context, fn func(tx *sql.Tx) error) error { + tx, err := s.beginWrite(ctx, true) + if err != nil { + return storeError(err) + } + defer tx.Rollback() + if err := fn(tx); err != nil { + return storeError(err) + } + return storeError(tx.Commit()) +} + +// Read runs fn against a consistent read-only snapshot. Version 0 means blank; +// workstream readers must answer empty/not found for every version below 3. +// Reads never initialize or migrate the database, and share Write's busy mapping. +func (s *Store) Read(ctx context.Context, fn func(tx *sql.Tx, version int) error) error { + tx, version, err := s.beginRead(ctx) + if err != nil { + return err + } + defer tx.Rollback() + return storeError(fn(tx, version)) +} + +// beginRead keeps the schema decision and projected rows in one snapshot. A +// peer may migrate between separate transactions, adding fields an old projection +// cannot supply (notably factory on a new job ref). +func (s *Store) beginRead(ctx context.Context) (*sql.Tx, int, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, 0, storeError(err) + } + state, err := inspectSchema(ctx, tx) + if err != nil { + _ = tx.Rollback() + return nil, 0, storeError(err) + } + return tx, state.version(), nil +} + +// CreateIn preserves collection naming rules within a larger atomic write. +func CreateIn(ctx context.Context, tx *sql.Tx, id, name string, at int64) error { + if err := ValidateName(name); err != nil { + return err + } + if err := nameTaken(ctx, tx, name, ""); err != nil { + return err + } + _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", id, name, at) + return err +} + +// RenameIn preserves Unicode name uniqueness within a larger atomic write. +func RenameIn(ctx context.Context, tx *sql.Tx, id, name string) error { + if err := ValidateName(name); err != nil { + return err + } + if err := requireCollection(ctx, tx, id); err != nil { + return err + } + if err := nameTaken(ctx, tx, name, id); err != nil { + return err + } + _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id) + return err +} + +func touchWorkstream(ctx context.Context, tx *sql.Tx, id string, at int64) error { + _, err := tx.ExecContext(ctx, "UPDATE workstreams SET updated_at=max(updated_at+1,?) WHERE id=?", at, id) + return err +} + +func checkWorkstreamJob(ctx context.Context, tx *sql.Tx, id string, ref Ref) error { + if ref.Kind != FactoryJobKind { + return nil + } + var taken WorkstreamTaken + var isWorkstream bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstreams WHERE id=?)", id).Scan(&isWorkstream); err != nil { + return err + } + if !isWorkstream { + return nil + } + err := tx.QueryRowContext(ctx, `SELECT j.workstream_id,c.name FROM workstream_jobs j JOIN collections c ON c.id=j.workstream_id + WHERE j.factory=? AND j.job=? AND j.workstream_id!=?`, ref.Factory, ref.ID, id).Scan(&taken.WorkstreamID, &taken.WorkstreamName) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + n, _ := strconv.ParseInt(ref.ID, 10, 64) + taken.Factory, taken.Job = ref.Factory, int(n) + return &taken +} + +func factoryColumn(version int) string { + if version >= 3 { + return "factory" + } + return "''" +} + +func workstreamColumn(version int) string { + if version >= 3 { + return "EXISTS(SELECT 1 FROM workstreams w WHERE w.id=c.id)" + } + return "0" +} + +// Claims are maintained by triggers so even SQL writes through Write cannot +// accidentally bypass the unique job owner. Plain collections have no claims. +var workstreamSchema = fmt.Sprintf(` +CREATE TABLE workstreams ( + id TEXT PRIMARY KEY REFERENCES collections(id) ON DELETE CASCADE, + why TEXT NOT NULL DEFAULT '', origin_chat_id TEXT NOT NULL DEFAULT '', agent_id TEXT NOT NULL, + autonomy TEXT NOT NULL DEFAULT 'propose' CHECK(autonomy IN ('propose','small','off')), + wake_morning INTEGER NOT NULL DEFAULT 0, wake_related_issues INTEGER NOT NULL DEFAULT 0, + budget_usd REAL CHECK(budget_usd IS NULL OR budget_usd>0), + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, started_at INTEGER, done_at INTEGER, + how_it_went TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX workstreams_client ON workstreams(client_id) WHERE client_id!=''; +CREATE TABLE workstream_jobs ( + member_seq INTEGER PRIMARY KEY REFERENCES memberships(seq) ON DELETE CASCADE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + factory TEXT NOT NULL, job TEXT NOT NULL +); +CREATE UNIQUE INDEX workstream_job_owner ON workstream_jobs(factory,job); +CREATE TRIGGER workstream_job_insert AFTER INSERT ON memberships + WHEN NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id) + BEGIN INSERT INTO workstream_jobs VALUES(NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id); END; +CREATE TRIGGER workstream_job_update AFTER UPDATE ON memberships + BEGIN + DELETE FROM workstream_jobs WHERE member_seq=OLD.seq; + INSERT INTO workstream_jobs SELECT NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id + WHERE NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id); + END; +CREATE TRIGGER workstream_job_promote AFTER INSERT ON workstreams + BEGIN INSERT INTO workstream_jobs SELECT seq,collection_id,factory,ref_id FROM memberships + WHERE collection_id=NEW.id AND kind='factory_job'; END; +CREATE TABLE workstream_pages ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES workstream_pages(id) ON DELETE CASCADE, + title TEXT NOT NULL, owner TEXT NOT NULL CHECK(owner IN ('you','agent')), + read_by_jobs INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL CHECK(position>=0), + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), updated_from TEXT, + doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)) +); +CREATE INDEX workstream_page_tree ON workstream_pages(workstream_id,parent_id,position); +CREATE UNIQUE INDEX workstream_overview ON workstream_pages(workstream_id) WHERE overview=1; +CREATE TABLE workstream_page_versions ( + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + version INTEGER NOT NULL, saved_at INTEGER NOT NULL, saved_by TEXT NOT NULL CHECK(saved_by IN ('you','agent')), + source TEXT, title TEXT NOT NULL, doc TEXT NOT NULL, bytes INTEGER NOT NULL, + PRIMARY KEY(page_id,version) +); +CREATE TABLE workstream_records ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('decision','question')), text TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped','superseded')), + actor TEXT NOT NULL CHECK(actor IN ('you','agent')), source TEXT, created_at INTEGER NOT NULL, + decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id) +); +CREATE INDEX workstream_record_list ON workstream_records(workstream_id,created_at); +CREATE TABLE workstream_goaheads ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK(scope IN ('job','wave','milestone')), + factory TEXT, job INTEGER, wave INTEGER, milestone_id TEXT, + cap_usd REAL NOT NULL CHECK(cap_usd>0 AND cap_usd<=%d), ends_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK(state IN ('active','ended')), + end_reason TEXT CHECK(end_reason IN ('expired','revoked','workstream_done')), + granted_at INTEGER NOT NULL, ended_at INTEGER, + CHECK((scope='job' AND factory IS NOT NULL AND length(factory)>0 AND job IS NOT NULL AND job>0 AND wave IS NULL AND milestone_id IS NULL) + OR (scope='wave' AND wave IS NOT NULL AND wave>0 AND factory IS NULL AND job IS NULL AND milestone_id IS NULL) + OR (scope='milestone' AND milestone_id IS NOT NULL AND length(milestone_id)>0 AND factory IS NULL AND job IS NULL AND wave IS NULL)), + CHECK(ends_at>granted_at AND ends_at<=granted_at+%d) +); +CREATE INDEX workstream_goahead_list ON workstream_goaheads(workstream_id,state); +CREATE TABLE workstream_plans ( + workstream_id TEXT PRIMARY KEY REFERENCES workstreams(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), + milestones TEXT NOT NULL, waves TEXT NOT NULL +); +`, WorkstreamGoAheadCapMax, WorkstreamGoAheadDurationMax.Milliseconds()) + +// Opening a schema-3 file verifies the tables needed by both domain reads and +// collection ownership checks. A partial schema is refused rather than repaired. +var workstreamSchemaReads = []string{ + "SELECT id,why,origin_chat_id,agent_id,autonomy,wake_morning,wake_related_issues,budget_usd,created_at,updated_at,started_at,done_at,how_it_went,client_id FROM workstreams LIMIT 0", + "SELECT member_seq,workstream_id,factory,job FROM workstream_jobs LIMIT 0", + "SELECT id,workstream_id,parent_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview FROM workstream_pages LIMIT 0", + "SELECT page_id,version,saved_at,saved_by,source,title,doc,bytes FROM workstream_page_versions LIMIT 0", + "SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes,superseded_by FROM workstream_records LIMIT 0", + "SELECT id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,end_reason,granted_at,ended_at FROM workstream_goaheads LIMIT 0", + "SELECT workstream_id,version,updated_at,updated_by,milestones,waves FROM workstream_plans LIMIT 0", +} diff --git a/internal/workspace/workstreams_test.go b/internal/workspace/workstreams_test.go new file mode 100644 index 0000000000..685c262755 --- /dev/null +++ b/internal/workspace/workstreams_test.go @@ -0,0 +1,162 @@ +package workspace + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" +) + +func seedWorkstream(t *testing.T, s *Store, id, name string) { + t.Helper() + if err := s.Write(context.Background(), func(tx *sql.Tx) error { + if err := CreateIn(context.Background(), tx, id, name, 100); err != nil { + return err + } + _, err := tx.Exec("INSERT INTO workstreams(id,agent_id,created_at,updated_at) VALUES (?,?,100,100)", id, "agent-"+id) + return err + }); err != nil { + t.Fatal(err) + } +} + +// Part A (b): the collection store, including relink and raw SQL's unique +// constraint, enforces job ownership; plain collections make no job claim. +func TestPartAWorkstreamMembershipOwnershipIsEnforcedInSQLite(t *testing.T) { + s := openTestStore(t, filepath.Join(t.TempDir(), "claims.db")) + ctx := context.Background() + seedWorkstream(t, s, "a", "First") + seedWorkstream(t, s, "b", "Second") + job := Ref{Kind: FactoryJobKind, ID: "7", Factory: "f"} + fixedClock(s, 200) + if err := s.Add(ctx, "a", job); err != nil { + t.Fatal(err) + } + fixedClock(s, 300) + _, _, err := s.AddRefs(ctx, "b", []Addition{{Ref: Ref{Kind: ConversationKind, ID: "extra"}}, {Ref: job}}) + var taken *WorkstreamTaken + if !errors.As(err, &taken) || taken.WorkstreamID != "a" || taken.WorkstreamName != "First" || taken.Job != 7 || taken.Factory != "f" { + t.Fatalf("taken: %+v %v", taken, err) + } + for id, want := range map[string]int64{"a": 200, "b": 100} { + var at int64 + if err := s.db.QueryRow("SELECT updated_at FROM workstreams WHERE id=?", id).Scan(&at); err != nil || at != want { + t.Fatalf("timestamp %s: %d %v", id, at, err) + } + } + if members, err := s.Members(ctx, "b"); err != nil || len(members) != 0 { + t.Fatalf("partial add: %+v %v", members, err) + } + if err := s.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('b','factory_job','7','','f')") + return err + }); err == nil { + t.Fatal("SQL bypassed unique job owner") + } + plain := createTestCollection(t, s, "Plain") + if err := s.Add(ctx, plain.ID, job); err != nil { + t.Fatal(err) + } + differentFactory := Ref{Kind: FactoryJobKind, ID: "7", Factory: "other"} + if err := s.Add(ctx, "b", differentFactory); err != nil { + t.Fatal(err) + } + if _, _, err := s.Relink(ctx, "b", differentFactory, job); !errors.As(err, &taken) { + t.Fatalf("relink took another owner's job: %v", err) + } + if err := s.Remove(ctx, "a", job); err != nil { + t.Fatal(err) + } + if _, _, err := s.Relink(ctx, "b", differentFactory, job); err != nil { + t.Fatal(err) + } + a, err := s.Describe(ctx, "a") + if err != nil || !a.Workstream { + t.Fatalf("workstream flag: %+v %v", a, err) + } + copy, err := s.Duplicate(ctx, "b", "Copy") + if err != nil { + t.Fatal(err) + } + copySummary, err := s.Describe(ctx, copy.ID) + if err != nil || copySummary.Workstream { + t.Fatalf("plain duplicate: %+v %v", copySummary, err) + } + if _, err := s.Delete(ctx, "a"); !errors.Is(err, ErrWorkstreamDelete) { + t.Fatalf("delete: %v", err) + } +} + +// Part A (a): schema 1 upgrades straight to 3, preserving legacy timestamps as +// unknown; schema 3 checks the new ref's factory on direct SQL writes too. +func TestPartASchemaOneMigratesStraightToThree(t *testing.T) { + s := openTestStore(t, writeLegacyStore(t)) + ctx := context.Background() + if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "11", Factory: "f"}); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 3 { + t.Fatal("did not migrate directly to schema 3") + } + var count int + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 7 { + t.Fatalf("workstream tables: %d %v", count, err) + } + var created, added int64 + if err := s.db.QueryRow("SELECT created_at FROM collections WHERE id='aaaa'").Scan(&created); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT added_at FROM memberships WHERE kind='task'").Scan(&added); err != nil { + t.Fatal(err) + } + if created != 0 || added != 0 { + t.Fatal("invented legacy timestamps") + } + for _, q := range []string{ + "INSERT INTO memberships(collection_id,kind,ref_id,session_id) VALUES ('aaaa','factory_job','12','')", + "INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('aaaa','conversation','other','','f')", + } { + if _, err := s.db.Exec(q); err == nil { + t.Fatalf("accepted invalid factory: %s", q) + } + } +} + +func TestPartAFactoryIsPartOfEveryCollectionReadIdentity(t *testing.T) { + s := openTestStore(t, filepath.Join(t.TempDir(), "identity.db")) + ctx := context.Background() + c := createTestCollection(t, s, "Jobs") + a, b := Ref{Kind: FactoryJobKind, ID: "1", Factory: "a"}, Ref{Kind: FactoryJobKind, ID: "1", Factory: "b"} + if err := s.Add(ctx, c.ID, a); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, b); err != nil { + t.Fatal(err) + } + for _, r := range []Ref{a, b} { + parents, err := s.CollectionsFor(ctx, r) + if err != nil || len(parents) != 1 { + t.Fatalf("find %+v: %+v %v", r, parents, err) + } + } + expansion, err := s.Expand(ctx, c.ID, 1) + if err != nil || len(expansion.Refs) != 2 { + t.Fatalf("expansion: %+v %v", expansion, err) + } + if err := s.Remove(ctx, c.ID, a); err != nil { + t.Fatal(err) + } + members, err := s.Members(ctx, c.ID) + if err != nil || len(members) != 1 || members[0] != b { + t.Fatalf("remove: %+v %v", members, err) + } + list, err := s.List(ctx, &a) + if err != nil || len(list) != 1 || *list[0].Holds { + t.Fatalf("holds: %+v %v", list, err) + } + parents, err := s.CollectionsFor(ctx, a) + if err != nil || len(parents) != 0 { + t.Fatalf("removed find: %+v %v", parents, err) + } +} diff --git a/internal/workstream/goaheads.go b/internal/workstream/goaheads.go new file mode 100644 index 0000000000..c21531abd1 --- /dev/null +++ b/internal/workstream/goaheads.go @@ -0,0 +1,203 @@ +package workstream + +import ( + "context" + "database/sql" + "math" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const goaheadSelect = `SELECT id,workstream_id,scope,coalesce(factory,''),coalesce(job,0),coalesce(wave,0),coalesce(milestone_id,''),cap_usd,ends_at,state,coalesce(end_reason,''),granted_at,ended_at FROM workstream_goaheads WHERE id=?` + +func goaheadIn(ctx context.Context, tx *sql.Tx, id string) (GoAhead, error) { + var g GoAhead + var ends, granted int64 + var ended sql.NullInt64 + err := tx.QueryRowContext(ctx, goaheadSelect, id).Scan(&g.ID, &g.WorkstreamID, &g.Scope, &g.Factory, &g.Job, &g.Wave, &g.MilestoneID, &g.CapUSD, &ends, &g.State, &g.EndReason, &granted, &ended) + if err != nil { + return g, found(err) + } + g.EndsAt, g.GrantedAt, g.EndedAt = instant(ends), instant(granted), optionalInstant(ended) + return g, nil +} + +// Expiry is derived on read, so listing never writes or emits a change. +func expire(g GoAhead, now time.Time) GoAhead { + if g.State == "active" && !g.EndsAt.After(now) { + g.State = "ended" + g.EndReason = "expired" + g.EndedAt = g.EndsAt + } + return g +} +func (s *Store) GoAheads(ctx context.Context, id string, ended bool) ([]GoAhead, error) { + out := []GoAhead{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, "SELECT id FROM workstream_goaheads WHERE workstream_id=? ORDER BY granted_at DESC,rowid DESC", id) + if err != nil { + return err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + now := s.now() + for _, id := range ids { + g, err := goaheadIn(ctx, tx, id) + if err != nil { + return err + } + g = expire(g, now) + if ended || g.State == "active" { + out = append(out, g) + } + } + return nil + }) + return out, err +} +func (s *Store) GrantGoAhead(ctx context.Context, p GoAheadGrant) (GoAhead, Change, error) { + // Store millisecond precision in the validation too: an end within the same + // stored millisecond as the grant is not a future authorization. + now := s.now() + ends := p.EndsAt + if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || ends.IsZero() || !ends.After(now) || ends.After(now.Add(workspace.WorkstreamGoAheadDurationMax)) || ends.UnixMilli() <= now.UnixMilli() { + return GoAhead{}, Change{}, invalid("go-ahead needs a cap greater than 0 and at most 10000, and a future end within 90 days") + } + switch p.Scope { + case "job": + if p.Job <= 0 || p.Wave != 0 || p.MilestoneID != "" { + return GoAhead{}, Change{}, invalid("job scope needs only factory and job") + } + if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: strconv.Itoa(p.Job), Factory: p.Factory}).Validate(); err != nil { + return GoAhead{}, Change{}, err + } + case "wave": + if p.Wave <= 0 || p.Factory != "" || p.Job != 0 || p.MilestoneID != "" { + return GoAhead{}, Change{}, invalid("wave scope needs only wave") + } + case "milestone": + if p.MilestoneID == "" || p.Factory != "" || p.Job != 0 || p.Wave != 0 { + return GoAhead{}, Change{}, invalid("milestone scope needs only milestone_id") + } + default: + return GoAhead{}, Change{}, invalid("go-ahead needs a scope") + } + var out GoAhead + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, p.WorkstreamID) + if err != nil { + return err + } + if !w.DoneAt.IsZero() { + return ErrConflict + } + // Recheck after waiting for the writer, so an expired grant cannot sneak in. + at := s.now() + if p.EndsAt.UnixMilli() <= at.UnixMilli() || p.EndsAt.After(at.Add(workspace.WorkstreamGoAheadDurationMax)) { + return invalid("go-ahead end must be future and within 90 days") + } + valid := false + switch p.Scope { + case "job": + for _, j := range w.Jobs() { + if j.Factory == p.Factory && j.Number == p.Job { + valid = true + break + } + } + case "wave", "milestone": + plan, err := planIn(ctx, tx, p.WorkstreamID) + if err != nil { + return err + } + if p.Scope == "wave" { + for _, v := range plan.Waves { + if v.N == p.Wave { + valid = true + break + } + } + } else { + for _, v := range plan.Milestones { + if v.ID == p.MilestoneID { + valid = true + break + } + } + } + } + if !valid { + return invalid("scope target is not a member or does not exist in the plan") + } + id, err := s.newID() + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_goaheads(id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,granted_at) VALUES (?,?,?,?,?,?,?,?,?,'active',?)`, id, p.WorkstreamID, p.Scope, nullString(p.Factory), nullInt(p.Job), nullInt(p.Wave), nullString(p.MilestoneID), p.CapUSD, p.EndsAt.UnixMilli(), at.UnixMilli()); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at.UnixMilli()); err != nil { + return err + } + out, err = goaheadIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartGoAheads) + return err + }) + if err != nil { + return GoAhead{}, Change{}, err + } + return out, c, nil +} +func (s *Store) RevokeGoAhead(ctx context.Context, id string) (GoAhead, Change, error) { + var out GoAhead + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + g, err := goaheadIn(ctx, tx, id) + if err != nil { + return err + } + now := s.now() + g = expire(g, now) + if g.State == "ended" { + out = g + return nil + } + at := now.UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_goaheads SET state='ended',end_reason='revoked',ended_at=? WHERE id=?", at, id); err != nil { + return err + } + if err := touch(ctx, tx, g.WorkstreamID, at); err != nil { + return err + } + out, err = goaheadIn(ctx, tx, id) + c = changed(g.WorkstreamID, PartGoAheads) + return err + }) + if err != nil { + return GoAhead{}, Change{}, err + } + return out, c, nil +} diff --git a/internal/workstream/legacy_test.go b/internal/workstream/legacy_test.go new file mode 100644 index 0000000000..ade79780cd --- /dev/null +++ b/internal/workstream/legacy_test.go @@ -0,0 +1,76 @@ +package workstream + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// Part A (a): domain reads through older schemas are empty, never migrations. +func TestPartALegacyWorkstreamReadsDoNotWrite(t *testing.T) { + for _, version := range []int{1, 2} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + created, added := "", "" + if version == 2 { + created = ", created_at INTEGER NOT NULL DEFAULT 0" + added = ", added_at INTEGER NOT NULL DEFAULT 0" + } + _, err = db.Exec(fmt.Sprintf(`CREATE TABLE collections(seq INTEGER PRIMARY KEY,id TEXT UNIQUE,name TEXT%s); +CREATE TABLE memberships(seq INTEGER PRIMARY KEY,collection_id TEXT,kind TEXT,ref_id TEXT,session_id TEXT,target_collection TEXT%s); +PRAGMA application_id=1095123788; PRAGMA user_version=%d; +INSERT INTO collections(seq,id,name) VALUES (1,'collection','Existing');`, created, added, version)) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + w, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + defer w.Close() + s := New(w, Options{}) + ctx := context.Background() + all, err := s.List(ctx) + if err != nil || all == nil || len(all) != 0 { + t.Fatalf("legacy list: %+v %v", all, err) + } + for name, read := range map[string]func() error{ + "get": func() error { _, err := s.Get(ctx, "collection"); return err }, + "plan": func() error { _, err := s.Plan(ctx, "collection"); return err }, + "pages": func() error { _, err := s.Pages(ctx, "collection"); return err }, + "page": func() error { _, err := s.Page(ctx, "missing", true); return err }, + "history": func() error { _, err := s.PageHistory(ctx, "missing", 50); return err }, + "records": func() error { _, err := s.Records(ctx, "collection", "", ""); return err }, + "goaheads": func() error { _, err := s.GoAheads(ctx, "collection", true); return err }, + } { + if err := read(); !errors.Is(err, ErrNotFound) { + t.Fatalf("legacy %s: %v", name, err) + } + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("domain read changed legacy file") + } + }) + } +} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go new file mode 100644 index 0000000000..cc3a238bb2 --- /dev/null +++ b/internal/workstream/pages.go @@ -0,0 +1,514 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +const pageSelect = `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview FROM workstream_pages WHERE id=?` + +func pageIn(ctx context.Context, tx *sql.Tx, id string) (Page, bool, error) { + var p Page + var at int64 + var source sql.NullString + var overview bool + var doc string + err := tx.QueryRowContext(ctx, pageSelect, id).Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &doc, &overview) + if err != nil { + return p, false, found(err) + } + p.UpdatedAt = instant(at) + p.Doc = json.RawMessage(doc) + p.UpdatedFrom, err = readSource(source) + return p, overview, err +} +func pageNodesIn(ctx context.Context, tx *sql.Tx, id string) ([]PageNode, error) { + rows, err := tx.QueryContext(ctx, `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from FROM workstream_pages WHERE workstream_id=? ORDER BY position,id`, id) + if err != nil { + return nil, err + } + defer rows.Close() + siblings := map[string][]PageNode{} + for rows.Next() { + var p PageNode + var at int64 + var source sql.NullString + if err := rows.Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source); err != nil { + return nil, err + } + p.UpdatedAt = instant(at) + p.UpdatedFrom, err = readSource(source) + if err != nil { + return nil, err + } + siblings[p.ParentID] = append(siblings[p.ParentID], p) + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]PageNode, 0) + seen := map[string]bool{} + // An explicit stack keeps exports safe for a very deep tree. + var stack []PageNode + push := func(nodes []PageNode) { + for i := len(nodes) - 1; i >= 0; i-- { + stack = append(stack, nodes[i]) + } + } + push(siblings[""]) + for len(stack) > 0 { + last := len(stack) - 1 + p := stack[last] + stack = stack[:last] + if seen[p.ID] { + continue + } + seen[p.ID] = true + out = append(out, p) + push(siblings[p.ID]) + } + return out, nil +} +func (s *Store) Pages(ctx context.Context, id string) ([]PageNode, error) { + out := make([]PageNode, 0) + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + var err error + out, err = pageNodesIn(ctx, tx, id) + return err + }) + return out, err +} +func (s *Store) Page(ctx context.Context, id string, markdown bool) (Page, error) { + var p Page + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + var err error + p, _, err = pageIn(ctx, tx, id) + return err + }) + if err == nil && markdown { + var md string + md, err = pagedoc.Markdown(p.Doc) + p.Markdown = &md + } + return p, err +} +func checkDoc(doc json.RawMessage) error { + if len(doc) > DocMax { + return ErrTooLarge + } + raw := strings.TrimSpace(string(doc)) + if len(raw) == 0 || raw[0] != '{' || !json.Valid(doc) || !utf8.Valid(doc) { + return invalid("doc must be a JSON object") + } + return nil +} +func saveVersion(ctx context.Context, tx *sql.Tx, id string) error { + _, err := tx.ExecContext(ctx, `INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,source,title,doc,bytes) + SELECT id,version,updated_at,updated_by,updated_from,title,doc,length(CAST(doc AS BLOB)) FROM workstream_pages WHERE id=?`, id) + return err +} +func (s *Store) SavePage(ctx context.Context, id string, base int, doc json.RawMessage, who Attribution) (Page, Change, error) { + if err := checkDoc(doc); err != nil { + return Page{}, Change{}, err + } + return s.savePage(ctx, id, base, doc, 0, who) +} +func (s *Store) RestorePage(ctx context.Context, id string, version, base int, who Attribution) (Page, Change, error) { + if version < 1 { + return Page{}, Change{}, invalid("version must be positive") + } + return s.savePage(ctx, id, base, nil, version, who) +} +func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawMessage, restore int, who Attribution) (Page, Change, error) { + if base < 0 { + return Page{}, Change{}, invalid("base_version must be non-negative") + } + by, err := actor(who.By) + if err != nil { + return Page{}, Change{}, err + } + source, err := sourceJSON(who.From) + if err != nil { + return Page{}, Change{}, err + } + var out Page + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + p, _, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if p.Version != base { + return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} + } + title := p.Title + if restore > 0 { + var raw string + err := tx.QueryRowContext(ctx, "SELECT title,doc FROM workstream_page_versions WHERE page_id=? AND version=?", id, restore).Scan(&title, &raw) + if err != nil { + return found(err) + } + doc = json.RawMessage(raw) + } + if err := checkDoc(doc); err != nil { + return err + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,doc=?,version=version+1,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, string(doc), at, by, source, id); err != nil { + return err + } + if err := saveVersion(ctx, tx, id); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + out, _, err = pageIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartPages) + return err + }) + if err != nil { + return Page{}, Change{}, err + } + return out, c, nil +} + +// placePage validates every target before changing sibling positions. It packs +// positions after a move so a later insert never relies on gaps or old offsets. +func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, last bool, at int64, by string, source any) (bool, error) { + if parent != "" { + seen := map[string]bool{} + for id := parent; id != ""; { + if id == p.ID || seen[id] { + return false, ErrCycle + } + seen[id] = true + var workstreamID, next string + err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=?", id).Scan(&workstreamID, &next) + if err != nil { + return false, found(err) + } + if workstreamID != p.WorkstreamID { + return false, invalid("parent is in another workstream") + } + id = next + } + } + if after != "" { + var workstreamID, siblingParent string + err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=?", after).Scan(&workstreamID, &siblingParent) + if err != nil { + return false, found(err) + } + if workstreamID != p.WorkstreamID || siblingParent != parent || after == p.ID { + return false, invalid("after_id must be another sibling in this workstream") + } + } + nodes, err := pageNodesIn(ctx, tx, p.WorkstreamID) + if err != nil { + return false, err + } + siblings := make([]PageNode, 0) + old := make([]PageNode, 0) + for _, n := range nodes { + if n.ID == p.ID { + continue + } + if n.ParentID == parent { + siblings = append(siblings, n) + } + if p.ParentID != parent && n.ParentID == p.ParentID { + old = append(old, n) + } + } + index := 0 + if last { + index = len(siblings) + } + if after != "" { + for i, n := range siblings { + if n.ID == after { + index = i + 1 + break + } + } + } + // Root position zero belongs to the immutable Overview identity, even after a rename. + if parent == "" && index == 0 && len(siblings) > 0 { + index = 1 + } + if p.ParentID == parent && p.Position == index { + return false, nil + } + siblings = append(siblings, PageNode{}) + copy(siblings[index+1:], siblings[index:]) + siblings[index] = p.PageNode + for i, n := range siblings { + if n.ID == p.ID { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET parent_id=?,position=?,updated_at=?,updated_by=?,updated_from=? WHERE id=?", nullString(parent), i, at, by, source, p.ID); err != nil { + return false, err + } + } else if n.Position != i { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { + return false, err + } + } + } + for i, n := range old { + if n.Position != i { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { + return false, err + } + } + } + return true, nil +} +func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, error) { + if err := validTitle(p.Title); err != nil { + return Page{}, Change{}, err + } + owner, err := actor(p.Owner) + if err != nil { + return Page{}, Change{}, err + } + by, err := actor(p.By) + if err != nil { + return Page{}, Change{}, err + } + source, err := sourceJSON(p.From) + if err != nil { + return Page{}, Change{}, err + } + if p.Doc == nil { + p.Doc = json.RawMessage(emptyDoc) + } + if err := checkDoc(p.Doc); err != nil { + return Page{}, Change{}, err + } + var out Page + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + if _, err := getIn(ctx, tx, p.WorkstreamID); err != nil { + return err + } + id, err := s.newID() + if err != nil { + return err + } + at := s.now().UnixMilli() + // A temporary root position distinct from every packed position makes the + // common placement helper handle creation and movement identically. + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc) VALUES (?,?,?,?,?,2147483647,1,?,?,?,?)`, id, p.WorkstreamID, p.Title, owner, p.ReadByJobs, at, by, source, string(p.Doc)); err != nil { + return err + } + page, _, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if _, err := placePage(ctx, tx, page, p.ParentID, p.AfterID, p.AfterID == "", at, by, source); err != nil { + return err + } + if err := saveVersion(ctx, tx, id); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + out, _, err = pageIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartPages) + return err + }) + if err != nil { + return Page{}, Change{}, err + } + return out, c, nil +} +func (s *Store) UpdatePage(ctx context.Context, id string, p PagePatch) (PageNode, Change, error) { + if p.Title != nil { + if err := validTitle(*p.Title); err != nil { + return PageNode{}, Change{}, err + } + } + if p.Owner != nil { + if *p.Owner != "you" && *p.Owner != "agent" { + return PageNode{}, Change{}, invalid("owner must be you or agent") + } + } + by, err := actor(p.By) + if err != nil { + return PageNode{}, Change{}, err + } + source, err := sourceJSON(p.From) + if err != nil { + return PageNode{}, Change{}, err + } + var out PageNode + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + page, _, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + title, owner, read := page.Title, page.Owner, page.ReadByJobs + if p.Title != nil { + title = *p.Title + } + if p.Owner != nil { + owner = *p.Owner + } + if p.ReadByJobs != nil { + read = *p.ReadByJobs + } + if title == page.Title && owner == page.Owner && read == page.ReadByJobs { + out = page.PageNode + return nil + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,owner=?,read_by_jobs=?,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, owner, read, at, by, source, id); err != nil { + return err + } + if err := touch(ctx, tx, page.WorkstreamID, at); err != nil { + return err + } + page, _, err = pageIn(ctx, tx, id) + out = page.PageNode + c = changed(page.WorkstreamID, PartPages) + return err + }) + if err != nil { + return PageNode{}, Change{}, err + } + return out, c, nil +} +func (s *Store) MovePage(ctx context.Context, id, parent, after string) (PageNode, Change, error) { + var out PageNode + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + p, overview, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if overview { + return invalid("Overview cannot be moved") + } + at := s.now().UnixMilli() + moved, err := placePage(ctx, tx, p, parent, after, false, at, "you", nil) + if err != nil { + return err + } + if moved { + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + c = changed(p.WorkstreamID, PartPages) + } + p, _, err = pageIn(ctx, tx, id) + out = p.PageNode + return err + }) + if err != nil { + return PageNode{}, Change{}, err + } + return out, c, nil +} +func (s *Store) DeletePage(ctx context.Context, id string) ([]string, Change, error) { + deleted := []string{} + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + p, overview, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if overview { + return invalid("Overview cannot be deleted") + } + nodes, err := pageNodesIn(ctx, tx, p.WorkstreamID) + if err != nil { + return err + } + gone := map[string]bool{id: true} + for _, n := range nodes { + if gone[n.ID] || gone[n.ParentID] { + gone[n.ID] = true + deleted = append(deleted, n.ID) + } + } + // Delete leaves before parents to avoid SQLite's trigger depth limit on a + // deep subtree; the cascade remains a backstop for ordinary SQL deletions. + for i := len(deleted) - 1; i >= 0; i-- { + if _, err := tx.ExecContext(ctx, "DELETE FROM workstream_pages WHERE id=?", deleted[i]); err != nil { + return err + } + } + i := 0 + for _, n := range nodes { + if !gone[n.ID] && n.ParentID == p.ParentID { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { + return err + } + i++ + } + } + if err := touch(ctx, tx, p.WorkstreamID, s.now().UnixMilli()); err != nil { + return err + } + c = changed(p.WorkstreamID, PartPages) + return nil + }) + if err != nil { + return nil, Change{}, err + } + return deleted, c, nil +} +func (s *Store) PageHistory(ctx context.Context, id string, limit int) ([]PageVersion, error) { + if limit == 0 { + limit = 50 + } + if limit < 1 || limit > 200 { + return nil, invalid("history limit must be 1–200") + } + out := []PageVersion{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, _, err := pageIn(ctx, tx, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, "SELECT version,saved_at,saved_by,source,title,bytes FROM workstream_page_versions WHERE page_id=? ORDER BY version DESC LIMIT ?", id, limit) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var p PageVersion + var at int64 + var source sql.NullString + if err := rows.Scan(&p.Version, &at, &p.SavedBy, &source, &p.Title, &p.Bytes); err != nil { + return err + } + p.SavedAt = instant(at) + p.From, err = readSource(source) + if err != nil { + return err + } + out = append(out, p) + } + return rows.Err() + }) + return out, err +} diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go new file mode 100644 index 0000000000..595ac51678 --- /dev/null +++ b/internal/workstream/pages_test.go @@ -0,0 +1,217 @@ +package workstream + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +// Part A (f): optimistic saves refuse stale versions without changing history; +// restoration copies an old title and document into a new immutable version. +func TestPartAPageSaveHistoryAndRestore(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Pages") + nodes, err := s.Pages(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + first, err := s.Page(ctx, nodes[0].ID, false) + if err != nil { + t.Fatal(err) + } + doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Saved"}]}]}`) + *now = now.Add(time.Hour) + saved, _, err := s.SavePage(ctx, first.ID, first.Version, doc, Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}) + if err != nil || saved.Version != 2 { + t.Fatalf("save: %+v %v", saved, err) + } + _, c, err := s.SavePage(ctx, first.ID, 1, json.RawMessage(emptyDoc), Attribution{}) + var stale *VersionConflict + if !errors.As(err, &stale) || stale.ID != first.ID || stale.Version != 2 || stale.UpdatedBy != "agent" || !stale.UpdatedAt.Equal(*now) || len(c.Parts) != 0 { + t.Fatalf("stale: %+v %v", stale, err) + } + current, _ := s.Page(ctx, first.ID, false) + history, _ := s.PageHistory(ctx, first.ID, 50) + if !reflect.DeepEqual(current, saved) || len(history) != 2 || history[0].Version != 2 || history[1].Version != 1 { + t.Fatalf("stale changed page or history: %+v %+v", current, history) + } + title := "Renamed" + _, _, err = s.UpdatePage(ctx, first.ID, PagePatch{Title: &title}) + if err != nil { + t.Fatal(err) + } + restored, _, err := s.RestorePage(ctx, first.ID, 1, 2, Attribution{}) + if err != nil || restored.Version != 3 || restored.Title != "Overview" || string(restored.Doc) != emptyDoc { + t.Fatalf("restore: %+v %v", restored, err) + } + history, _ = s.PageHistory(ctx, first.ID, 50) + if len(history) != 3 || history[0].Version != 3 || history[1].SavedBy != "agent" { + t.Fatalf("history: %+v", history) + } + _, _, err = s.SavePage(ctx, first.ID, 3, json.RawMessage(`{"x":"`+strings.Repeat("x", DocMax)+`"}`), Attribution{}) + if !errors.Is(err, ErrTooLarge) { + t.Fatalf("size: %v", err) + } +} + +// Part A (f): placement defaults differ for create and move; Overview is fixed, +// parent cycles and cross-workstream moves refuse, and delete removes descendants. +func TestPartAPageTreePlacementCyclesAndDelete(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Tree") + nodes, _ := s.Pages(ctx, w.ID) + overview := nodes[0] + a, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "A"}) + if err != nil { + t.Fatal(err) + } + b, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "B"}) + if err != nil { + t.Fatal(err) + } + child, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, ParentID: a.ID, Title: "Child"}) + if err != nil { + t.Fatal(err) + } + if a.Position != 1 || b.Position != 2 || child.Position != 0 { + t.Fatalf("positions: %+v %+v %+v", a, b, child) + } + if _, _, err := s.MovePage(ctx, a.ID, child.ID, ""); !errors.Is(err, ErrCycle) { + t.Fatalf("descendant: %v", err) + } + if _, _, err := s.MovePage(ctx, a.ID, a.ID, ""); !errors.Is(err, ErrCycle) { + t.Fatalf("self: %v", err) + } + if _, _, err := s.MovePage(ctx, overview.ID, a.ID, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("Overview move: %v", err) + } + moved, _, err := s.MovePage(ctx, b.ID, "", "") + if err != nil || moved.Position != 1 { + t.Fatalf("root move: %+v %v", moved, err) + } + nodes, _ = s.Pages(ctx, w.ID) + ids := []string{} + for _, n := range nodes { + ids = append(ids, n.ID) + } + if !reflect.DeepEqual(ids, []string{overview.ID, b.ID, a.ID, child.ID}) { + t.Fatalf("depth-first: %v", ids) + } + other := createStream(t, s, "OtherTree") + otherPages, _ := s.Pages(ctx, other.ID) + if _, _, err := s.MovePage(ctx, a.ID, otherPages[0].ID, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("cross-workstream: %v", err) + } + if _, _, err := s.DeletePage(ctx, overview.ID); !errors.Is(err, ErrInvalid) { + t.Fatalf("Overview delete: %v", err) + } + deleted, _, err := s.DeletePage(ctx, a.ID) + if err != nil || !reflect.DeepEqual(deleted, []string{a.ID, child.ID}) { + t.Fatalf("delete: %v %v", deleted, err) + } + if _, err := s.Page(ctx, child.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("child remains: %v", err) + } +} + +func TestPartAPlanMembershipVersionsAndDone(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Plan", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "8", Factory: "f"}) + p, err := s.Plan(ctx, w.ID) + if err != nil || p.Version != 0 || p.Milestones == nil || p.Waves == nil { + t.Fatalf("initial: %+v %v", p, err) + } + waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 8}, {Kind: "draft", DraftID: "draft", Title: "Next"}}}} + p, _, err = s.SetPlan(ctx, w.ID, 0, []Milestone{{Title: "Ship"}}, waves, Attribution{By: "agent"}) + if err != nil || p.Version != 1 || p.Milestones[0].ID == "" { + t.Fatalf("set: %+v %v", p, err) + } + _, _, err = s.SetPlan(ctx, w.ID, 0, nil, nil, Attribution{}) + var stale *VersionConflict + if !errors.As(err, &stale) || stale.Version != 1 || stale.UpdatedBy != "agent" { + t.Fatalf("stale: %v", err) + } + for _, bad := range [][]Wave{ + {{N: 0}}, {{N: 2}, {N: 1}}, {{N: 1}, {N: 1}}, {{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 99}}}}, + } { + if _, _, err := s.SetPlan(ctx, w.ID, 1, nil, bad, Attribution{}); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid waves %+v: %v", bad, err) + } + } + _, _, err = s.MarkDone(ctx, w.ID, "") + if err != nil { + t.Fatal(err) + } + if _, _, err := s.SetPlan(ctx, w.ID, 1, nil, nil, Attribution{}); !errors.Is(err, ErrConflict) { + t.Fatalf("done: %v", err) + } +} + +// page.get's markdown is the shared page renderer's output (pagedoc), so the +// engine has one renderer and it matches the desktop app's. +func TestPartAMarkdownExportIsThePageRenderer(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Markdown") + pages, err := s.Pages(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + doc := json.RawMessage(`{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},{"type":"paragraph","content":[{"type":"text","text":"Body"}]}]}`) + if _, _, err := s.SavePage(ctx, pages[0].ID, 1, doc, Attribution{}); err != nil { + t.Fatal(err) + } + p, err := s.Page(ctx, pages[0].ID, true) + if err != nil { + t.Fatal(err) + } + want, err := pagedoc.Markdown(doc) + if err != nil { + t.Fatal(err) + } + if p.Markdown == nil || *p.Markdown != want || !strings.Contains(want, "Title") { + t.Fatalf("markdown %v, want %q", p.Markdown, want) + } +} + +func TestPartAOverviewIdentitySurvivesRenaming(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Identity") + pages, err := s.Pages(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + title := "Introduction" + overview, _, err := s.UpdatePage(ctx, pages[0].ID, PagePatch{Title: &title}) + if err != nil { + t.Fatal(err) + } + other, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Overview"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MovePage(ctx, overview.ID, other.ID, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("renamed Overview move: %v", err) + } + if _, _, err := s.DeletePage(ctx, overview.ID); !errors.Is(err, ErrInvalid) { + t.Fatalf("renamed Overview delete: %v", err) + } + if _, _, err := s.MovePage(ctx, other.ID, "", ""); err != nil { + t.Fatalf("ordinary page named Overview: %v", err) + } + pages, err = s.Pages(ctx, w.ID) + if err != nil || pages[0].ID != overview.ID || pages[0].Position != 0 { + t.Fatalf("Overview order: %+v %v", pages, err) + } +} diff --git a/internal/workstream/plan.go b/internal/workstream/plan.go new file mode 100644 index 0000000000..b3b0443243 --- /dev/null +++ b/internal/workstream/plan.go @@ -0,0 +1,156 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" +) + +func planIn(ctx context.Context, tx *sql.Tx, id string) (Plan, error) { + p := Plan{WorkstreamID: id, Milestones: []Milestone{}, Waves: []Wave{}, UpdatedBy: "you"} + var at int64 + var milestones, waves string + err := tx.QueryRowContext(ctx, "SELECT version,updated_at,updated_by,milestones,waves FROM workstream_plans WHERE workstream_id=?", id).Scan(&p.Version, &at, &p.UpdatedBy, &milestones, &waves) + if errors.Is(err, sql.ErrNoRows) { + return p, nil + } + if err != nil { + return p, err + } + p.UpdatedAt = instant(at) + if err := json.Unmarshal([]byte(milestones), &p.Milestones); err != nil { + return p, err + } + err = json.Unmarshal([]byte(waves), &p.Waves) + return p, err +} +func (s *Store) Plan(ctx context.Context, id string) (Plan, error) { + var p Plan + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + var err error + p, err = planIn(ctx, tx, id) + return err + }) + return p, err +} +func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []Milestone, waves []Wave, who Attribution) (Plan, Change, error) { + by, err := actor(who.By) + if err != nil { + return Plan{}, Change{}, err + } + if base < 0 { + return Plan{}, Change{}, invalid("base_version must be non-negative") + } + // Copy slices because assigned ids and normalized lists belong to the saved plan. + milestones = append([]Milestone{}, milestones...) + waves = append([]Wave{}, waves...) + for i := range waves { + waves[i].Items = append([]PlanItem{}, waves[i].Items...) + } + var out Plan + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if !w.DoneAt.IsZero() { + return ErrConflict + } + p, err := planIn(ctx, tx, id) + if err != nil { + return err + } + if base != p.Version { + at := p.UpdatedAt + if at.IsZero() { + at = w.UpdatedAt + } + return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: at, UpdatedBy: p.UpdatedBy} + } + ids := map[string]bool{} + for i := range milestones { + m := &milestones[i] + if err := validTitle(m.Title); err != nil { + return err + } + if m.ID == "" { + m.ID, err = s.newID() + if err != nil { + return err + } + } + if ids[m.ID] { + return invalid("milestone ids must be unique") + } + ids[m.ID] = true + } + jobs := map[Job]bool{} + for _, j := range w.Jobs() { + jobs[j] = true + } + last := 0 + for _, wave := range waves { + if wave.N <= last { + return invalid("waves must be positive, unique and ascending") + } + last = wave.N + if !lineWithin(wave.Title, 200) { + return invalid("wave title must be at most 200 bytes without control characters") + } + if wave.MilestoneID != "" && !ids[wave.MilestoneID] { + return invalid("wave milestone does not exist") + } + for _, item := range wave.Items { + switch item.Kind { + case "job": + if item.Job <= 0 || !jobs[Job{Factory: item.Factory, Number: item.Job}] { + return invalid("plan job must be a member") + } + case "draft": + if item.DraftID == "" || item.Title == "" { + return invalid("draft needs draft_id and title") + } + if err := validTitle(item.Title); err != nil { + return err + } + if !textWithin(item.Text, len(item.Text)) { + return invalid("draft text allows only newline and tab control characters") + } + default: + return invalid("unknown plan item kind") + } + } + } + m, err := json.Marshal(milestones) + if err != nil { + return err + } + v, err := json.Marshal(waves) + if err != nil { + return err + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES (?,?,?,?,?,?) + ON CONFLICT(workstream_id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,updated_by=excluded.updated_by,milestones=excluded.milestones,waves=excluded.waves`, id, p.Version+1, at, by, string(m), string(v)); err != nil { + return err + } + if err := touch(ctx, tx, id, at); err != nil { + return err + } + out, err = planIn(ctx, tx, id) + c = changed(id, PartPlan) + return err + }) + if err != nil { + return Plan{}, Change{}, err + } + return out, c, nil +} diff --git a/internal/workstream/records.go b/internal/workstream/records.go new file mode 100644 index 0000000000..6b47ae28eb --- /dev/null +++ b/internal/workstream/records.go @@ -0,0 +1,223 @@ +package workstream + +import ( + "context" + "database/sql" + "strings" +) + +const recordSelect = `SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,coalesce(supersedes,''),coalesce(superseded_by,'') FROM workstream_records` + +func recordIn(ctx context.Context, tx *sql.Tx, id string) (Record, error) { + var r Record + var source sql.NullString + var at int64 + var decided sql.NullInt64 + err := tx.QueryRowContext(ctx, recordSelect+" WHERE id=?", id).Scan(&r.ID, &r.WorkstreamID, &r.Kind, &r.Text, &r.State, &r.By, &source, &at, &decided, &r.Supersedes, &r.SupersededBy) + if err != nil { + return r, found(err) + } + r.CreatedAt, r.DecidedAt = instant(at), optionalInstant(decided) + r.Source, err = readSource(source) + return r, err +} +func recordText(kind, text string) error { + if kind != "decision" && kind != "question" { + return invalid("record kind must be decision or question") + } + if strings.TrimSpace(text) == "" || !textWithin(text, 2000) { + return invalid("record text must be 1–2000 bytes") + } + return nil +} +func (s *Store) Records(ctx context.Context, id, kind, state string) ([]Record, error) { + if kind != "" && kind != "decision" && kind != "question" { + return nil, invalid("unknown record kind") + } + if state != "" && state != "proposed" && state != "kept" && state != "dropped" && state != "superseded" { + return nil, invalid("unknown record state") + } + out := []Record{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT id FROM workstream_records WHERE workstream_id=? AND (?='' OR kind=?) AND (?='' OR state=?) ORDER BY created_at DESC,rowid DESC`, id, kind, kind, state, state) + if err != nil { + return err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, id := range ids { + r, err := recordIn(ctx, tx, id) + if err != nil { + return err + } + out = append(out, r) + } + return nil + }) + return out, err +} +func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Change, error) { + if err := recordText(p.Kind, p.Text); err != nil { + return Record{}, Change{}, err + } + if p.By == "" { + if p.Kept { + p.By = "you" + } else { + p.By = "agent" + } + } + by, err := actor(p.By) + if err != nil { + return Record{}, Change{}, err + } + source, err := sourceJSON(p.Source) + if err != nil { + return Record{}, Change{}, err + } + var out Record + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + if _, err := getIn(ctx, tx, p.WorkstreamID); err != nil { + return err + } + id, err := s.newID() + if err != nil { + return err + } + at := s.now().UnixMilli() + state := "proposed" + var decided any + if p.Kept { + state = "kept" + decided = at + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at) VALUES (?,?,?,?,?,?,?,?,?)`, id, p.WorkstreamID, p.Kind, p.Text, state, by, source, at, decided); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + out, err = recordIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartRecords) + return err + }) + if err != nil { + return Record{}, Change{}, err + } + return out, c, nil +} +func (s *Store) KeepRecord(ctx context.Context, id string) (Record, Change, error) { + return s.setRecordState(ctx, id, "kept") +} +func (s *Store) DropRecord(ctx context.Context, id string) (Record, Change, error) { + return s.setRecordState(ctx, id, "dropped") +} +func (s *Store) setRecordState(ctx context.Context, id, state string) (Record, Change, error) { + var out Record + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + r, err := recordIn(ctx, tx, id) + if err != nil { + return err + } + if r.State == "superseded" { + return ErrConflict + } + if r.State == state { + out = r + return nil + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state=?,decided_at=? WHERE id=?", state, at, id); err != nil { + return err + } + if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { + return err + } + out, err = recordIn(ctx, tx, id) + c = changed(r.WorkstreamID, PartRecords) + return err + }) + if err != nil { + return Record{}, Change{}, err + } + return out, c, nil +} +func (s *Store) SupersedeRecord(ctx context.Context, id string, p RecordReplacement) (Record, Record, Change, error) { + by := p.By + if by == "" { + by = "agent" + } + by, err := actor(by) + if err != nil { + return Record{}, Record{}, Change{}, err + } + source, err := sourceJSON(p.Source) + if err != nil { + return Record{}, Record{}, Change{}, err + } + var out, old Record + var c Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + r, err := recordIn(ctx, tx, id) + if err != nil { + return err + } + if r.State == "superseded" { + return ErrConflict + } + kind := p.Kind + if kind == "" { + kind = r.Kind + } + if err := recordText(kind, p.Text); err != nil { + return err + } + replacement, err := s.newID() + if err != nil { + return err + } + at := s.now().UnixMilli() + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes) VALUES (?,?,?,?,'kept',?,?,?,?,?)`, replacement, r.WorkstreamID, kind, p.Text, by, source, at, at, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state='superseded',decided_at=?,superseded_by=? WHERE id=?", at, replacement, id); err != nil { + return err + } + if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { + return err + } + out, err = recordIn(ctx, tx, replacement) + if err != nil { + return err + } + old, err = recordIn(ctx, tx, id) + c = changed(r.WorkstreamID, PartRecords) + return err + }) + if err != nil { + return Record{}, Record{}, Change{}, err + } + return out, old, c, nil +} diff --git a/internal/workstream/records_goaheads_test.go b/internal/workstream/records_goaheads_test.go new file mode 100644 index 0000000000..3641a63594 --- /dev/null +++ b/internal/workstream/records_goaheads_test.go @@ -0,0 +1,240 @@ +package workstream + +import ( + "context" + "errors" + "math" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// Part A (g): keep/drop support undo; supersede creates a kept replacement with +// both links, and every attempt to change the old record conflicts. +func TestPartARecordLifecycle(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Records") + r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Count retries?"}) + if err != nil || r.State != "proposed" || r.By != "agent" { + t.Fatalf("propose: %+v %v", r, err) + } + r, _, err = s.KeepRecord(ctx, r.ID) + if err != nil || r.State != "kept" || r.DecidedAt.IsZero() { + t.Fatalf("keep: %+v %v", r, err) + } + r, _, err = s.DropRecord(ctx, r.ID) + if err != nil || r.State != "dropped" { + t.Fatalf("drop: %+v %v", r, err) + } + r, _, err = s.KeepRecord(ctx, r.ID) + if err != nil || r.State != "kept" { + t.Fatalf("undo: %+v %v", r, err) + } + replacement, old, _, err := s.SupersedeRecord(ctx, r.ID, RecordReplacement{Kind: "decision", Text: "Retries do not count"}) + if err != nil || replacement.State != "kept" || replacement.Supersedes != r.ID || old.State != "superseded" || old.SupersededBy != replacement.ID { + t.Fatalf("supersede: %+v %+v %v", replacement, old, err) + } + if _, _, err := s.KeepRecord(ctx, r.ID); !errors.Is(err, ErrConflict) { + t.Fatalf("keep superseded: %v", err) + } + if _, _, err := s.DropRecord(ctx, r.ID); !errors.Is(err, ErrConflict) { + t.Fatalf("drop superseded: %v", err) + } + if _, _, _, err := s.SupersedeRecord(ctx, r.ID, RecordReplacement{Text: "Again"}); !errors.Is(err, ErrConflict) { + t.Fatalf("supersede superseded: %v", err) + } + kept, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "My decision", Kept: true}) + if err != nil || kept.By != "you" || kept.State != "kept" || kept.DecidedAt.IsZero() { + t.Fatalf("kept: %+v %v", kept, err) + } + all, err := s.Records(ctx, w.ID, "", "") + if err != nil || len(all) != 3 { + t.Fatalf("records: %+v %v", all, err) + } + proposed, err := s.Records(ctx, w.ID, "question", "proposed") + if err != nil || len(proposed) != 0 { + t.Fatalf("filter: %+v %v", proposed, err) + } +} + +// Part A (h): each malformed grant is refused and stores nothing. +func TestPartAGoAheadBoundsAndTargets(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Bounds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) + good := GoAheadGrant{WorkstreamID: w.ID, Scope: "job", Factory: "f", Job: 4, CapUSD: 1, EndsAt: now.Add(time.Hour)} + for _, tc := range []struct { + name string + change func(*GoAheadGrant) + }{ + {"scope", func(p *GoAheadGrant) { p.Scope = "" }}, + {"cap absent", func(p *GoAheadGrant) { p.CapUSD = 0 }}, + {"negative cap", func(p *GoAheadGrant) { p.CapUSD = -1 }}, + {"large cap", func(p *GoAheadGrant) { p.CapUSD = 10000.01 }}, + {"nan cap", func(p *GoAheadGrant) { p.CapUSD = math.NaN() }}, + {"infinite cap", func(p *GoAheadGrant) { p.CapUSD = math.Inf(1) }}, + {"end absent", func(p *GoAheadGrant) { p.EndsAt = time.Time{} }}, + {"end past", func(p *GoAheadGrant) { p.EndsAt = now.Add(-time.Second) }}, + {"end now", func(p *GoAheadGrant) { p.EndsAt = *now }}, + {"end too far", func(p *GoAheadGrant) { p.EndsAt = now.Add(90*24*time.Hour + time.Millisecond) }}, + {"factory", func(p *GoAheadGrant) { p.Factory = "" }}, + {"job", func(p *GoAheadGrant) { p.Job = 0 }}, + {"not member", func(p *GoAheadGrant) { p.Job = 5 }}, + {"wave missing", func(p *GoAheadGrant) { p.Scope = "wave"; p.Factory = ""; p.Job = 0; p.Wave = 1 }}, + {"milestone missing", func(p *GoAheadGrant) { p.Scope = "milestone"; p.Factory = ""; p.Job = 0; p.MilestoneID = "missing" }}, + } { + t.Run(tc.name, func(t *testing.T) { + isolated, _, _ := testStore(t) + stream := createStream(t, isolated, "Bounds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) + p := good + p.WorkstreamID = stream.ID + tc.change(&p) + _, c, err := isolated.GrantGoAhead(ctx, p) + if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { + t.Fatalf("grant: %+v %v", c, err) + } + all, err := isolated.GoAheads(ctx, stream.ID, true) + if err != nil || len(all) != 0 { + t.Fatalf("stored invalid grant: %+v %v", all, err) + } + }) + } + good.CapUSD = 10000 + good.EndsAt = now.Add(90 * 24 * time.Hour) + if _, _, err := s.GrantGoAhead(ctx, good); err != nil { + t.Fatalf("inclusive limits: %v", err) + } +} + +// Part A (e): MarkDone ends every scope together, is idempotent, and preserves +// prior revocations. Expiry is derived by reads without mutating storage. +func TestPartAMarkDoneEndsGoAheadsAndExpiry(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "GoAheads", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) + plan, _, err := s.SetPlan(ctx, w.ID, 0, []Milestone{{ID: "ship", Title: "Ship"}}, []Wave{{N: 1}}, Attribution{}) + if err != nil || plan.Version != 1 { + t.Fatal(err) + } + for _, p := range []GoAheadGrant{ + {Scope: "job", Factory: "f", Job: 4}, {Scope: "wave", Wave: 1}, {Scope: "milestone", MilestoneID: "ship"}, + } { + p.WorkstreamID = w.ID + p.CapUSD = 20 + p.EndsAt = now.Add(time.Hour) + if _, _, err := s.GrantGoAhead(ctx, p); err != nil { + t.Fatal(err) + } + } + all, _ := s.GoAheads(ctx, w.ID, true) + if _, _, err := s.RevokeGoAhead(ctx, all[0].ID); err != nil { + t.Fatal(err) + } + _, c, err := s.MarkDone(ctx, w.ID, "Shipped") + if err != nil || len(c.Parts) != 2 { + t.Fatalf("done: %+v %v", c, err) + } + active, _ := s.GoAheads(ctx, w.ID, false) + if len(active) != 0 { + t.Fatalf("active: %+v", active) + } + all, _ = s.GoAheads(ctx, w.ID, true) + ended := 0 + for _, g := range all { + if g.State != "ended" || g.EndedAt.IsZero() { + t.Fatalf("not ended: %+v", g) + } + if g.EndReason == "workstream_done" { + ended++ + } + } + if ended != 2 { + t.Fatalf("ended %d active scopes", ended) + } + _, c, err = s.MarkDone(ctx, w.ID, "Changed") + if err != nil || len(c.Parts) != 0 { + t.Fatalf("repeated done: %+v %v", c, err) + } + if _, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: w.ID, Scope: "wave", Wave: 1, CapUSD: 2, EndsAt: now.Add(time.Hour)}); !errors.Is(err, ErrConflict) { + t.Fatalf("grant on done: %v", err) + } + fresh := createStream(t, s, "Expiry", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "9", Factory: "f"}) + g, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: fresh.ID, Scope: "job", Factory: "f", Job: 9, CapUSD: 2, EndsAt: now.Add(time.Minute)}) + if err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + all, err = s.GoAheads(ctx, fresh.ID, true) + if err != nil || len(all) != 1 || all[0].EndReason != "expired" || !all[0].EndedAt.Equal(g.EndsAt) { + t.Fatalf("expiry: %+v %v", all, err) + } + active, _ = s.GoAheads(ctx, fresh.ID, false) + if len(active) != 0 { + t.Fatal("expired shown active") + } + g, c, err = s.RevokeGoAhead(ctx, g.ID) + if err != nil || g.EndReason != "expired" || len(c.Parts) != 0 { + t.Fatalf("expired revoke: %+v %+v %v", g, c, err) + } + if _, _, err := s.MarkDone(ctx, fresh.ID, ""); err != nil { + t.Fatal(err) + } + all, err = s.GoAheads(ctx, fresh.ID, true) + if err != nil || len(all) != 1 || all[0].EndReason != "expired" { + t.Fatalf("done changed expiry: %+v %v", all, err) + } +} + +// Part A (j): unknown readings stay absent and measured zero stays present. +func TestPartANumbersUnknownAndMeasuredZero(t *testing.T) { + jobs := []Job{{Factory: "f", Number: 1}, {Factory: "f", Number: 2}} + zero := 0.0 + no := false + yes := true + n := CountNumbers(jobs, nil, &zero, 3) + if n.Total != 2 || n.Merged != nil || n.NeedsYou != nil || n.SpendUSD != nil { + t.Fatalf("unknown: %+v", n) + } + facts := map[Job]JobFacts{jobs[0]: {State: "backlog", Merged: &no, CostUSD: &zero}, jobs[1]: {State: "backlog", Merged: &no, CostUSD: &zero}} + n = CountNumbers(jobs, facts, &zero, 0) + if n.Merged == nil || *n.Merged != 0 || n.NeedsYou == nil || *n.NeedsYou != 0 || n.SpendUSD == nil || *n.SpendUSD != 0 { + t.Fatalf("zero: %+v", n) + } + cost := 2.5 + agent := 1.0 + facts[jobs[0]] = JobFacts{State: "needs_you", Merged: &yes, CostUSD: &cost} + n = CountNumbers(jobs, facts, &agent, 3) + if *n.Merged != 1 || *n.NeedsYou != 4 || *n.SpendUSD != 3.5 { + t.Fatalf("numbers: %+v", n) + } + n = CountNumbers(jobs, facts, nil, 0) + if n.SpendUSD != nil || n.Merged == nil || n.NeedsYou == nil { + t.Fatalf("unknown agent: %+v", n) + } +} + +func TestPartANumbersWithPartiallyUnknownFacts(t *testing.T) { + jobs := []Job{{Factory: "f", Number: 1}, {Factory: "f", Number: 2}} + zero := 0.0 + no := false + known := JobFacts{State: "done", Merged: &no, CostUSD: &zero} + for _, facts := range []map[Job]JobFacts{ + {jobs[0]: known}, + {jobs[0]: known, jobs[1]: {State: "unknown", Merged: &no, CostUSD: &zero}}, + } { + n := CountNumbers(jobs, facts, &zero, 0) + if n.Total != 2 || n.Merged != nil || n.NeedsYou != nil || n.SpendUSD != nil { + t.Fatalf("partial unknown: %+v", n) + } + } + n := CountNumbers(jobs, map[Job]JobFacts{jobs[0]: known, jobs[1]: {State: "done", Merged: &no}}, &zero, 0) + if n.Merged == nil || n.NeedsYou == nil || n.SpendUSD != nil { + t.Fatalf("unknown cost: %+v", n) + } + n = CountNumbers(nil, nil, &zero, 2) + if n.Total != 0 || n.Merged == nil || *n.Merged != 0 || n.NeedsYou == nil || *n.NeedsYou != 2 || n.SpendUSD == nil || *n.SpendUSD != 0 { + t.Fatalf("no jobs: %+v", n) + } +} diff --git a/internal/workstream/store.go b/internal/workstream/store.go new file mode 100644 index 0000000000..1d7ecebf7b --- /dev/null +++ b/internal/workstream/store.go @@ -0,0 +1,535 @@ +package workstream + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +type Options struct { + Now func() time.Time + NewID func() (string, error) +} +type Store struct { + workspace *workspace.Store + now func() time.Time + newID func() (string, error) +} + +func New(w *workspace.Store, opts Options) *Store { + if opts.Now == nil { + opts.Now = time.Now + } + if opts.NewID == nil { + opts.NewID = func() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil + } + } + return &Store{workspace: w, now: opts.Now, newID: opts.NewID} +} + +func instant(ms int64) time.Time { return time.UnixMilli(ms).UTC() } +func optionalInstant(ms sql.NullInt64) time.Time { + if !ms.Valid { + return time.Time{} + } + return instant(ms.Int64) +} +func nullString(s string) any { + if s == "" { + return nil + } + return s +} +func nullInt(n int) any { + if n == 0 { + return nil + } + return n +} +func invalid(message string) error { return fmt.Errorf("%w: %s", ErrInvalid, message) } +func textWithin(s string, limit int) bool { + return len(s) <= limit && utf8.ValidString(s) && + strings.IndexFunc(s, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\t' }) < 0 +} +func lineWithin(s string, limit int) bool { + return len(s) <= limit && utf8.ValidString(s) && strings.IndexFunc(s, unicode.IsControl) < 0 +} +func actor(by string) (string, error) { + if by == "" { + by = "you" + } + if by != "you" && by != "agent" { + return "", invalid("actor must be you or agent") + } + return by, nil +} +func validTitle(title string) error { + if strings.TrimSpace(title) == "" || !lineWithin(title, 200) { + return invalid("title must be 1–200 bytes") + } + return nil +} +func sourceJSON(source *Source) (any, error) { + if source == nil { + return nil, nil + } + if source.ID == "" || !textWithin(source.ID, 4096) || !lineWithin(source.Label, 200) { + return nil, invalid("source needs an id and a label of at most 200 bytes") + } + switch source.Kind { + case "chat", "page", "url": + case "message": + if source.ChatID == "" { + return nil, invalid("message source needs chat_id") + } + case "factory_job": + if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: source.ID, Factory: source.Factory}).Validate(); err != nil { + return nil, err + } + default: + return nil, invalid("unknown source kind") + } + b, err := json.Marshal(source) + return string(b), err +} +func readSource(raw sql.NullString) (*Source, error) { + if !raw.Valid { + return nil, nil + } + var s Source + err := json.Unmarshal([]byte(raw.String), &s) + return &s, err +} +func found(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err +} +func touch(ctx context.Context, tx *sql.Tx, id string, at int64) error { + _, err := tx.ExecContext(ctx, "UPDATE workstreams SET updated_at=max(updated_at+1,?) WHERE id=?", at, id) + return err +} +func changed(id string, part ChangedPart) Change { + c := Change{ID: id, Parts: []ChangedPart{PartWorkstream}} + if part != PartWorkstream { + c.Parts = append(c.Parts, part) + } + return c +} + +const workstreamSelect = `SELECT w.id,c.name,w.why,w.origin_chat_id,w.agent_id,w.autonomy,w.wake_morning,w.wake_related_issues, + w.budget_usd,w.created_at,w.updated_at,w.started_at,w.done_at,w.how_it_went,w.client_id, + (SELECT count(*) FROM workstream_records r WHERE r.workstream_id=w.id AND r.state='proposed') + FROM workstreams w JOIN collections c ON c.id=w.id WHERE w.id=?` + +func getIn(ctx context.Context, tx *sql.Tx, id string) (Workstream, error) { + var w Workstream + var created, updated int64 + var start, done sql.NullInt64 + var budget sql.NullFloat64 + err := tx.QueryRowContext(ctx, workstreamSelect, id).Scan(&w.ID, &w.Name, &w.Why, &w.OriginChatID, &w.AgentID, &w.Autonomy, &w.Wake.Morning, &w.Wake.RelatedIssues, &budget, &created, &updated, &start, &done, &w.HowItWent, &w.ClientID, &w.Proposed) + if err != nil { + return w, found(err) + } + w.CreatedAt, w.UpdatedAt, w.StartedAt, w.DoneAt = instant(created), instant(updated), optionalInstant(start), optionalInstant(done) + if budget.Valid { + w.BudgetUSD = &budget.Float64 + } + w.Stage = Stage(w.DoneAt, w.StartedAt, nil) + rows, err := tx.QueryContext(ctx, "SELECT kind,ref_id,session_id,factory FROM memberships WHERE collection_id=? ORDER BY seq", id) + if err != nil { + return w, err + } + defer rows.Close() + w.Members = make([]workspace.Ref, 0) + for rows.Next() { + var ref workspace.Ref + if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID, &ref.Factory); err != nil { + return w, err + } + w.Members = append(w.Members, ref) + if ref.Kind == workspace.FactoryJobKind { + w.Total++ + } + } + return w, rows.Err() +} + +func (s *Store) Get(ctx context.Context, id string) (Workstream, error) { + var out Workstream + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return ErrNotFound + } + var err error + out, err = getIn(ctx, tx, id) + return err + }) + return out, err +} + +// ByClientID lets an adapter recognize a retry before creating its agent. +// Create checks it again inside the writer transaction to settle races. +func (s *Store) ByClientID(ctx context.Context, clientID string) (Workstream, error) { + var out Workstream + if clientID == "" { + return out, ErrNotFound + } + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + var id string + if err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", clientID).Scan(&id); err != nil { + return found(err) + } + var err error + out, err = getIn(ctx, tx, id) + return err + }) + return out, err +} + +func (s *Store) List(ctx context.Context) ([]Workstream, error) { + out := make([]Workstream, 0) + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT id FROM workstreams ORDER BY updated_at DESC,id") + if err != nil { + return err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, id := range ids { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + out = append(out, w) + } + return nil + }) + return out, err +} + +func (s *Store) Create(ctx context.Context, p Create) (Workstream, Change, error) { + p.Name = strings.TrimSpace(p.Name) + if err := workspace.ValidateName(p.Name); err != nil { + return Workstream{}, Change{}, err + } + if !textWithin(p.Why, 4000) || !lineWithin(p.ClientID, 128) || len(p.Refs) > 200 { + return Workstream{}, Change{}, invalid("create exceeds workstream bounds") + } + refs := append([]workspace.Ref(nil), p.Refs...) + refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.AgentID}) + if p.OriginChatID != "" { + refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.OriginChatID}) + } + additions := make([]workspace.Addition, 0, len(refs)) + for _, ref := range refs { + if err := ref.Validate(); err != nil { + return Workstream{}, Change{}, err + } + additions = append(additions, workspace.Addition{Ref: ref}) + } + var out Workstream + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + if p.ClientID != "" { + var id string + err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", p.ClientID).Scan(&id) + if err == nil { + out, err = getIn(ctx, tx, id) + return err + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + } + id, err := s.newID() + if err != nil { + return err + } + at := s.now().UnixMilli() + if err := workspace.CreateIn(ctx, tx, id, p.Name, at); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstreams(id,why,origin_chat_id,agent_id,created_at,updated_at,client_id) VALUES (?,?,?,?,?,?,?)`, id, p.Why, p.OriginChatID, p.AgentID, at, at, p.ClientID); err != nil { + return err + } + if _, _, err := workspace.AddRefsIn(ctx, tx, id, additions, at); err != nil { + return err + } + pageID, err := s.newID() + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc,overview) VALUES (?,?,'Overview','you',0,1,?,'you',?,1)`, pageID, id, at, emptyDoc); err != nil { + return err + } + if err := saveVersion(ctx, tx, pageID); err != nil { + return err + } + out, err = getIn(ctx, tx, id) + c = Change{ID: id, Parts: []ChangedPart{PartWorkstream, PartPages, PartJobs}} + return err + }) + if err != nil { + return Workstream{}, Change{}, err + } + return out, c, nil +} + +func (s *Store) Update(ctx context.Context, id string, p Patch) (Workstream, Change, error) { + if p.Name != nil { + name := strings.TrimSpace(*p.Name) + p.Name = &name + if err := workspace.ValidateName(name); err != nil { + return Workstream{}, Change{}, err + } + } + if p.Why != nil && !textWithin(*p.Why, 4000) { + return Workstream{}, Change{}, invalid("why exceeds 4000 bytes") + } + if p.Autonomy != nil && *p.Autonomy != "propose" && *p.Autonomy != "small" && *p.Autonomy != "off" { + return Workstream{}, Change{}, invalid("unknown autonomy") + } + if p.BudgetUSD != nil && (math.IsNaN(*p.BudgetUSD) || math.IsInf(*p.BudgetUSD, 0) || *p.BudgetUSD < 0) { + return Workstream{}, Change{}, invalid("budget must be finite and non-negative") + } + var out Workstream + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + before := w + if p.Name != nil { + w.Name = *p.Name + } + if p.Why != nil { + w.Why = *p.Why + } + if p.Autonomy != nil { + w.Autonomy = *p.Autonomy + } + if p.Wake != nil { + w.Wake = *p.Wake + } + if p.BudgetUSD != nil { + w.BudgetUSD = p.BudgetUSD + if *p.BudgetUSD == 0 { + w.BudgetUSD = nil + } + } + sameBudget := (w.BudgetUSD == nil && before.BudgetUSD == nil) || (w.BudgetUSD != nil && before.BudgetUSD != nil && *w.BudgetUSD == *before.BudgetUSD) + if w.Name == before.Name && w.Why == before.Why && w.Autonomy == before.Autonomy && w.Wake == before.Wake && sameBudget { + out = w + return nil + } + if w.Name != before.Name { + if err := workspace.RenameIn(ctx, tx, id, w.Name); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE workstreams SET why=?,autonomy=?,wake_morning=?,wake_related_issues=?,budget_usd=?,updated_at=max(updated_at+1,?) WHERE id=?`, w.Why, w.Autonomy, w.Wake.Morning, w.Wake.RelatedIssues, w.BudgetUSD, s.now().UnixMilli(), id); err != nil { + return err + } + out, err = getIn(ctx, tx, id) + c = changed(id, PartWorkstream) + return err + }) + if err != nil { + return Workstream{}, Change{}, err + } + return out, c, nil +} + +// Stage follows the wire rule; dropped and unknown states do not start a workstream. +func Stage(done, started time.Time, states []string) string { + if !done.IsZero() { + return "done" + } + if !started.IsZero() { + return "building" + } + for _, state := range states { + switch state { + case "queued", "running", "needs_you", "done": + return "building" + } + } + return "shaping" +} + +// Jobs returns direct factory job members in collection order. +func (w Workstream) Jobs() []Job { + out := make([]Job, 0, w.Total) + for _, ref := range w.Members { + if ref.Kind == workspace.FactoryJobKind { + n, _ := strconv.Atoi(ref.ID) + out = append(out, Job{Factory: ref.Factory, Number: n}) + } + } + return out +} +func (s *Store) ObserveJobStates(ctx context.Context, id string, states map[Job]string) (Change, error) { + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if !w.StartedAt.IsZero() { + return nil + } + memberStates := make([]string, 0, w.Total) + for _, job := range w.Jobs() { + memberStates = append(memberStates, states[job]) + } + if Stage(time.Time{}, time.Time{}, memberStates) != "building" { + return nil + } + at := s.now().UnixMilli() + _, err = tx.ExecContext(ctx, "UPDATE workstreams SET started_at=?,updated_at=max(updated_at+1,?) WHERE id=?", at, at, id) + if err == nil { + c = changed(id, PartWorkstream) + } + return err + }) + if err != nil { + return Change{}, err + } + return c, nil +} +func (s *Store) MarkDone(ctx context.Context, id, how string) (Workstream, Change, error) { + if !textWithin(how, 4000) { + return Workstream{}, Change{}, invalid("how_it_went exceeds 4000 bytes") + } + return s.setDone(ctx, id, how, true) +} +func (s *Store) Reopen(ctx context.Context, id string) (Workstream, Change, error) { + return s.setDone(ctx, id, "", false) +} +func (s *Store) setDone(ctx context.Context, id, how string, done bool) (Workstream, Change, error) { + var out Workstream + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if done == !w.DoneAt.IsZero() { + out = w + return nil + } + at := s.now().UnixMilli() + if done { + if _, err := tx.ExecContext(ctx, "UPDATE workstreams SET done_at=?,how_it_went=?,updated_at=max(updated_at+1,?) WHERE id=?", at, how, at, id); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `UPDATE workstream_goaheads SET state='ended', + end_reason=CASE WHEN ends_at<=? THEN 'expired' ELSE 'workstream_done' END, + ended_at=CASE WHEN ends_at<=? THEN ends_at ELSE ? END + WHERE workstream_id=? AND state='active'`, at, at, at, id) + if err != nil { + return err + } + c = changed(id, PartWorkstream) + n, err := result.RowsAffected() + if err != nil { + return err + } + if n > 0 { + c.Parts = append(c.Parts, PartGoAheads) + } + } else { + if _, err := tx.ExecContext(ctx, "UPDATE workstreams SET done_at=NULL,started_at=coalesce(started_at,?),updated_at=max(updated_at+1,?) WHERE id=?", at, at, id); err != nil { + return err + } + c = changed(id, PartWorkstream) + } + out, err = getIn(ctx, tx, id) + return err + }) + if err != nil { + return Workstream{}, Change{}, err + } + return out, c, nil +} + +// CountNumbers retains measured zeroes and withholds any total needing unknown +// facts. Job state must be known even for spend, as required by the contract. +func CountNumbers(jobs []Job, facts map[Job]JobFacts, agentSpend *float64, proposed int) Numbers { + out := Numbers{Total: len(jobs)} + merged, needs, spend := 0, proposed, 0.0 + mergedKnown, statesKnown, costKnown := true, true, true + for _, job := range jobs { + f, ok := facts[job] + known := ok && (f.State == "backlog" || f.State == "queued" || f.State == "running" || f.State == "needs_you" || f.State == "done" || f.State == "dropped") + if !known { + statesKnown = false + } + if !known || f.Merged == nil { + mergedKnown = false + } else if *f.Merged { + merged++ + } + if f.State == "needs_you" { + needs++ + } + if !known || f.CostUSD == nil || math.IsNaN(*f.CostUSD) || math.IsInf(*f.CostUSD, 0) || *f.CostUSD < 0 { + costKnown = false + } else { + spend += *f.CostUSD + } + } + if mergedKnown { + out.Merged = &merged + } + if statesKnown { + out.NeedsYou = &needs + } + if costKnown && agentSpend != nil && !math.IsNaN(*agentSpend) && !math.IsInf(*agentSpend, 0) && *agentSpend >= 0 { + spend += *agentSpend + if !math.IsInf(spend, 0) { + out.SpendUSD = &spend + } + } + return out +} diff --git a/internal/workstream/store_test.go b/internal/workstream/store_test.go new file mode 100644 index 0000000000..3995666199 --- /dev/null +++ b/internal/workstream/store_test.go @@ -0,0 +1,215 @@ +package workstream + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +func testStore(t *testing.T) (*Store, *workspace.Store, *time.Time) { + t.Helper() + w, err := workspace.Open(filepath.Join(t.TempDir(), "collections.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := w.Close(); err != nil { + t.Error(err) + } + }) + now := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC) + n := 0 + s := New(w, Options{Now: func() time.Time { return now }, NewID: func() (string, error) { n++; return fmt.Sprintf("id-%d", n), nil }}) + return s, w, &now +} + +func createStream(t *testing.T, s *Store, name string, refs ...workspace.Ref) Workstream { + t.Helper() + row, _, err := s.Create(context.Background(), Create{Name: name, AgentID: "agent-" + name, Refs: refs}) + if err != nil { + t.Fatal(err) + } + return row +} + +// Part A create: storage creates exactly one collection, row and Overview; +// client retries and refused job ownership leave no partial creation. +func TestPartACreateIsAtomicAndIdempotent(t *testing.T) { + s, w, _ := testStore(t) + ctx := context.Background() + args := Create{Name: "Billing", AgentID: "agent", OriginChatID: "origin", ClientID: "retry", Refs: []workspace.Ref{{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}}} + first, c, err := s.Create(ctx, args) + if err != nil || len(c.Parts) == 0 { + t.Fatalf("create: %+v %v", c, err) + } + indexed, err := s.ByClientID(ctx, "retry") + if err != nil || indexed.ID != first.ID { + t.Fatalf("client lookup: %+v %v", indexed, err) + } + if _, err := s.ByClientID(ctx, ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("empty client id: %v", err) + } + args.Name = "Other" + args.AgentID = "other" + again, c, err := s.Create(ctx, args) + if err != nil || again.ID != first.ID || len(c.Parts) != 0 { + t.Fatalf("retry: %+v %+v %v", again, c, err) + } + all, err := s.List(ctx) + if err != nil || len(all) != 1 { + t.Fatalf("rows: %v %v", all, err) + } + collections, err := w.Collections(ctx) + if err != nil || len(collections) != 1 || !collections[0].Workstream { + t.Fatalf("collections: %v %v", collections, err) + } + pages, err := s.Pages(ctx, first.ID) + if err != nil || len(pages) != 1 || pages[0].Title != "Overview" || pages[0].Owner != "you" || pages[0].Version != 1 || pages[0].Position != 0 { + t.Fatalf("pages: %v %v", pages, err) + } + p, err := s.Page(ctx, pages[0].ID, true) + if err != nil || string(p.Doc) != `{"type":"doc","content":[]}` || p.Markdown == nil || *p.Markdown != "" { + t.Fatalf("overview: %+v %v", p, err) + } + members, err := w.Members(ctx, first.ID) + if err != nil || len(members) != 3 { + t.Fatalf("members: %v %v", members, err) + } + _, _, err = s.Create(ctx, Create{Name: "Conflict", AgentID: "third", Refs: args.Refs}) + var taken *workspace.WorkstreamTaken + if !errors.As(err, &taken) || taken.WorkstreamID != first.ID || taken.WorkstreamName != "Billing" { + t.Fatalf("taken: %v", err) + } + all, _ = s.List(ctx) + collections, _ = w.Collections(ctx) + if len(all) != 1 || len(collections) != 1 { + t.Fatal("failed create left state") + } +} + +// Part A (b): a batch add rolls back every ref and both workstream timestamps. +func TestPartAJobOwnershipRefusalAndPlainDuplicate(t *testing.T) { + s, w, now := testStore(t) + ctx := context.Background() + job := workspace.Ref{Kind: workspace.FactoryJobKind, ID: "17", Factory: "factory"} + a := createStream(t, s, "First", job) + b := createStream(t, s, "Second") + before, _ := w.Members(ctx, b.ID) + *now = now.Add(time.Hour) + _, _, err := w.AddRefs(ctx, b.ID, []workspace.Addition{{Ref: workspace.Ref{Kind: workspace.ConversationKind, ID: "extra"}}, {Ref: job}}) + var taken *workspace.WorkstreamTaken + if !errors.As(err, &taken) || taken.Factory != "factory" || taken.Job != 17 || taken.WorkstreamID != a.ID || taken.WorkstreamName != "First" { + t.Fatalf("taken: %+v %v", taken, err) + } + after, _ := w.Members(ctx, b.ID) + a2, _ := s.Get(ctx, a.ID) + b2, _ := s.Get(ctx, b.ID) + if !reflect.DeepEqual(before, after) || !a.UpdatedAt.Equal(a2.UpdatedAt) || !b.UpdatedAt.Equal(b2.UpdatedAt) { + t.Fatal("refused add changed state") + } + plain, err := w.Create(ctx, "Plain") + if err != nil { + t.Fatal(err) + } + if err := w.Add(ctx, plain.ID, job); err != nil { + t.Fatal(err) + } + duplicate, err := w.Duplicate(ctx, a.ID, "Copy") + if err != nil || duplicate.Workstream { + t.Fatalf("duplicate: %+v %v", duplicate, err) + } + if _, err := s.Get(ctx, duplicate.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("duplicate became workstream: %v", err) + } + if _, err := w.Delete(ctx, a.ID); !errors.Is(err, workspace.ErrWorkstreamDelete) { + t.Fatalf("delete: %v", err) + } + if err := w.Remove(ctx, a.ID, job); err != nil { + t.Fatal(err) + } + if err := w.Add(ctx, b.ID, job); err != nil { + t.Fatal(err) + } + b2, _ = s.Get(ctx, b.ID) + if !b2.UpdatedAt.After(b.UpdatedAt) { + t.Fatal("membership did not update timestamp") + } +} + +// Part A (d): stage persists the first departure, all merged is not Done, +// and reopening an unstarted workstream gives Building even with Backlog. +func TestPartAStageAndObservation(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + job := workspace.Ref{Kind: workspace.FactoryJobKind, ID: "1", Factory: "f"} + row := createStream(t, s, "Stage", job) + for _, state := range []string{"", "backlog", "dropped", "unknown"} { + if got := Stage(time.Time{}, time.Time{}, []string{state}); got != "shaping" { + t.Fatalf("%s: %s", state, got) + } + } + for _, state := range []string{"queued", "running", "needs_you", "done"} { + if got := Stage(time.Time{}, time.Time{}, []string{state}); got != "building" { + t.Fatalf("%s: %s", state, got) + } + } + c, err := s.ObserveJobStates(ctx, row.ID, map[Job]string{{Factory: "f", Number: 1}: "queued"}) + if err != nil || len(c.Parts) == 0 { + t.Fatalf("observe: %+v %v", c, err) + } + got, _ := s.Get(ctx, row.ID) + if got.StartedAt.IsZero() || got.Stage != "building" { + t.Fatalf("started: %+v", got) + } + c, err = s.ObserveJobStates(ctx, row.ID, map[Job]string{{Factory: "f", Number: 1}: "backlog"}) + if err != nil || len(c.Parts) != 0 { + t.Fatal("observation reset start") + } + done, _, err := s.MarkDone(ctx, row.ID, "") + if err != nil || done.Stage != "done" { + t.Fatalf("done: %+v %v", done, err) + } + open, _, err := s.Reopen(ctx, row.ID) + if err != nil || open.Stage != "building" { + t.Fatalf("reopen: %+v %v", open, err) + } + fresh := createStream(t, s, "Unstarted") + _, _, err = s.MarkDone(ctx, fresh.ID, "") + if err != nil { + t.Fatal(err) + } + open, _, err = s.Reopen(ctx, fresh.ID) + if err != nil || open.StartedAt.IsZero() || open.Stage != "building" { + t.Fatalf("unstarted reopen: %+v %v", open, err) + } +} + +func TestPartAUpdateClearsSettingsAndKeepsNameRules(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + row := createStream(t, s, "Settings") + why := "Because" + budget := 20.0 + small := "small" + row, _, err := s.Update(ctx, row.ID, Patch{Why: &why, BudgetUSD: &budget, Autonomy: &small, Wake: &Wake{Morning: true}}) + if err != nil || row.BudgetUSD == nil || *row.BudgetUSD != 20 || !row.Wake.Morning { + t.Fatalf("update: %+v %v", row, err) + } + why = "" + budget = 0 + row, _, err = s.Update(ctx, row.ID, Patch{Why: &why, BudgetUSD: &budget}) + if err != nil || row.Why != "" || row.BudgetUSD != nil { + t.Fatalf("clear: %+v %v", row, err) + } + createStream(t, s, "Taken") + name := "taken" + if _, _, err := s.Update(ctx, row.ID, Patch{Name: &name}); !errors.Is(err, workspace.ErrNameTaken) { + t.Fatalf("name: %v", err) + } +} diff --git a/internal/workstream/workstream.go b/internal/workstream/workstream.go new file mode 100644 index 0000000000..ce31042bb1 --- /dev/null +++ b/internal/workstream/workstream.go @@ -0,0 +1,233 @@ +// Package workstream owns workstream rules and durable state. Adapters supply +// job observations and recorded spend; execution owners stay outside this package. +package workstream + +import ( + "encoding/json" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const ( + DocMax = 1 << 20 + emptyDoc = `{"type":"doc","content":[]}` +) + +var ( + ErrInvalid = workspace.ErrInvalid + ErrNotFound = errors.New("workstream target not found") + ErrConflict = errors.New("workstream conflict") + ErrCycle = errors.New("page move would form a cycle") + ErrTooLarge = errors.New("encoded document exceeds 1 MiB") +) + +type ChangedPart string + +const ( + PartWorkstream ChangedPart = "workstream" + PartPlan ChangedPart = "plan" + PartPages ChangedPart = "pages" + PartRecords ChangedPart = "records" + PartGoAheads ChangedPart = "goaheads" + PartJobs ChangedPart = "jobs" +) + +// Change is empty for an unchanged write. Adapters emit it only after success. +type Change struct { + ID string + Parts []ChangedPart +} + +type VersionConflict struct { + ID string + Version int + UpdatedAt time.Time + UpdatedBy string +} + +func (e *VersionConflict) Error() string { return "the version changed since it was read" } + +type Wake struct{ Morning, RelatedIssues bool } +type Workstream struct { + ID string + Name string + Why string + OriginChatID string + AgentID string + Autonomy string + HowItWent string + ClientID string + Wake Wake + BudgetUSD *float64 + CreatedAt time.Time + UpdatedAt time.Time + StartedAt time.Time + DoneAt time.Time + Stage string + Members []workspace.Ref + Proposed int + Total int +} +type Create struct { + Name string + Why string + OriginChatID string + AgentID string + ClientID string + Refs []workspace.Ref +} +type Patch struct { + Name, Why, Autonomy *string + Wake *Wake + BudgetUSD *float64 +} + +type Job struct { + Factory string + Number int +} + +// JobFacts is absent from the facts map until a job was successfully read. +// Individual facts may remain unknown even when its state is known. +type JobFacts struct { + State string + Merged *bool + CostUSD *float64 +} +type Numbers struct { + Total int + Merged, NeedsYou *int + SpendUSD *float64 +} + +type Source struct { + Kind string `json:"kind"` + ID string `json:"id"` + ChatID string `json:"chat_id,omitempty"` + Factory string `json:"factory,omitempty"` + Label string `json:"label,omitempty"` +} + +// Attribution permits later agent tools to use the same domain operations. +// A zero attribution means the person; proposal authors default to agent. +type Attribution struct { + By string + From *Source +} + +type Milestone struct { + ID string `json:"id"` + Title string `json:"title"` +} +type PlanItem struct { + Kind string `json:"kind"` + Factory string `json:"factory,omitempty"` + Job int `json:"job,omitempty"` + DraftID string `json:"draft_id,omitempty"` + Title string `json:"title,omitempty"` + Repo string `json:"repo,omitempty"` + Text string `json:"text,omitempty"` +} +type Wave struct { + N int `json:"n"` + Title string `json:"title,omitempty"` + MilestoneID string `json:"milestone_id,omitempty"` + AutoStart bool `json:"auto_start"` + Items []PlanItem `json:"items"` +} +type Plan struct { + WorkstreamID string + Version int + UpdatedAt time.Time + UpdatedBy string + Milestones []Milestone + Waves []Wave +} + +type PageNode struct { + ID string + WorkstreamID string + ParentID string + Title string + Owner string + ReadByJobs bool + Position, Version int + UpdatedAt time.Time + UpdatedBy string + UpdatedFrom *Source +} +type Page struct { + PageNode + Doc json.RawMessage + Markdown *string +} +type PageVersion struct { + Version int + SavedAt time.Time + SavedBy string + From *Source + Title string + Bytes int +} +type PageCreate struct { + WorkstreamID string + ParentID string + AfterID string + Title string + Owner string + ReadByJobs bool + Doc json.RawMessage + Attribution +} +type PagePatch struct { + Title, Owner *string + ReadByJobs *bool + Attribution +} + +type Record struct { + ID string + WorkstreamID string + Kind string + Text string + State string + By string + Source *Source + CreatedAt, DecidedAt time.Time + Supersedes, SupersededBy string +} +type RecordPropose struct { + WorkstreamID, Kind, Text string + Source *Source + Kept bool + By string +} +type RecordReplacement struct { + Kind, Text string + Source *Source + By string +} + +type GoAhead struct { + ID string + WorkstreamID string + Scope string + Factory string + MilestoneID string + Job, Wave int + CapUSD float64 + EndsAt time.Time + State, EndReason string + GrantedAt, EndedAt time.Time +} +type GoAheadGrant struct { + WorkstreamID string + Scope string + Factory string + MilestoneID string + Job, Wave int + CapUSD float64 + EndsAt time.Time +} From 7232f12b12aa1a43e3dcc74a8e70152d96643c23 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 01:20:50 -0500 Subject: [PATCH 05/20] tests: workstream methods keep the wire contract's shapes (conformance) (#2288) * tests: the wire contract's workstream examples as engine testdata The examples, field tables, enums and error codes the Workstreams wire contract carries, one file per method and event, with the extractor that regenerates them when the contract changes. Co-Authored-By: Claude Opus 5.5 * tests: workstream answers through the wire keep the contract's shapes Each method's example params go through the real wire handler against a seeded store; each answer must have its example's keys at every level, the field tables' JSON kinds and enum words, no nulls and integral integers. Every engine-sent error code has a call that checks its code and detail keys. A method, event or error code the contract gains without a case fails. Co-Authored-By: Claude Opus 5.5 * docs: change entry for the workstream wire conformance test Co-Authored-By: Claude Opus 5.5 * docs: the conformance change entry names its pull request Co-Authored-By: Claude Opus 5.5 * tests: conformance replies carry every illustrated array element A reply array shorter than its example no longer skips the elements the example illustrates (a plan's draft item, an unattributed page version); page.delete must return exactly the seeded parent and descendant. Request writes to the wire are bounded like replies. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/appworkstreams_conformance_test.go | 1282 +++++++++++++++++ .../testdata/workstreams-wire/README.md | 10 + .../workstreams-wire/collections.add.json | 42 + .../testdata/workstreams-wire/contract.json | 1180 +++++++++++++++ .../testdata/workstreams-wire/extract.py | 103 ++ .../workstreams-wire/goahead.grant.json | 39 + .../workstreams-wire/goahead.list.json | 36 + .../workstreams-wire/goahead.revoke.json | 35 + .../workstreams-wire/page.create.json | 47 + .../workstreams-wire/page.delete.json | 25 + .../testdata/workstreams-wire/page.get.json | 39 + .../workstreams-wire/page.history.json | 45 + .../testdata/workstreams-wire/page.list.json | 35 + .../testdata/workstreams-wire/page.move.json | 55 + .../workstreams-wire/page.restore.json | 41 + .../testdata/workstreams-wire/page.save.json | 56 + .../workstreams-wire/page.update.json | 37 + .../workstreams-wire/record.drop.json | 39 + .../workstreams-wire/record.keep.json | 39 + .../workstreams-wire/record.list.json | 41 + .../workstreams-wire/record.propose.json | 46 + .../workstreams-wire/record.supersede.json | 65 + .../workstreams-wire/workstream.changed.json | 22 + .../workstreams-wire/workstream.create.json | 51 + .../workstreams-wire/workstream.get.json | 69 + .../workstreams-wire/workstream.list.json | 48 + .../workstream.mark_done.json | 47 + .../workstreams-wire/workstream.plan_get.json | 28 + .../workstreams-wire/workstream.plan_set.json | 86 ++ .../workstreams-wire/workstream.reopen.json | 43 + .../workstreams-wire/workstream.update.json | 52 + .../2288-workstreams-wire-conformance.md | 7 + 32 files changed, 3790 insertions(+) create mode 100644 cmd/codeaf/appworkstreams_conformance_test.go create mode 100644 cmd/codeaf/testdata/workstreams-wire/README.md create mode 100644 cmd/codeaf/testdata/workstreams-wire/collections.add.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/contract.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/extract.py create mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.grant.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.list.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.create.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.delete.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.get.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.history.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.list.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.move.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.restore.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.save.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/page.update.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/record.drop.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/record.keep.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/record.list.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/record.propose.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/record.supersede.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.changed.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.create.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.get.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.list.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.update.json create mode 100644 docs/changes/unreleased/2288-workstreams-wire-conformance.md diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go new file mode 100644 index 0000000000..f39b158764 --- /dev/null +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -0,0 +1,1282 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "reflect" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +type workstreamWireContract struct { + Methods []string `json:"methods"` + Events []struct { + Event string `json:"event"` + DataType string `json:"data_type"` + } `json:"events"` + Errors []workstreamWireErrorType `json:"errors"` + Enums map[string][]string `json:"enums"` + Types map[string]map[string]struct { + Type string `json:"type"` + Required bool `json:"required"` + } `json:"types"` +} + +type workstreamWireErrorType struct { + Code string `json:"code"` + DeskLocal bool `json:"desk_local"` + DetailType *string `json:"detail_type"` +} + +type workstreamWireExamples struct { + Method string `json:"method"` + Event string `json:"event"` + ParamsType string `json:"params_type"` + ResultType string `json:"result_type"` + DataType string `json:"data_type"` + Errors []string `json:"errors"` + Examples struct { + Params []json.RawMessage `json:"params"` + Result []json.RawMessage `json:"result"` + Error []json.RawMessage `json:"error"` + Data []json.RawMessage `json:"data"` + } `json:"examples"` +} + +type workstreamWireDocQuestion struct { + Path string + Question string +} + +// Each entry is a difference between the engine and the wire contract that the +// contract must settle (a doc question), tolerated only at its path. Each +// alternate element must still match its own explicit example and pass +// conforms. An entry is stale once its path no longer differs. +var workstreamWireDocQuestions = []workstreamWireDocQuestion{} + +type workstreamWireComparison struct { + Questions map[string]string + Differed map[string]bool +} + +type workstreamWireShapeMode int + +const ( + workstreamWirePreparedShape workstreamWireShapeMode = iota + workstreamWireReplyShape + workstreamWireEventShape +) + +func workstreamWireDecode(t *testing.T, raw []byte) any { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + t.Fatal(err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + t.Fatalf("want one JSON value, got trailing data: %v", err) + } + return value +} + +func workstreamWireEncode(t *testing.T, value any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + +func workstreamWireKind(value any) string { + switch value.(type) { + case nil: + return "null" + case map[string]any: + return "object" + case []any: + return "array" + case string: + return "string" + case bool: + return "boolean" + case json.Number: + return "number" + default: + return fmt.Sprintf("%T", value) + } +} + +func workstreamWireKeys(value map[string]any) []string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + slices.Sort(keys) + return keys +} + +func workstreamWireNoNulls(path string, value any) []string { + var failures []string + switch value := value.(type) { + case nil: + failures = append(failures, path+": null is never allowed; optional fields must be absent") + case map[string]any: + for _, key := range workstreamWireKeys(value) { + failures = append(failures, workstreamWireNoNulls(path+"."+key, value[key])...) + } + case []any: + for i, item := range value { + failures = append(failures, workstreamWireNoNulls(fmt.Sprintf("%s[%d]", path, i), item)...) + } + } + return failures +} + +// conforms is shared by actual wire replies and every declared example type. +// It deliberately uses the independent contract rather than Go wire structs. +func (contract *workstreamWireContract) conforms(path string, value any, typeString string) []string { + if value == nil { + return workstreamWireNoNulls(path, value) + } + want := func() []string { + return []string{fmt.Sprintf("%s: want %s, got %s", path, typeString, workstreamWireKind(value))} + } + if element, array := strings.CutSuffix(typeString, "[]"); array { + items, ok := value.([]any) + if !ok { + return want() + } + var failures []string + for i, item := range items { + failures = append(failures, contract.conforms(fmt.Sprintf("%s[%d]", path, i), item, element)...) + } + return failures + } + if words, enum := contract.Enums[typeString]; enum { + word, ok := value.(string) + if !ok { + return want() + } + if !slices.Contains(words, word) { + return []string{fmt.Sprintf("%s: %q is not in %s %v", path, word, typeString, words)} + } + return nil + } + if fields, object := contract.Types[typeString]; object { + values, ok := value.(map[string]any) + if !ok { + return want() + } + var failures []string + for _, key := range workstreamWireKeys(values) { + field, known := fields[key] + if !known { + failures = append(failures, path+"."+key+": unknown field of "+typeString) + continue + } + failures = append(failures, contract.conforms(path+"."+key, values[key], field.Type)...) + } + var required []string + for key, field := range fields { + if _, present := values[key]; field.Required && !present { + required = append(required, key) + } + } + slices.Sort(required) + for _, key := range required { + failures = append(failures, path+"."+key+": missing required field of "+typeString) + } + return failures + } + switch typeString { + case "string": + if _, ok := value.(string); !ok { + return want() + } + case "string (RFC 3339)": + text, ok := value.(string) + if !ok { + return want() + } + if _, err := time.Parse(time.RFC3339Nano, text); err != nil { + return []string{fmt.Sprintf("%s: invalid RFC 3339 time %q: %v", path, text, err)} + } + case "boolean": + if _, ok := value.(bool); !ok { + return want() + } + case "integer": + number, ok := value.(json.Number) + if !ok { + return want() + } + // Exact arithmetic avoids accepting a rounded fractional number as an integer. + rational, valid := new(big.Rat).SetString(number.String()) + if !valid || !rational.IsInt() || rational.Sign() < 0 { + return []string{fmt.Sprintf("%s: want a non-negative integral JSON number, got %s", path, number)} + } + case "number (USD)": + if _, ok := value.(json.Number); !ok { + return want() + } + case "object": + if _, ok := value.(map[string]any); !ok { + return want() + } + return workstreamWireNoNulls(path, value) + default: + return []string{path + ": unknown contract type " + typeString} + } + return nil +} + +// Prepared params compare arrays by index and length because preparation may +// change only values. Replies require every illustrated index and compare each +// element with the same example index, falling back to the first past its end. +// Events may list fewer parts. An empty example array leaves element validation +// to the declared field type. +func (comparison *workstreamWireComparison) shape(path string, value, example any, mode workstreamWireShapeMode) []string { + if value == nil || example == nil { + return []string{path + ": null is never allowed"} + } + if workstreamWireKind(value) != workstreamWireKind(example) { + return []string{fmt.Sprintf("%s: want example kind %s, got %s", path, workstreamWireKind(example), workstreamWireKind(value))} + } + var failures []string + switch example := example.(type) { + case map[string]any: + actual := value.(map[string]any) + for _, key := range workstreamWireKeys(example) { + item, present := actual[key] + if !present { + failures = append(failures, path+"."+key+": example field is absent") + continue + } + failures = append(failures, comparison.shape(path+"."+key, item, example[key], mode)...) + } + for _, key := range workstreamWireKeys(actual) { + if _, present := example[key]; !present { + failures = append(failures, path+"."+key+": field is absent from example") + } + } + case []any: + actual := value.([]any) + if mode == workstreamWirePreparedShape && len(actual) != len(example) { + failures = append(failures, fmt.Sprintf("%s: preparation changed array length from %d to %d", path, len(example), len(actual))) + } + if mode == workstreamWireReplyShape && len(actual) < len(example) { + failures = append(failures, fmt.Sprintf("%s: want at least %d example elements, got %d", path, len(example), len(actual))) + } + if len(example) == 0 { + return failures + } + if len(actual) == 0 { + return append(failures, path+": expected a populated example array") + } + for i, item := range actual { + // An answer element is compared with the example element at the + // same index (arrays may mix shapes, such as a job and a draft + // plan item); past the example's end, with its first element. + index := 0 + if i < len(example) { + index = i + } + itemPath := fmt.Sprintf("%s[%d]", path, i) + differences := comparison.shape(itemPath, item, example[index], mode) + if _, question := comparison.Questions[itemPath]; mode != workstreamWirePreparedShape && question && i < len(example) && len(differences) > 0 { + comparison.Differed[itemPath] = true + // The question permits only the shape explicitly illustrated for + // this element, so it cannot hide new keys, nulls or wrong kinds. + differences = comparison.shape(itemPath, item, example[i], workstreamWirePreparedShape) + } + failures = append(failures, differences...) + } + } + return failures +} + +func workstreamWireCheck(t *testing.T, failures []string) { + t.Helper() + for _, failure := range failures { + t.Error(failure) + } +} + +func workstreamWireLoad(t *testing.T) (workstreamWireContract, map[string]workstreamWireExamples) { + t.Helper() + root := filepath.Join("testdata", "workstreams-wire") + raw, err := os.ReadFile(filepath.Join(root, "contract.json")) + if err != nil { + t.Fatal(err) + } + var contract workstreamWireContract + if err := json.Unmarshal(raw, &contract); err != nil { + t.Fatal(err) + } + paths, err := filepath.Glob(filepath.Join(root, "*.json")) + if err != nil { + t.Fatal(err) + } + files := make(map[string]workstreamWireExamples) + for _, path := range paths { + name := strings.TrimSuffix(filepath.Base(path), ".json") + if name == "contract" { + continue + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var examples workstreamWireExamples + if err := json.Unmarshal(raw, &examples); err != nil { + t.Fatalf("%s: %v", path, err) + } + files[name] = examples + } + return contract, files +} + +// Warm would open real provider/process owners. The existing harness already +// supplies their fakes; method dispatch and Close still belong to appBackend. +type workstreamWireBackend struct{ *appBackend } + +func (*workstreamWireBackend) Warm() {} + +type workstreamWireUnavailable struct{ *appBackend } + +func (*workstreamWireUnavailable) Warm() {} +func (*workstreamWireUnavailable) ServesWorkstreams() bool { return false } + +const workstreamWireTimeout = 30 * time.Second + +type workstreamWireConnection struct { + In *io.PipeWriter + Lines <-chan []byte + Err <-chan error + Next int + Writes sync.WaitGroup +} + +func workstreamWireConnect(t *testing.T, backend appwire.Engine) *workstreamWireConnection { + t.Helper() + inR, inW := io.Pipe() + outR, outW := io.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + server := appwire.NewServer(outW) + go func() { + err := server.Serve(ctx, inR, appwire.Hello{Version: "test"}, backend) + _ = inR.Close() + _ = outW.Close() + done <- err + }() + lines := make(chan []byte, 32) + readErr := make(chan error, 1) + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + defer close(lines) + scanner := bufio.NewScanner(outR) + scanner.Buffer(make([]byte, 64<<10), 4<<20) + for scanner.Scan() { + select { + case lines <- bytes.Clone(scanner.Bytes()): + case <-ctx.Done(): + return + } + } + readErr <- scanner.Err() + }() + connection := &workstreamWireConnection{In: inW, Lines: lines, Err: readErr} + t.Cleanup(func() { + _ = inW.Close() + connection.Writes.Wait() + select { + case err := <-done: + if err != nil { + t.Errorf("wire Serve: %v", err) + } + case <-time.After(workstreamWireTimeout): + t.Error("wire Serve did not close the backend") + } + cancel() + _ = inR.Close() + _ = outR.Close() + select { + case <-readerDone: + case <-time.After(5 * time.Second): + t.Error("wire reader did not stop") + } + }) + hello := connection.read(t) + if hello["event"] != appwire.EventHello { + t.Fatalf("want hello before replies, got %v", hello) + } + return connection +} + +func (connection *workstreamWireConnection) read(t *testing.T) map[string]any { + t.Helper() + select { + case raw, open := <-connection.Lines: + if !open { + t.Fatalf("wire ended before reply: %v", <-connection.Err) + } + frame, ok := workstreamWireDecode(t, raw).(map[string]any) + if !ok { + t.Fatalf("wire frame is not an object: %s", raw) + } + return frame + case <-time.After(workstreamWireTimeout): + t.Fatal("wire reply timed out") + return nil + } +} + +func (connection *workstreamWireConnection) call(t *testing.T, method string, params any) map[string]any { + t.Helper() + connection.Next++ + raw := workstreamWireEncode(t, map[string]any{"id": connection.Next, "method": method, "params": params}) + writeErr := make(chan error, 1) + connection.Writes.Add(1) + go func() { + defer connection.Writes.Done() + _, err := connection.In.Write(append(raw, '\n')) + writeErr <- err + }() + select { + case err := <-writeErr: + if err != nil { + t.Fatal(err) + } + case <-time.After(workstreamWireTimeout): + t.Fatal("wire request write timed out") + } + for { + frame := connection.read(t) + id, reply := frame["id"].(json.Number) + if !reply { + if _, event := frame["event"].(string); !event { + t.Fatalf("frame has neither a numeric id nor an event: %v", frame) + } + continue + } + if id.String() != strconv.Itoa(connection.Next) { + t.Fatalf("reply id %s, want %d", id, connection.Next) + } + if len(frame) != 2 { + t.Fatalf("reply must have id and one result or error, got keys %v", workstreamWireKeys(frame)) + } + return frame + } +} + +func workstreamWireResult(t *testing.T, frame map[string]any) any { + t.Helper() + result, present := frame["result"] + if !present || result == nil { + t.Fatalf("want result without error, got %v", frame) + } + return result +} + +type workstreamWireFixture struct { + Backend *appBackend + Recorder *appRecorder + IDs map[string]string + Now time.Time + Stream appwire.Workstream +} + +func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWireFixture { + t.Helper() + backend, recorder, _, origin := workstreamBackend(t) + now := time.Now().UTC().Truncate(time.Millisecond) + backend.now = func() time.Time { return now } + backend.workstreams.archiveChats = func([]string) error { return nil } + fixture := &workstreamWireFixture{ + Backend: backend, Recorder: recorder, Now: now, + IDs: map[string]string{"conv_5b9e": origin.ID, "ms_usage": "conformance-milestone", "draft_418c": "conformance-draft"}, + } + if name == "workstream.create" { + // An unobserved member job keeps all optional numbers unknown on create. + return fixture + } + zero := name == "workstream.get" && situation == 1 + unknown := name == "workstream.list" && situation == 1 + params := appwire.WorkstreamCreateParams{Name: "Usage-based billing", Why: workstreamPtr("Charge for recorded usage.")} + if !zero { + params.OriginChatID = &origin.ID + if name != "collections.add" { + ref := workstreamJobRef("1107") + ref.Factory = workstreamPtr("f_local") + params.Refs = []appwire.WorkstreamRef{ref} + } + } + if zero { + params.Why = nil + } + made, err := backend.WorkstreamCreate(params) + if err != nil { + t.Fatal(err) + } + fixture.Stream = made.Workstream + fixture.IDs["ws_8f2c1a"] = made.Workstream.ID + fixture.IDs["conv_8d22"] = made.Workstream.AgentID + if unknown { + backend.workstreams.spend = func(string) *float64 { return nil } + } else { + backend.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{Jobs: []api.Job{{ID: 1107, Num: 2107, State: "needs_you", Cost: 2.5}}}, nil + } + } + if !zero && !unknown && strings.HasPrefix(name, "workstream.") && name != "workstream.changed" { + if _, err := backend.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: made.Workstream.ID, Patch: appwire.WorkstreamPatch{BudgetUSD: workstreamPtr(100.0)}}); err != nil { + t.Fatal(err) + } + } + pages, err := backend.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: made.Workstream.ID}) + if err != nil || len(pages.Pages) != 1 { + t.Fatalf("seed Overview: %+v, %v", pages, err) + } + fixture.IDs["pg_31d0"] = pages.Pages[0].ID + switch name { + case "workstream.reopen": + if _, err := backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: made.Workstream.ID}); err != nil { + t.Fatal(err) + } + case "page.update", "page.delete", "page.move", "workstream.changed": + parent := fixture.IDs["pg_31d0"] + if name == "page.move" { + parent = "" + } + fixture.page(t, "pg_32e1", parent) + if name == "page.move" && situation == 1 { + fixture.page(t, "pg_548c", fixture.IDs["pg_31d0"]) + } + if name == "page.delete" { + fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"]) + } + case "page.history", "page.restore": + fixture.saveByAgent(t) + } + if name == "record.list" || name == "record.keep" || name == "record.drop" || name == "record.supersede" || name == "workstream.changed" { + fixture.record(t) + if name == "record.supersede" { + if _, err := backend.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: fixture.IDs["rec_7a1e"]}); err != nil { + t.Fatal(err) + } + } + } + if strings.HasPrefix(name, "goahead.") || name == "workstream.changed" { + fixture.plan(t) + if name != "goahead.grant" { + fixture.grant(t) + } + } + return fixture +} + +func (fixture *workstreamWireFixture) page(t *testing.T, alias, parent string) { + t.Helper() + params := appwire.WorkstreamPageCreateParams{WorkstreamID: fixture.Stream.ID, Title: "Metering decisions"} + if parent != "" { + params.ParentID = &parent + } + page, err := fixture.Backend.WorkstreamPageCreate(params) + if err != nil { + t.Fatal(err) + } + fixture.IDs[alias] = page.Page.ID +} + +func (fixture *workstreamWireFixture) source() *appwire.WorkstreamSource { + return &appwire.WorkstreamSource{Kind: "message", ID: "msg_19a2", ChatID: workstreamPtr(fixture.IDs["conv_5b9e"]), Label: workstreamPtr("Billing scope")} +} + +func (fixture *workstreamWireFixture) saveByAgent(t *testing.T) { + t.Helper() + store, err := fixture.Backend.workstreamStore(false) + 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 { + t.Fatal(err) + } +} + +func (fixture *workstreamWireFixture) record(t *testing.T) { + t.Helper() + record, err := fixture.Backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{ + WorkstreamID: fixture.Stream.ID, Kind: "question", Text: "Should retries count toward usage?", Source: fixture.source(), + }) + if err != nil { + t.Fatal(err) + } + fixture.IDs["rec_7a1e"] = record.Record.ID +} + +func (fixture *workstreamWireFixture) plan(t *testing.T) { + t.Helper() + _, err := fixture.Backend.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ + ID: fixture.Stream.ID, + Milestones: []appwire.WorkstreamMilestone{{ID: fixture.IDs["ms_usage"], Title: "Recorded usage"}}, + Waves: []appwire.WorkstreamWave{{N: 1, Title: workstreamPtr("Metering"), MilestoneID: workstreamPtr(fixture.IDs["ms_usage"]), Items: []appwire.WorkstreamPlanItem{ + {Kind: "job", Factory: workstreamPtr("f_local"), Job: workstreamPtr(1107)}, + {Kind: "draft", DraftID: workstreamPtr(fixture.IDs["draft_418c"]), Title: workstreamPtr("Expose usage totals"), Repo: workstreamPtr("acme/billing"), Text: workstreamPtr("Show the recorded usage.")}, + }}}, + }) + if err != nil { + t.Fatal(err) + } +} + +func (fixture *workstreamWireFixture) grant(t *testing.T) { + t.Helper() + grant, err := fixture.Backend.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{ + WorkstreamID: fixture.Stream.ID, Scope: "job", Factory: workstreamPtr("f_local"), Job: workstreamPtr(1107), CapUSD: 40, EndsAt: fixture.Now.Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + fixture.IDs["ga_0c44"] = grant.GoAhead.ID +} + +// The decoded example is a fresh tree on every call. Only existing values are +// replaced; prepared shape comparison proves that keys and kinds survive. +func (fixture *workstreamWireFixture) prepare(t *testing.T, raw json.RawMessage) map[string]any { + t.Helper() + var replace func(any) any + replace = func(value any) any { + switch value := value.(type) { + case map[string]any: + for key, item := range value { + if key == "ends_at" { + value[key] = fixture.Now.Add(time.Hour).Format(time.RFC3339Nano) + } else { + value[key] = replace(item) + } + } + case []any: + for i, item := range value { + value[i] = replace(item) + } + case string: + if seeded, known := fixture.IDs[value]; known { + return seeded + } + } + return value + } + return replace(workstreamWireDecode(t, raw)).(map[string]any) +} + +func (contract *workstreamWireContract) errorType(code string) (workstreamWireErrorType, bool) { + for _, definition := range contract.Errors { + if definition.Code == code { + return definition, true + } + } + return workstreamWireErrorType{}, false +} + +func (contract *workstreamWireContract) checkError(t *testing.T, path string, value any, code string, example any, exactMessage bool) { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("%s: want error object, got %s", path, workstreamWireKind(value)) + } + workstreamWireCheck(t, workstreamWireNoNulls(path, object)) + if object["code"] != code { + t.Errorf("%s.code: got %v, want %s", path, object["code"], code) + } + message, ok := object["message"].(string) + if !ok || strings.TrimSpace(message) == "" { + t.Errorf("%s.message: want non-empty string, got %v", path, object["message"]) + } + definition, known := contract.errorType(code) + if !known || definition.DeskLocal { + t.Fatalf("%s: %s is not an engine-sent contract error", path, code) + } + for key := range object { + if key != "code" && key != "message" && key != "detail" { + t.Errorf("%s.%s: unexpected error field", path, key) + } + } + detail, present := object["detail"] + if definition.DetailType == nil { + if present { + t.Errorf("%s.detail: must be absent for %s", path, code) + } + } else if !present { + t.Errorf("%s.detail: missing %s", path, *definition.DetailType) + } else { + workstreamWireCheck(t, contract.conforms(path+".detail", detail, *definition.DetailType)) + } + if example != nil { + comparison := &workstreamWireComparison{} + workstreamWireCheck(t, comparison.shape(path, object, example, workstreamWireReplyShape)) + if exactMessage && object["message"] != example.(map[string]any)["message"] { + t.Errorf("%s.message: got %q, want %q", path, message, example.(map[string]any)["message"]) + } + } +} + +type workstreamWireErrorCase struct { + Name string + Method string + Code string + Example string +} + +// TestAppWorkstreamWireConformance keeps the table independent of the fixtures: +// adding a contract method, event, file or engine-sent error must require a case. +func TestAppWorkstreamWireConformance(t *testing.T) { + contract, files := workstreamWireLoad(t) + cases := []string{ + "workstream.list", "workstream.get", "workstream.create", "workstream.update", + "workstream.mark_done", "workstream.reopen", "workstream.plan_get", "workstream.plan_set", + "page.list", "page.get", "page.save", "page.create", "page.update", "page.move", + "page.delete", "page.history", "page.restore", "record.list", "record.propose", + "record.keep", "record.drop", "record.supersede", "goahead.list", "goahead.grant", + "goahead.revoke", "collections.add", "workstream.changed", + } + errorCases := []workstreamWireErrorCase{ + {Name: "bad_params", Method: "workstream.get", Code: "bad_params"}, + {Name: "not_found", Method: "workstream.get", Code: "not_found", Example: "workstream.get"}, + {Name: "conflict", Method: "workstream.create", Code: "conflict"}, + {Name: "collection_owner", Method: "collections.add", Code: "in_other_workstream", Example: "collections.add"}, + {Name: "create_owner", Method: "workstream.create", Code: "in_other_workstream", Example: "collections.add"}, + {Name: "page_version", Method: "page.save", Code: "version_conflict", Example: "page.save"}, + {Name: "plan_version", Method: "workstream.plan_set", Code: "version_conflict", Example: "page.save"}, + {Name: "cycle", Method: "page.move", Code: "cycle"}, + {Name: "too_large", Method: "page.save", Code: "too_large"}, + {Name: "unknown_method", Method: "workstream.list", Code: "unknown_method", Example: "workstream.list"}, + {Name: "busy", Method: "workstream.update", Code: "busy"}, + } + comparison := &workstreamWireComparison{Questions: make(map[string]string), Differed: make(map[string]bool)} + for _, question := range workstreamWireDocQuestions { + if question.Path == "" || strings.TrimSpace(question.Question) == "" || comparison.Questions[question.Path] != "" { + t.Fatalf("invalid or duplicate doc question: %+v", question) + } + comparison.Questions[question.Path] = question.Question + } + + // (a), (c): coverage is checked in both directions, including auxiliary files + // and all engine-sent errors. No busy exemption is needed: it has a real case. + if !t.Run("coverage", func(t *testing.T) { + workstreamWireCoverage(t, &contract, files, cases, errorCases) + }) { + return + } + + // (b), (d): the same strict type validator checks all documentation examples. + t.Run("shape_rules", func(t *testing.T) { + for _, rule := range []struct { + Name string + Type string + JSON string + Valid bool + }{ + {"string", "string", `"text"`, true}, + {"string_number", "string", `1`, false}, + {"nano_time", "string (RFC 3339)", `"2026-11-02T14:05:00.123456789Z"`, true}, + {"bad_time", "string (RFC 3339)", `"yesterday"`, false}, + {"boolean", "boolean", `false`, true}, + {"boolean_string", "boolean", `"false"`, false}, + {"zero", "integer", `0`, true}, + {"integral_decimal", "integer", `1.0`, true}, + {"fraction", "integer", `1.5`, false}, + {"precise_fraction", "integer", `9007199254740992.1`, false}, + {"negative", "integer", `-1`, false}, + {"numeric_string", "integer", `"1"`, false}, + {"usd", "number (USD)", `2.5`, true}, + {"usd_string", "number (USD)", `"2.5"`, false}, + {"enum", "WorkstreamStage", `"building"`, true}, + {"unknown_enum", "WorkstreamStage", `"working"`, false}, + {"empty_array", "WorkstreamChangedPart[]", `[]`, true}, + {"unknown_array_enum", "WorkstreamChangedPart[]", `["jobs","other"]`, false}, + {"null_array", "WorkstreamChangedPart[]", `null`, false}, + {"optional_absent", "WorkstreamChanged", `{"id":"seeded"}`, true}, + {"optional_null", "WorkstreamChanged", `{"id":"seeded","parts":null}`, false}, + {"missing_required", "WorkstreamChanged", `{"parts":[]}`, false}, + {"unknown_field", "WorkstreamChanged", `{"id":"seeded","extra":true}`, false}, + {"opaque_object", "object", `{"arbitrary":[{"value":0}]}`, true}, + {"opaque_nested_null", "object", `{"arbitrary":[null]}`, false}, + } { + t.Run(rule.Name, func(t *testing.T) { + path := "shape rule " + rule.Name + " " + failures := contract.conforms(path, workstreamWireDecode(t, []byte(rule.JSON)), rule.Type) + if (len(failures) == 0) != rule.Valid { + t.Errorf("valid = %v, want %v: %v", len(failures) == 0, rule.Valid, failures) + } + for _, failure := range failures { + if !strings.HasPrefix(failure, path) { + t.Errorf("failure lost its JSON path: %s", failure) + } + } + }) + } + }) + + // (b), (d): missing reply tails cannot skip illustrated shapes; events may + // report fewer changed parts, and prepared arrays must retain exact lengths. + t.Run("array_coverage", func(t *testing.T) { + example := `[{"job":1},{"draft":"draft"}]` + for _, rule := range []struct { + Name string + JSON string + Example string + Mode workstreamWireShapeMode + Valid bool + }{ + {"same_index", example, example, workstreamWireReplyShape, true}, + {"missing_tail", `[{"job":1}]`, example, workstreamWireReplyShape, false}, + {"empty_reply", `[]`, example, workstreamWireReplyShape, false}, + {"nested_missing_tail", `{"items":[{"job":1}]}`, `{"items":` + example + `}`, workstreamWireReplyShape, false}, + {"additional_first_shape", `[{"job":1},{"draft":"draft"},{"job":2}]`, example, workstreamWireReplyShape, true}, + {"additional_wrong_shape", `[{"job":1},{"draft":"draft"},{"draft":"other"}]`, example, workstreamWireReplyShape, false}, + {"empty_example", `[{"job":1}]`, `[]`, workstreamWireReplyShape, true}, + {"prepared_short", `[{"job":1}]`, example, workstreamWirePreparedShape, false}, + {"prepared_long", `[{"job":1},{"draft":"draft"},{"job":2}]`, example, workstreamWirePreparedShape, false}, + {"event_fewer_parts", `{"parts":["pages"]}`, `{"parts":["pages","plan"]}`, workstreamWireEventShape, true}, + {"event_empty_parts", `{"parts":[]}`, `{"parts":["pages","plan"]}`, workstreamWireEventShape, false}, + } { + t.Run(rule.Name, func(t *testing.T) { + path := "array coverage " + rule.Name + " " + comparison := &workstreamWireComparison{} + failures := comparison.shape(path, workstreamWireDecode(t, []byte(rule.JSON)), workstreamWireDecode(t, []byte(rule.Example)), rule.Mode) + if (len(failures) == 0) != rule.Valid { + t.Errorf("valid = %v, want %v: %v", len(failures) == 0, rule.Valid, failures) + } + for _, failure := range failures { + if !strings.HasPrefix(failure, path) { + t.Errorf("failure lost its JSON path: %s", failure) + } + } + }) + } + }) + + // (b), (d): malformed examples are contract failures, before any engine call. + t.Run("doc_self_check", func(t *testing.T) { + for _, name := range cases { + t.Run(name, func(t *testing.T) { + examples := files[name] + for _, part := range []struct { + Name string + Type string + Rows []json.RawMessage + }{ + {"params", examples.ParamsType, examples.Examples.Params}, + {"result", examples.ResultType, examples.Examples.Result}, + {"data", examples.DataType, examples.Examples.Data}, + } { + for i, raw := range part.Rows { + typeString := part.Type + if name == "collections.add" { + typeString = "object" + } + path := fmt.Sprintf("%s %s[%d] ", name, part.Name, i) + workstreamWireCheck(t, contract.conforms(path, workstreamWireDecode(t, raw), typeString)) + } + } + for i, raw := range examples.Examples.Error { + value := workstreamWireDecode(t, raw) + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("%s error[%d] is not an object", name, i) + } + code, ok := object["code"].(string) + if !ok || (name != "collections.add" && !slices.Contains(examples.Errors, code)) { + t.Fatalf("%s error[%d] has unlisted code %v", name, i, object["code"]) + } + contract.checkError(t, fmt.Sprintf("%s error[%d]", name, i), value, code, nil, false) + } + }) + } + }) + + // (b), (d): each params/result pairing gets an isolated seeded situation. + // A single params example with multiple results exercises every situation. + for _, name := range cases { + t.Run(name, func(t *testing.T) { + examples := files[name] + if examples.Event != "" { + workstreamWireEvent(t, &contract, examples, comparison) + return + } + count := max(len(examples.Examples.Params), len(examples.Examples.Result)) + for i := 0; i < count; i++ { + t.Run(fmt.Sprintf("example_%d", i), func(t *testing.T) { + paramsIndex, resultIndex := i, i + if len(examples.Examples.Params) == 1 { + paramsIndex = 0 + } + if len(examples.Examples.Result) == 1 { + resultIndex = 0 + } + fixture := workstreamWireSeed(t, name, i) + params := fixture.prepare(t, examples.Examples.Params[paramsIndex]) + paramsPath := fmt.Sprintf("%s params[%d] ", name, paramsIndex) + workstreamWireCheck(t, comparison.shape(paramsPath, params, workstreamWireDecode(t, examples.Examples.Params[paramsIndex]), workstreamWirePreparedShape)) + if examples.ParamsType != "" { + workstreamWireCheck(t, contract.conforms(paramsPath, params, examples.ParamsType)) + } + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: fixture.Backend}) + result := workstreamWireResult(t, connection.call(t, name, params)) + resultPath := fmt.Sprintf("%s result[%d] ", name, resultIndex) + if examples.ResultType != "" { + workstreamWireCheck(t, contract.conforms(resultPath, result, examples.ResultType)) + } + workstreamWireCheck(t, workstreamWireNoNulls(resultPath, result)) + workstreamWireCheck(t, comparison.shape(resultPath, result, workstreamWireDecode(t, examples.Examples.Result[resultIndex]), workstreamWireReplyShape)) + if name == "page.delete" { + deleted, ok := result.(map[string]any)["deleted"].([]any) + want := []any{fixture.IDs["pg_32e1"], fixture.IDs["conformance-descendant"]} + if !ok || len(deleted) != len(want) || !slices.Contains(deleted, want[0]) || !slices.Contains(deleted, want[1]) { + t.Errorf("%s.deleted: want exactly seeded parent and descendant %v, got %v", resultPath, want, deleted) + } + } + if name == "collections.add" { + workstreamWireCollectionResult(t, result) + } + }) + } + }) + } + + // (c): every engine-sent code is produced by a real wire call, including + // both ownership doors and both optimistic-version doors. + t.Run("errors", func(t *testing.T) { + for _, errorCase := range errorCases { + t.Run(errorCase.Name, func(t *testing.T) { + workstreamWireError(t, &contract, files, errorCase) + }) + } + }) + + // (d): fixing an example or changing the observed discrepancy requires + // removing its exception; doc questions cannot silently become permanent. + t.Run("doc_questions_are_current", func(t *testing.T) { + for _, question := range workstreamWireDocQuestions { + if !comparison.Differed[question.Path] { + t.Errorf("stale doc question at %s: %s", question.Path, question.Question) + } + } + }) +} + +func workstreamWireCoverage(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, cases []string, errorCases []workstreamWireErrorCase) { + t.Helper() + if !reflect.DeepEqual(contract.Methods, appwire.WorkstreamMethods) { + t.Errorf("contract methods/order %v differ from appwire.WorkstreamMethods %v", contract.Methods, appwire.WorkstreamMethods) + } + counts := make(map[string]int) + for _, name := range cases { + counts[name]++ + if _, exists := files[name]; !exists { + t.Errorf("stale case %s has no example file", name) + } + } + events := make(map[string]string) + for _, event := range contract.Events { + if _, duplicate := events[event.Event]; duplicate { + t.Errorf("duplicate contract event %s", event.Event) + } + events[event.Event] = event.DataType + if _, exists := files[event.Event]; !exists || counts[event.Event] != 1 { + t.Errorf("event %s needs one file and one case", event.Event) + } + } + methods := make(map[string]bool) + for _, name := range contract.Methods { + if methods[name] { + t.Errorf("duplicate contract method %s", name) + } + methods[name] = true + if _, exists := files[name]; !exists || counts[name] != 1 { + t.Errorf("method %s needs one file and one case, got %d cases", name, counts[name]) + } + } + for name, examples := range files { + if counts[name] != 1 { + t.Errorf("file %s.json needs exactly one case, got %d", name, counts[name]) + } + if dataType, event := events[name]; event { + if examples.Event != name || examples.Method != "" || examples.DataType != dataType || len(examples.Examples.Data) == 0 { + t.Errorf("event file %s disagrees with contract or lacks examples", name) + } + continue + } + if !methods[name] && name != "collections.add" { + t.Errorf("file/case %s names no contract method or event", name) + } + if examples.Method != name || examples.Event != "" || len(examples.Examples.Params) == 0 || len(examples.Examples.Result) == 0 { + t.Errorf("method file %s disagrees with its name or lacks examples", name) + } + params, results := len(examples.Examples.Params), len(examples.Examples.Result) + if params != results && params != 1 && results != 1 { + t.Errorf("%s: unpaired example counts %d/%d need explicit situations", name, params, results) + } + if name != "collections.add" { + for _, typeString := range []string{examples.ParamsType, examples.ResultType} { + if _, known := contract.Types[typeString]; !known { + t.Errorf("%s: missing declared type %q", name, typeString) + } + } + } + for _, code := range examples.Errors { + if _, known := contract.errorType(code); !known { + t.Errorf("%s: unlisted error code %s", name, code) + } + } + } + if !slices.Contains(appwire.Methods(), "collections.add") { + t.Error("collections.add is not a registered wire method") + } + coveredErrors := make(map[string]bool) + caseNames := make(map[string]bool) + for _, errorCase := range errorCases { + if caseNames[errorCase.Name] { + t.Errorf("duplicate error case %s", errorCase.Name) + } + caseNames[errorCase.Name] = true + definition, known := contract.errorType(errorCase.Code) + if !known || definition.DeskLocal { + t.Errorf("stale error case %s: code %s is not engine-sent", errorCase.Name, errorCase.Code) + } + if _, exists := files[errorCase.Method]; !exists { + t.Errorf("stale error case %s: missing method file %s", errorCase.Name, errorCase.Method) + } + if errorCase.Method != "collections.add" && !slices.Contains(files[errorCase.Method].Errors, errorCase.Code) { + t.Errorf("%s does not list %s", errorCase.Method, errorCase.Code) + } + if errorCase.Example != "" { + found := false + for _, raw := range files[errorCase.Example].Examples.Error { + if workstreamWireDecode(t, raw).(map[string]any)["code"] == errorCase.Code { + found = true + } + } + if !found { + t.Errorf("stale error example %s/%s", errorCase.Example, errorCase.Code) + } + } + coveredErrors[errorCase.Code] = true + } + listedErrors := make(map[string]bool) + for _, definition := range contract.Errors { + if listedErrors[definition.Code] { + t.Errorf("duplicate contract error %s", definition.Code) + } + listedErrors[definition.Code] = true + if !definition.DeskLocal && !coveredErrors[definition.Code] { + t.Errorf("engine-sent contract error %s has no wire case", definition.Code) + } + } +} + +func workstreamWireCollectionResult(t *testing.T, result any) { + t.Helper() + object := result.(map[string]any) + added, addedArray := object["added"].([]any) + already, alreadyArray := object["already"].([]any) + if !addedArray || len(added) != 1 || !alreadyArray || len(already) != 0 { + t.Fatalf("collections.add result: want one added job and empty already array, got %v", result) + } + for i, value := range added { + member := value.(map[string]any) + if member["kind"] != "factory_job" || member["id"] != "1107" || member["factory"] != "f_local" { + t.Errorf("collections.add .added[%d]: wrong factory job ref %v", i, member) + } + at, ok := member["added_at"].(string) + if _, err := time.Parse(time.RFC3339Nano, at); !ok || err != nil { + t.Errorf("collections.add .added[%d].added_at: want RFC 3339 string, got %v", i, member["added_at"]) + } + } +} + +func workstreamWireEvent(t *testing.T, contract *workstreamWireContract, examples workstreamWireExamples, comparison *workstreamWireComparison) { + t.Helper() + fixture := workstreamWireSeed(t, "workstream.changed", 0) + before := len(workstreamEvents(fixture.Recorder)) + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: fixture.Backend}) + workstreamWireResult(t, connection.call(t, "workstream.update", map[string]any{ + "id": fixture.Stream.ID, "patch": map[string]any{"why": "Updated through the wire"}, + })) + events := workstreamEvents(fixture.Recorder) + if len(events) <= before { + t.Fatal("wire mutation did not emit workstream.changed") + } + for i, event := range events { + value := workstreamWireDecode(t, workstreamWireEncode(t, event)) + path := fmt.Sprintf("workstream.changed data[%d] ", i) + workstreamWireCheck(t, contract.conforms(path, value, examples.DataType)) + exampleIndex := 0 + if _, parts := value.(map[string]any)["parts"]; !parts { + exampleIndex = 1 + } + if exampleIndex >= len(examples.Examples.Data) { + t.Fatalf("%s: no example for parts presence", path) + } + workstreamWireCheck(t, comparison.shape(path, value, workstreamWireDecode(t, examples.Examples.Data[exampleIndex]), workstreamWireEventShape)) + if event.ID != fixture.Stream.ID { + t.Errorf("%s.id: got %s, want seeded workstream %s", path, event.ID, fixture.Stream.ID) + } + } +} + +func workstreamWireError(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, errorCase workstreamWireErrorCase) { + t.Helper() + fixture := workstreamWireSeed(t, "workstream.get", 0) + backend := fixture.Backend + examples := files[errorCase.Method] + params := fixture.prepare(t, examples.Examples.Params[0]) + var engine appwire.Engine = &workstreamWireBackend{appBackend: backend} + release := func() {} + switch errorCase.Name { + case "bad_params": + delete(params, "id") + case "not_found": + params["id"] = "missing-workstream" + case "conflict": + params["name"] = fixture.Stream.Name + case "collection_owner": + other := workstreamCreateForTest(t, backend, "Account migration") + params["id"] = other.ID + case "create_owner": + params["name"] = "Another workstream" + case "page_version": + fixture.saveByAgent(t) + case "plan_version": + fixture.plan(t) + case "cycle": + fixture.page(t, "pg_32e1", fixture.IDs["pg_31d0"]) + fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"]) + params["id"] = fixture.IDs["pg_32e1"] + params["parent_id"] = fixture.IDs["conformance-descendant"] + case "too_large": + params["doc"] = map[string]any{"type": "doc", "content": []any{ + map[string]any{"type": "paragraph", "content": []any{ + map[string]any{"type": "text", "text": strings.Repeat("x", (1<<20)+1)}, + }}, + }} + case "unknown_method": + engine = &workstreamWireUnavailable{appBackend: backend} + case "busy": + release = workstreamWireHoldWriter(t, backend) + default: + t.Fatalf("unimplemented error case %s", errorCase.Name) + } + if errorCase.Name != "bad_params" && errorCase.Name != "too_large" { + comparison := &workstreamWireComparison{} + workstreamWireCheck(t, comparison.shape(errorCase.Method+" error params ", params, workstreamWireDecode(t, examples.Examples.Params[0]), workstreamWirePreparedShape)) + } + connection := workstreamWireConnect(t, engine) + frame := connection.call(t, errorCase.Method, params) + release() + value, present := frame["error"] + if !present { + t.Fatalf("%s: want %s error, got %v", errorCase.Method, errorCase.Code, frame) + } + var example any + if errorCase.Example != "" { + for _, raw := range files[errorCase.Example].Examples.Error { + candidate := workstreamWireDecode(t, raw).(map[string]any) + if candidate["code"] == errorCase.Code { + example = candidate + break + } + } + } + contract.checkError(t, errorCase.Method+" error", value, errorCase.Code, example, errorCase.Code == "unknown_method") + object := value.(map[string]any) + if errorCase.Code == "in_other_workstream" { + detail, ok := object["detail"].(map[string]any) + if !ok || detail["workstream_id"] != fixture.Stream.ID || detail["workstream_name"] != fixture.Stream.Name || detail["factory"] != "f_local" || detail["job"] != json.Number("1107") { + t.Errorf("ownership detail does not identify the seeded owner/job: %v", object["detail"]) + } + } + if errorCase.Code == "version_conflict" { + detail, ok := object["detail"].(map[string]any) + id, version, by := fixture.IDs["pg_31d0"], "2", "agent" + if errorCase.Name == "plan_version" { + id, version, by = fixture.Stream.ID, "1", "you" + } + if !ok || detail["id"] != id || detail["version"] != json.Number(version) || detail["updated_by"] != by { + t.Errorf("stale detail does not describe the current seeded version: %v", object["detail"]) + } + } +} + +// Hold a writer on a separate handle to the same real store. The backend must +// exhaust the normal busy timeout and translate that refusal on the wire. +func workstreamWireHoldWriter(t *testing.T, backend *appBackend) func() { + t.Helper() + other, err := workspace.Open(backend.collectionsPath) + if err != nil { + t.Fatal(err) + } + entered := make(chan struct{}) + unlock := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- other.Write(context.Background(), func(*sql.Tx) error { + close(entered) + <-unlock + return nil + }) + }() + var once sync.Once + release := func() { + once.Do(func() { + close(unlock) + select { + case err := <-done: + if err != nil { + t.Errorf("held writer: %v", err) + } + case <-time.After(30 * time.Second): + t.Error("held writer did not release") + } + if err := other.Close(); err != nil { + t.Errorf("held writer close: %v", err) + } + }) + } + t.Cleanup(release) + select { + case <-entered: + case err := <-done: + // Put the result back so the shared cleanup can still consume it. + done <- err + t.Fatalf("writer did not take the lock: %v", err) + case <-time.After(30 * time.Second): + t.Fatal("writer did not take the lock") + } + return release +} diff --git a/cmd/codeaf/testdata/workstreams-wire/README.md b/cmd/codeaf/testdata/workstreams-wire/README.md new file mode 100644 index 0000000000..29bc64a7b9 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/README.md @@ -0,0 +1,10 @@ +# workstreams-wire + +The examples the desk's contract carries; regenerate when the contract changes: + + python3 -I extract.py . + +`contract.json` holds the method index, the event, the error codes, the enums and every +field table (type, required). Each other file holds one method's or event's examples. +`appworkstreams_conformance_test.go` sends the examples through the wire handler and +compares each answer's shape with them. Values may differ; shapes may not. diff --git a/cmd/codeaf/testdata/workstreams-wire/collections.add.json b/cmd/codeaf/testdata/workstreams-wire/collections.add.json new file mode 100644 index 0000000000..767a4e61ed --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/collections.add.json @@ -0,0 +1,42 @@ +{ + "method": "collections.add", + "examples": { + "params": [ + { + "id": "ws_8f2c1a", + "refs": [ + { + "kind": "factory_job", + "id": "1107", + "factory": "f_local" + } + ] + } + ], + "result": [ + { + "added": [ + { + "kind": "factory_job", + "id": "1107", + "factory": "f_local", + "added_at": "2026-11-02T14:05:00Z" + } + ], + "already": [] + } + ], + "error": [ + { + "code": "in_other_workstream", + "message": "that job belongs to another workstream", + "detail": { + "factory": "f_local", + "job": 1107, + "workstream_id": "ws_92b7", + "workstream_name": "Account migration" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/contract.json b/cmd/codeaf/testdata/workstreams-wire/contract.json new file mode 100644 index 0000000000..df9349aa4f --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/contract.json @@ -0,0 +1,1180 @@ +{ + "methods": [ + "workstream.list", + "workstream.get", + "workstream.create", + "workstream.update", + "workstream.mark_done", + "workstream.reopen", + "workstream.plan_get", + "workstream.plan_set", + "page.list", + "page.get", + "page.save", + "page.create", + "page.update", + "page.move", + "page.delete", + "page.history", + "page.restore", + "record.list", + "record.propose", + "record.keep", + "record.drop", + "record.supersede", + "goahead.list", + "goahead.grant", + "goahead.revoke" + ], + "events": [ + { + "event": "workstream.changed", + "data_type": "WorkstreamChanged" + } + ], + "errors": [ + { + "code": "bad_params", + "desk_local": false, + "detail_type": null + }, + { + "code": "not_found", + "desk_local": false, + "detail_type": null + }, + { + "code": "conflict", + "desk_local": false, + "detail_type": null + }, + { + "code": "in_other_workstream", + "desk_local": false, + "detail_type": "WorkstreamTaken" + }, + { + "code": "version_conflict", + "desk_local": false, + "detail_type": "WorkstreamVersionConflict" + }, + { + "code": "cycle", + "desk_local": false, + "detail_type": null + }, + { + "code": "too_large", + "desk_local": false, + "detail_type": null + }, + { + "code": "busy", + "desk_local": false, + "detail_type": null + }, + { + "code": "unknown_method", + "desk_local": false, + "detail_type": null + }, + { + "code": "unsupported", + "desk_local": true, + "detail_type": null + } + ], + "enums": { + "WorkstreamStage": [ + "shaping", + "building", + "done" + ], + "WorkstreamAutonomy": [ + "propose", + "small", + "off" + ], + "WorkstreamActor": [ + "you", + "agent" + ], + "WorkstreamRefKind": [ + "collection", + "conversation", + "task", + "artifact", + "crew", + "process", + "factory_job" + ], + "WorkstreamSourceKind": [ + "chat", + "message", + "factory_job", + "page", + "url" + ], + "WorkstreamPlanItemKind": [ + "job", + "draft" + ], + "WorkstreamRecordKind": [ + "decision", + "question" + ], + "WorkstreamRecordState": [ + "proposed", + "kept", + "dropped", + "superseded" + ], + "WorkstreamGoAheadScope": [ + "job", + "wave", + "milestone" + ], + "WorkstreamGoAheadState": [ + "active", + "ended" + ], + "WorkstreamGoAheadEndReason": [ + "expired", + "revoked", + "workstream_done" + ], + "WorkstreamChangedPart": [ + "workstream", + "plan", + "pages", + "records", + "goaheads", + "jobs" + ] + }, + "types": { + "WorkstreamWake": { + "morning": { + "type": "boolean", + "required": true + }, + "related_issues": { + "type": "boolean", + "required": true + } + }, + "WorkstreamRef": { + "kind": { + "type": "WorkstreamRefKind", + "required": true + }, + "id": { + "type": "string", + "required": true + }, + "session_id": { + "type": "string", + "required": false + }, + "factory": { + "type": "string", + "required": false + } + }, + "WorkstreamSource": { + "kind": { + "type": "WorkstreamSourceKind", + "required": true + }, + "id": { + "type": "string", + "required": true + }, + "chat_id": { + "type": "string", + "required": false + }, + "factory": { + "type": "string", + "required": false + }, + "label": { + "type": "string", + "required": false + } + }, + "WorkstreamRow": { + "id": { + "type": "string", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "stage": { + "type": "WorkstreamStage", + "required": true + }, + "total": { + "type": "integer", + "required": true + }, + "merged": { + "type": "integer", + "required": false + }, + "needs_you": { + "type": "integer", + "required": false + }, + "spend_usd": { + "type": "number (USD)", + "required": false + }, + "budget_usd": { + "type": "number (USD)", + "required": false + }, + "updated_at": { + "type": "string (RFC 3339)", + "required": true + } + }, + "Workstream": { + "id": { + "type": "string", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "why": { + "type": "string", + "required": true + }, + "stage": { + "type": "WorkstreamStage", + "required": true + }, + "agent_id": { + "type": "string", + "required": true + }, + "origin_chat_id": { + "type": "string", + "required": false + }, + "autonomy": { + "type": "WorkstreamAutonomy", + "required": true + }, + "wake": { + "type": "WorkstreamWake", + "required": true + }, + "budget_usd": { + "type": "number (USD)", + "required": false + }, + "created_at": { + "type": "string (RFC 3339)", + "required": true + }, + "updated_at": { + "type": "string (RFC 3339)", + "required": true + }, + "started_at": { + "type": "string (RFC 3339)", + "required": false + }, + "done_at": { + "type": "string (RFC 3339)", + "required": false + }, + "how_it_went": { + "type": "string", + "required": false + }, + "total": { + "type": "integer", + "required": true + }, + "merged": { + "type": "integer", + "required": false + }, + "needs_you": { + "type": "integer", + "required": false + }, + "spend_usd": { + "type": "number (USD)", + "required": false + } + }, + "WorkstreamPlan": { + "workstream_id": { + "type": "string", + "required": true + }, + "version": { + "type": "integer", + "required": true + }, + "updated_at": { + "type": "string (RFC 3339)", + "required": false + }, + "milestones": { + "type": "WorkstreamMilestone[]", + "required": true + }, + "waves": { + "type": "WorkstreamWave[]", + "required": true + } + }, + "WorkstreamMilestone": { + "id": { + "type": "string", + "required": true + }, + "title": { + "type": "string", + "required": true + } + }, + "WorkstreamWave": { + "n": { + "type": "integer", + "required": true + }, + "title": { + "type": "string", + "required": false + }, + "milestone_id": { + "type": "string", + "required": false + }, + "auto_start": { + "type": "boolean", + "required": true + }, + "items": { + "type": "WorkstreamPlanItem[]", + "required": true + } + }, + "WorkstreamPlanItem": { + "kind": { + "type": "WorkstreamPlanItemKind", + "required": true + }, + "factory": { + "type": "string", + "required": false + }, + "job": { + "type": "integer", + "required": false + }, + "draft_id": { + "type": "string", + "required": false + }, + "title": { + "type": "string", + "required": false + }, + "repo": { + "type": "string", + "required": false + }, + "text": { + "type": "string", + "required": false + } + }, + "WorkstreamPageNode": { + "id": { + "type": "string", + "required": true + }, + "workstream_id": { + "type": "string", + "required": true + }, + "parent_id": { + "type": "string", + "required": false + }, + "title": { + "type": "string", + "required": true + }, + "owner": { + "type": "WorkstreamActor", + "required": true + }, + "read_by_jobs": { + "type": "boolean", + "required": true + }, + "position": { + "type": "integer", + "required": true + }, + "version": { + "type": "integer", + "required": true + }, + "updated_at": { + "type": "string (RFC 3339)", + "required": true + }, + "updated_by": { + "type": "WorkstreamActor", + "required": true + }, + "updated_from": { + "type": "WorkstreamSource", + "required": false + } + }, + "WorkstreamPage": { + "id": { + "type": "string", + "required": true + }, + "workstream_id": { + "type": "string", + "required": true + }, + "parent_id": { + "type": "string", + "required": false + }, + "title": { + "type": "string", + "required": true + }, + "owner": { + "type": "WorkstreamActor", + "required": true + }, + "read_by_jobs": { + "type": "boolean", + "required": true + }, + "position": { + "type": "integer", + "required": true + }, + "version": { + "type": "integer", + "required": true + }, + "updated_at": { + "type": "string (RFC 3339)", + "required": true + }, + "updated_by": { + "type": "WorkstreamActor", + "required": true + }, + "updated_from": { + "type": "WorkstreamSource", + "required": false + }, + "doc": { + "type": "object", + "required": true + }, + "markdown": { + "type": "string", + "required": false + } + }, + "WorkstreamPageVersion": { + "version": { + "type": "integer", + "required": true + }, + "saved_at": { + "type": "string (RFC 3339)", + "required": true + }, + "saved_by": { + "type": "WorkstreamActor", + "required": true + }, + "from": { + "type": "WorkstreamSource", + "required": false + }, + "title": { + "type": "string", + "required": true + }, + "bytes": { + "type": "integer", + "required": true + } + }, + "WorkstreamRecord": { + "id": { + "type": "string", + "required": true + }, + "workstream_id": { + "type": "string", + "required": true + }, + "kind": { + "type": "WorkstreamRecordKind", + "required": true + }, + "text": { + "type": "string", + "required": true + }, + "state": { + "type": "WorkstreamRecordState", + "required": true + }, + "by": { + "type": "WorkstreamActor", + "required": true + }, + "source": { + "type": "WorkstreamSource", + "required": false + }, + "created_at": { + "type": "string (RFC 3339)", + "required": true + }, + "decided_at": { + "type": "string (RFC 3339)", + "required": false + }, + "supersedes": { + "type": "string", + "required": false + }, + "superseded_by": { + "type": "string", + "required": false + } + }, + "WorkstreamGoAhead": { + "id": { + "type": "string", + "required": true + }, + "workstream_id": { + "type": "string", + "required": true + }, + "scope": { + "type": "WorkstreamGoAheadScope", + "required": true + }, + "factory": { + "type": "string", + "required": false + }, + "job": { + "type": "integer", + "required": false + }, + "wave": { + "type": "integer", + "required": false + }, + "milestone_id": { + "type": "string", + "required": false + }, + "cap_usd": { + "type": "number (USD)", + "required": true + }, + "ends_at": { + "type": "string (RFC 3339)", + "required": true + }, + "state": { + "type": "WorkstreamGoAheadState", + "required": true + }, + "end_reason": { + "type": "WorkstreamGoAheadEndReason", + "required": false + }, + "spent_usd": { + "type": "number (USD)", + "required": false + }, + "granted_at": { + "type": "string (RFC 3339)", + "required": true + }, + "ended_at": { + "type": "string (RFC 3339)", + "required": false + } + }, + "WorkstreamChanged": { + "id": { + "type": "string", + "required": true + }, + "parts": { + "type": "WorkstreamChangedPart[]", + "required": false + } + }, + "WorkstreamTaken": { + "factory": { + "type": "string", + "required": true + }, + "job": { + "type": "integer", + "required": true + }, + "workstream_id": { + "type": "string", + "required": true + }, + "workstream_name": { + "type": "string", + "required": true + } + }, + "WorkstreamVersionConflict": { + "id": { + "type": "string", + "required": true + }, + "version": { + "type": "integer", + "required": true + }, + "updated_at": { + "type": "string (RFC 3339)", + "required": true + }, + "updated_by": { + "type": "WorkstreamActor", + "required": true + } + }, + "WorkstreamPatch": { + "name": { + "type": "string", + "required": false + }, + "why": { + "type": "string", + "required": false + }, + "autonomy": { + "type": "WorkstreamAutonomy", + "required": false + }, + "wake": { + "type": "WorkstreamWake", + "required": false + }, + "budget_usd": { + "type": "number (USD)", + "required": false + } + }, + "WorkstreamListParams": {}, + "WorkstreamListResult": { + "workstreams": { + "type": "WorkstreamRow[]", + "required": true + } + }, + "WorkstreamGetParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamGetResult": { + "workstream": { + "type": "Workstream", + "required": true + } + }, + "WorkstreamCreateParams": { + "name": { + "type": "string", + "required": true + }, + "why": { + "type": "string", + "required": false + }, + "origin_chat_id": { + "type": "string", + "required": false + }, + "refs": { + "type": "WorkstreamRef[]", + "required": false + }, + "model": { + "type": "string", + "required": false + }, + "client_id": { + "type": "string", + "required": false + } + }, + "WorkstreamCreateResult": { + "workstream": { + "type": "Workstream", + "required": true + } + }, + "WorkstreamUpdateParams": { + "id": { + "type": "string", + "required": true + }, + "patch": { + "type": "WorkstreamPatch", + "required": true + } + }, + "WorkstreamUpdateResult": { + "workstream": { + "type": "Workstream", + "required": true + } + }, + "WorkstreamMarkDoneParams": { + "id": { + "type": "string", + "required": true + }, + "how_it_went": { + "type": "string", + "required": false + }, + "archive_chats": { + "type": "boolean", + "required": false + } + }, + "WorkstreamMarkDoneResult": { + "workstream": { + "type": "Workstream", + "required": true + } + }, + "WorkstreamReopenParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamReopenResult": { + "workstream": { + "type": "Workstream", + "required": true + } + }, + "WorkstreamPlanGetParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamPlanGetResult": { + "plan": { + "type": "WorkstreamPlan", + "required": true + } + }, + "WorkstreamPlanSetParams": { + "id": { + "type": "string", + "required": true + }, + "base_version": { + "type": "integer", + "required": true + }, + "milestones": { + "type": "WorkstreamMilestone[]", + "required": true + }, + "waves": { + "type": "WorkstreamWave[]", + "required": true + } + }, + "WorkstreamPlanSetResult": { + "plan": { + "type": "WorkstreamPlan", + "required": true + } + }, + "WorkstreamPageListParams": { + "workstream_id": { + "type": "string", + "required": true + } + }, + "WorkstreamPageListResult": { + "pages": { + "type": "WorkstreamPageNode[]", + "required": true + } + }, + "WorkstreamPageGetParams": { + "id": { + "type": "string", + "required": true + }, + "markdown": { + "type": "boolean", + "required": false + } + }, + "WorkstreamPageGetResult": { + "page": { + "type": "WorkstreamPage", + "required": true + } + }, + "WorkstreamPageSaveParams": { + "id": { + "type": "string", + "required": true + }, + "doc": { + "type": "object", + "required": true + }, + "base_version": { + "type": "integer", + "required": true + } + }, + "WorkstreamPageSaveResult": { + "page": { + "type": "WorkstreamPage", + "required": true + } + }, + "WorkstreamPageCreateParams": { + "workstream_id": { + "type": "string", + "required": true + }, + "parent_id": { + "type": "string", + "required": false + }, + "after_id": { + "type": "string", + "required": false + }, + "title": { + "type": "string", + "required": true + }, + "owner": { + "type": "WorkstreamActor", + "required": false + }, + "read_by_jobs": { + "type": "boolean", + "required": false + }, + "doc": { + "type": "object", + "required": false + } + }, + "WorkstreamPageCreateResult": { + "page": { + "type": "WorkstreamPage", + "required": true + } + }, + "WorkstreamPageUpdateParams": { + "id": { + "type": "string", + "required": true + }, + "title": { + "type": "string", + "required": false + }, + "owner": { + "type": "WorkstreamActor", + "required": false + }, + "read_by_jobs": { + "type": "boolean", + "required": false + } + }, + "WorkstreamPageUpdateResult": { + "page": { + "type": "WorkstreamPageNode", + "required": true + } + }, + "WorkstreamPageMoveParams": { + "id": { + "type": "string", + "required": true + }, + "parent_id": { + "type": "string", + "required": false + }, + "after_id": { + "type": "string", + "required": false + } + }, + "WorkstreamPageMoveResult": { + "page": { + "type": "WorkstreamPageNode", + "required": true + } + }, + "WorkstreamPageDeleteParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamPageDeleteResult": { + "deleted": { + "type": "string[]", + "required": true + } + }, + "WorkstreamPageHistoryParams": { + "id": { + "type": "string", + "required": true + }, + "limit": { + "type": "integer", + "required": false + } + }, + "WorkstreamPageHistoryResult": { + "versions": { + "type": "WorkstreamPageVersion[]", + "required": true + } + }, + "WorkstreamPageRestoreParams": { + "id": { + "type": "string", + "required": true + }, + "version": { + "type": "integer", + "required": true + }, + "base_version": { + "type": "integer", + "required": true + } + }, + "WorkstreamPageRestoreResult": { + "page": { + "type": "WorkstreamPage", + "required": true + } + }, + "WorkstreamRecordListParams": { + "workstream_id": { + "type": "string", + "required": true + }, + "kind": { + "type": "WorkstreamRecordKind", + "required": false + }, + "state": { + "type": "WorkstreamRecordState", + "required": false + } + }, + "WorkstreamRecordListResult": { + "records": { + "type": "WorkstreamRecord[]", + "required": true + } + }, + "WorkstreamRecordProposeParams": { + "workstream_id": { + "type": "string", + "required": true + }, + "kind": { + "type": "WorkstreamRecordKind", + "required": true + }, + "text": { + "type": "string", + "required": true + }, + "source": { + "type": "WorkstreamSource", + "required": false + }, + "kept": { + "type": "boolean", + "required": false + } + }, + "WorkstreamRecordProposeResult": { + "record": { + "type": "WorkstreamRecord", + "required": true + } + }, + "WorkstreamRecordKeepParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamRecordKeepResult": { + "record": { + "type": "WorkstreamRecord", + "required": true + } + }, + "WorkstreamRecordDropParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamRecordDropResult": { + "record": { + "type": "WorkstreamRecord", + "required": true + } + }, + "WorkstreamRecordSupersedeParams": { + "id": { + "type": "string", + "required": true + }, + "text": { + "type": "string", + "required": true + }, + "kind": { + "type": "WorkstreamRecordKind", + "required": false + }, + "source": { + "type": "WorkstreamSource", + "required": false + } + }, + "WorkstreamRecordSupersedeResult": { + "record": { + "type": "WorkstreamRecord", + "required": true + }, + "superseded": { + "type": "WorkstreamRecord", + "required": true + } + }, + "WorkstreamGoAheadListParams": { + "workstream_id": { + "type": "string", + "required": true + }, + "ended": { + "type": "boolean", + "required": false + } + }, + "WorkstreamGoAheadListResult": { + "goaheads": { + "type": "WorkstreamGoAhead[]", + "required": true + } + }, + "WorkstreamGoAheadGrantParams": { + "workstream_id": { + "type": "string", + "required": true + }, + "scope": { + "type": "WorkstreamGoAheadScope", + "required": true + }, + "factory": { + "type": "string", + "required": false + }, + "job": { + "type": "integer", + "required": false + }, + "wave": { + "type": "integer", + "required": false + }, + "milestone_id": { + "type": "string", + "required": false + }, + "cap_usd": { + "type": "number (USD)", + "required": true + }, + "ends_at": { + "type": "string (RFC 3339)", + "required": true + } + }, + "WorkstreamGoAheadGrantResult": { + "goahead": { + "type": "WorkstreamGoAhead", + "required": true + } + }, + "WorkstreamGoAheadRevokeParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamGoAheadRevokeResult": { + "goahead": { + "type": "WorkstreamGoAhead", + "required": true + } + } + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/extract.py b/cmd/codeaf/testdata/workstreams-wire/extract.py new file mode 100644 index 0000000000..546b10b286 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/extract.py @@ -0,0 +1,103 @@ +"""Extract wire shapes (types, enums, methods, examples) from the workstreams wire contract. + +usage: python3 -I extract.py +Writes contract.json (index, enums, errors, types) and one .json per method/event +with its examples. No prose is copied. +""" +import json, os, re, sys + +src, out = sys.argv[1], sys.argv[2] +lines = open(src, encoding="utf-8").read().split("\n") +end = next(i for i, l in enumerate(lines) if l.startswith("## Page document format")) +lines = lines[:end] + +TICK = re.compile(r"`([^`]*)`") + +def table_at(i): + """rows of the markdown table starting at or after line i (skip blank lines).""" + while i < len(lines) and not lines[i].startswith("|"): + i += 1 + rows = [] + while i < len(lines) and lines[i].startswith("|"): + cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] + rows.append(cells) + i += 1 + return rows[2:] # drop header + separator + +def fields(rows): + out = {} + for cells in rows: + if cells[0] in ("—", "-"): + continue + name = TICK.fullmatch(cells[0]).group(1) + typ = TICK.fullmatch(cells[1]).group(1) + req = cells[2] + assert req in ("yes", "no"), cells + out[name] = {"type": typ, "required": req == "yes"} + return out + +types, enums, errors, methods, events = {}, {}, [], [], [] +method_info = {} +examples = {} +section = None # H2 +h3 = None +i = 0 +while i < len(lines): + l = lines[i] + if l.startswith("## "): + section, h3 = l[3:].strip(), None + elif l.startswith("### "): + h3 = TICK.search(l).group(1) + if section == "Shared shapes": + types[h3] = fields(table_at(i + 1)) + elif section == "Methods": + methods.append(h3) + method_info[h3] = {"method": h3, "errors": []} + elif section == "Shared shapes" and l.startswith("| type | words |"): + for cells in table_at(i): + enums[TICK.fullmatch(cells[0]).group(1)] = TICK.findall(cells[1]) + elif section == "Method index" and l.startswith("| event | data |"): + for cells in table_at(i): + events.append({"event": TICK.fullmatch(cells[0]).group(1), "data_type": TICK.fullmatch(cells[1]).group(1)}) + elif section == "Method index" and l.startswith("| method | params | result |"): + index = [TICK.fullmatch(c[0]).group(1) for c in table_at(i)] + elif section == "Error codes" and l.startswith("| code |"): + for cells in table_at(i): + m = re.match(r"`(\w+)`( \(desk-local\))?", cells[0]) + det = re.match(r"`(Workstream\w+)`", cells[2]) + errors.append({"code": m.group(1), "desk_local": bool(m.group(2)), "detail_type": det.group(1) if det else None}) + m = re.match(r"^(Params|Result) \(`(\w+)`\):", l) + if m and section == "Methods": + types[m.group(2)] = fields(table_at(i + 1)) + method_info[h3][m.group(1).lower() + "_type"] = m.group(2) + if l.startswith("Errors:") and section == "Methods": + method_info[h3]["errors"] = TICK.findall(l) + m = re.match(r"^```json (\S+) (params|result|error|data)$", l) + if m: + j = i + 1 + while lines[j] != "```": + j += 1 + body = json.loads("\n".join(lines[i + 1:j])) + examples.setdefault(m.group(1), {}).setdefault(m.group(2), []).append(body) + i = j + i += 1 + +assert index == methods, (index, methods) +os.makedirs(out, exist_ok=True) +contract = {"methods": methods, "events": events, "errors": errors, "enums": enums, "types": types} +def dump(path, v): + with open(os.path.join(out, path), "w", encoding="utf-8") as f: + json.dump(v, f, indent=2, ensure_ascii=False) + f.write("\n") +dump("contract.json", contract) +for name in methods: + info = dict(method_info[name]) + info["examples"] = examples.pop(name) + dump(name + ".json", info) +for ev in events: + dump(ev["event"] + ".json", {"event": ev["event"], "data_type": ev["data_type"], "examples": examples.pop(ev["event"])}) +# collection examples the doc carries for the factory_job kind +for name, ex in sorted(examples.items()): + dump(name + ".json", {"method": name, "examples": ex}) + print("extra:", name, sorted(ex)) +print(len(methods), "methods;", len(types), "types;", len(enums), "enums;", len(errors), "error codes") diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json b/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json new file mode 100644 index 0000000000..08fd83ecc5 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json @@ -0,0 +1,39 @@ +{ + "method": "goahead.grant", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamGoAheadGrantParams", + "result_type": "WorkstreamGoAheadGrantResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a", + "scope": "job", + "factory": "f_local", + "job": 1107, + "cap_usd": 40.0, + "ends_at": "2026-11-03T14:05:00Z" + } + ], + "result": [ + { + "goahead": { + "id": "ga_0c44", + "workstream_id": "ws_8f2c1a", + "scope": "job", + "factory": "f_local", + "job": 1107, + "cap_usd": 40.0, + "ends_at": "2026-11-03T14:05:00Z", + "state": "active", + "granted_at": "2026-11-02T14:05:00Z" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.list.json b/cmd/codeaf/testdata/workstreams-wire/goahead.list.json new file mode 100644 index 0000000000..7a6fe79d3c --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/goahead.list.json @@ -0,0 +1,36 @@ +{ + "method": "goahead.list", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamGoAheadListParams", + "result_type": "WorkstreamGoAheadListResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a", + "ended": true + } + ], + "result": [ + { + "goaheads": [ + { + "id": "ga_0c44", + "workstream_id": "ws_8f2c1a", + "scope": "job", + "factory": "f_local", + "job": 1107, + "cap_usd": 40.0, + "ends_at": "2026-11-03T14:05:00Z", + "state": "active", + "granted_at": "2026-11-02T14:05:00Z" + } + ] + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json b/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json new file mode 100644 index 0000000000..db4c6ac2b4 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json @@ -0,0 +1,35 @@ +{ + "method": "goahead.revoke", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamGoAheadRevokeParams", + "result_type": "WorkstreamGoAheadRevokeResult", + "examples": { + "params": [ + { + "id": "ga_0c44" + } + ], + "result": [ + { + "goahead": { + "id": "ga_0c44", + "workstream_id": "ws_8f2c1a", + "scope": "job", + "factory": "f_local", + "job": 1107, + "cap_usd": 40.0, + "ends_at": "2026-11-03T14:05:00Z", + "state": "ended", + "granted_at": "2026-11-02T14:05:00Z", + "end_reason": "revoked", + "ended_at": "2026-11-02T15:00:00Z" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.create.json b/cmd/codeaf/testdata/workstreams-wire/page.create.json new file mode 100644 index 0000000000..10deddafb8 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.create.json @@ -0,0 +1,47 @@ +{ + "method": "page.create", + "errors": [ + "bad_params", + "not_found", + "too_large", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageCreateParams", + "result_type": "WorkstreamPageCreateResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a", + "parent_id": "pg_31d0", + "title": "Metering decisions", + "owner": "you", + "read_by_jobs": false, + "doc": { + "type": "doc", + "content": [] + } + } + ], + "result": [ + { + "page": { + "id": "pg_32e1", + "workstream_id": "ws_8f2c1a", + "title": "Metering decisions", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 1, + "updated_at": "2026-11-02T14:05:00Z", + "updated_by": "you", + "parent_id": "pg_31d0", + "doc": { + "type": "doc", + "content": [] + } + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.delete.json b/cmd/codeaf/testdata/workstreams-wire/page.delete.json new file mode 100644 index 0000000000..f13834bec0 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.delete.json @@ -0,0 +1,25 @@ +{ + "method": "page.delete", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageDeleteParams", + "result_type": "WorkstreamPageDeleteResult", + "examples": { + "params": [ + { + "id": "pg_32e1" + } + ], + "result": [ + { + "deleted": [ + "pg_32e1" + ] + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.get.json b/cmd/codeaf/testdata/workstreams-wire/page.get.json new file mode 100644 index 0000000000..fdcf420091 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.get.json @@ -0,0 +1,39 @@ +{ + "method": "page.get", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageGetParams", + "result_type": "WorkstreamPageGetResult", + "examples": { + "params": [ + { + "id": "pg_31d0", + "markdown": true + } + ], + "result": [ + { + "page": { + "id": "pg_31d0", + "workstream_id": "ws_8f2c1a", + "title": "Overview", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 1, + "updated_at": "2026-11-02T14:05:00Z", + "updated_by": "you", + "doc": { + "type": "doc", + "content": [] + }, + "markdown": "" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.history.json b/cmd/codeaf/testdata/workstreams-wire/page.history.json new file mode 100644 index 0000000000..ea5e6a6ce4 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.history.json @@ -0,0 +1,45 @@ +{ + "method": "page.history", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageHistoryParams", + "result_type": "WorkstreamPageHistoryResult", + "examples": { + "params": [ + { + "id": "pg_31d0", + "limit": 50 + } + ], + "result": [ + { + "versions": [ + { + "version": 2, + "saved_at": "2026-11-02T15:00:00Z", + "saved_by": "agent", + "from": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "title": "Overview", + "bytes": 27 + }, + { + "version": 1, + "saved_at": "2026-11-02T14:05:00Z", + "saved_by": "you", + "title": "Overview", + "bytes": 27 + } + ] + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.list.json b/cmd/codeaf/testdata/workstreams-wire/page.list.json new file mode 100644 index 0000000000..37550b606a --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.list.json @@ -0,0 +1,35 @@ +{ + "method": "page.list", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageListParams", + "result_type": "WorkstreamPageListResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a" + } + ], + "result": [ + { + "pages": [ + { + "id": "pg_31d0", + "workstream_id": "ws_8f2c1a", + "title": "Overview", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 1, + "updated_at": "2026-11-02T14:05:00Z", + "updated_by": "you" + } + ] + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.move.json b/cmd/codeaf/testdata/workstreams-wire/page.move.json new file mode 100644 index 0000000000..2079f5087e --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.move.json @@ -0,0 +1,55 @@ +{ + "method": "page.move", + "errors": [ + "bad_params", + "not_found", + "cycle", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageMoveParams", + "result_type": "WorkstreamPageMoveResult", + "examples": { + "params": [ + { + "id": "pg_32e1", + "parent_id": "pg_31d0" + }, + { + "id": "pg_32e1", + "parent_id": "pg_31d0", + "after_id": "pg_548c" + } + ], + "result": [ + { + "page": { + "id": "pg_32e1", + "workstream_id": "ws_8f2c1a", + "title": "Metering decisions", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 1, + "updated_at": "2026-11-02T14:05:00Z", + "updated_by": "you", + "parent_id": "pg_31d0" + } + }, + { + "page": { + "id": "pg_32e1", + "workstream_id": "ws_8f2c1a", + "title": "Metering decisions", + "owner": "you", + "read_by_jobs": false, + "position": 1, + "version": 1, + "updated_at": "2026-11-02T14:05:00Z", + "updated_by": "you", + "parent_id": "pg_31d0" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.restore.json b/cmd/codeaf/testdata/workstreams-wire/page.restore.json new file mode 100644 index 0000000000..9cdf549c70 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.restore.json @@ -0,0 +1,41 @@ +{ + "method": "page.restore", + "errors": [ + "bad_params", + "not_found", + "version_conflict", + "too_large", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageRestoreParams", + "result_type": "WorkstreamPageRestoreResult", + "examples": { + "params": [ + { + "id": "pg_31d0", + "version": 1, + "base_version": 2 + } + ], + "result": [ + { + "page": { + "id": "pg_31d0", + "workstream_id": "ws_8f2c1a", + "title": "Overview", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 3, + "updated_at": "2026-11-02T15:00:00Z", + "updated_by": "you", + "doc": { + "type": "doc", + "content": [] + } + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.save.json b/cmd/codeaf/testdata/workstreams-wire/page.save.json new file mode 100644 index 0000000000..914a68b84e --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.save.json @@ -0,0 +1,56 @@ +{ + "method": "page.save", + "errors": [ + "bad_params", + "not_found", + "version_conflict", + "too_large", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageSaveParams", + "result_type": "WorkstreamPageSaveResult", + "examples": { + "params": [ + { + "id": "pg_31d0", + "doc": { + "type": "doc", + "content": [] + }, + "base_version": 1 + } + ], + "result": [ + { + "page": { + "id": "pg_31d0", + "workstream_id": "ws_8f2c1a", + "title": "Overview", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 2, + "updated_at": "2026-11-02T15:00:00Z", + "updated_by": "you", + "doc": { + "type": "doc", + "content": [] + } + } + } + ], + "error": [ + { + "code": "version_conflict", + "message": "the page changed since it was read", + "detail": { + "id": "pg_31d0", + "version": 2, + "updated_at": "2026-11-02T15:00:00Z", + "updated_by": "agent" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.update.json b/cmd/codeaf/testdata/workstreams-wire/page.update.json new file mode 100644 index 0000000000..1a63b0e925 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/page.update.json @@ -0,0 +1,37 @@ +{ + "method": "page.update", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPageUpdateParams", + "result_type": "WorkstreamPageUpdateResult", + "examples": { + "params": [ + { + "id": "pg_32e1", + "title": "Metering decisions", + "owner": "agent", + "read_by_jobs": true + } + ], + "result": [ + { + "page": { + "id": "pg_32e1", + "workstream_id": "ws_8f2c1a", + "title": "Metering decisions", + "owner": "agent", + "read_by_jobs": true, + "position": 0, + "version": 1, + "updated_at": "2026-11-02T15:00:00Z", + "updated_by": "you", + "parent_id": "pg_31d0" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.drop.json b/cmd/codeaf/testdata/workstreams-wire/record.drop.json new file mode 100644 index 0000000000..4af5590af0 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/record.drop.json @@ -0,0 +1,39 @@ +{ + "method": "record.drop", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamRecordDropParams", + "result_type": "WorkstreamRecordDropResult", + "examples": { + "params": [ + { + "id": "rec_7a1e" + } + ], + "result": [ + { + "record": { + "id": "rec_7a1e", + "workstream_id": "ws_8f2c1a", + "kind": "question", + "text": "Should retries count toward usage?", + "state": "dropped", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T14:05:00Z", + "decided_at": "2026-11-02T15:00:00Z" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.keep.json b/cmd/codeaf/testdata/workstreams-wire/record.keep.json new file mode 100644 index 0000000000..faffcccb6b --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/record.keep.json @@ -0,0 +1,39 @@ +{ + "method": "record.keep", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamRecordKeepParams", + "result_type": "WorkstreamRecordKeepResult", + "examples": { + "params": [ + { + "id": "rec_7a1e" + } + ], + "result": [ + { + "record": { + "id": "rec_7a1e", + "workstream_id": "ws_8f2c1a", + "kind": "question", + "text": "Should retries count toward usage?", + "state": "kept", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T14:05:00Z", + "decided_at": "2026-11-02T15:00:00Z" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.list.json b/cmd/codeaf/testdata/workstreams-wire/record.list.json new file mode 100644 index 0000000000..06b669638b --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/record.list.json @@ -0,0 +1,41 @@ +{ + "method": "record.list", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamRecordListParams", + "result_type": "WorkstreamRecordListResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a", + "kind": "question", + "state": "proposed" + } + ], + "result": [ + { + "records": [ + { + "id": "rec_7a1e", + "workstream_id": "ws_8f2c1a", + "kind": "question", + "text": "Should retries count toward usage?", + "state": "proposed", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T14:05:00Z" + } + ] + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.propose.json b/cmd/codeaf/testdata/workstreams-wire/record.propose.json new file mode 100644 index 0000000000..1ef07f72ea --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/record.propose.json @@ -0,0 +1,46 @@ +{ + "method": "record.propose", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamRecordProposeParams", + "result_type": "WorkstreamRecordProposeResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a", + "kind": "question", + "text": "Should retries count toward usage?", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "kept": false + } + ], + "result": [ + { + "record": { + "id": "rec_7a1e", + "workstream_id": "ws_8f2c1a", + "kind": "question", + "text": "Should retries count toward usage?", + "state": "proposed", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T14:05:00Z" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.supersede.json b/cmd/codeaf/testdata/workstreams-wire/record.supersede.json new file mode 100644 index 0000000000..cbc5623e64 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/record.supersede.json @@ -0,0 +1,65 @@ +{ + "method": "record.supersede", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamRecordSupersedeParams", + "result_type": "WorkstreamRecordSupersedeResult", + "examples": { + "params": [ + { + "id": "rec_7a1e", + "text": "Retries do not count toward usage.", + "kind": "decision", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + } + } + ], + "result": [ + { + "record": { + "id": "rec_91bf", + "workstream_id": "ws_8f2c1a", + "kind": "decision", + "text": "Retries do not count toward usage.", + "state": "kept", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T15:00:00Z", + "decided_at": "2026-11-02T15:00:00Z", + "supersedes": "rec_7a1e" + }, + "superseded": { + "id": "rec_7a1e", + "workstream_id": "ws_8f2c1a", + "kind": "question", + "text": "Should retries count toward usage?", + "state": "superseded", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T14:05:00Z", + "decided_at": "2026-11-02T15:00:00Z", + "superseded_by": "rec_91bf" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json b/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json new file mode 100644 index 0000000000..663445fa90 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json @@ -0,0 +1,22 @@ +{ + "event": "workstream.changed", + "data_type": "WorkstreamChanged", + "examples": { + "data": [ + { + "id": "ws_8f2c1a", + "parts": [ + "workstream", + "plan", + "pages", + "records", + "goaheads", + "jobs" + ] + }, + { + "id": "ws_8f2c1a" + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.create.json b/cmd/codeaf/testdata/workstreams-wire/workstream.create.json new file mode 100644 index 0000000000..8b400ede0c --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.create.json @@ -0,0 +1,51 @@ +{ + "method": "workstream.create", + "errors": [ + "bad_params", + "not_found", + "conflict", + "in_other_workstream", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamCreateParams", + "result_type": "WorkstreamCreateResult", + "examples": { + "params": [ + { + "name": "Usage-based billing", + "why": "Charge for recorded usage.", + "origin_chat_id": "conv_5b9e", + "refs": [ + { + "kind": "factory_job", + "id": "1107", + "factory": "f_local" + } + ], + "model": "openai/gpt-5.2", + "client_id": "create_684a" + } + ], + "result": [ + { + "workstream": { + "id": "ws_8f2c1a", + "name": "Usage-based billing", + "why": "Charge for recorded usage.", + "stage": "shaping", + "agent_id": "conv_8d22", + "origin_chat_id": "conv_5b9e", + "autonomy": "propose", + "wake": { + "morning": true, + "related_issues": false + }, + "created_at": "2026-11-02T14:05:00Z", + "updated_at": "2026-11-02T14:05:00Z", + "total": 1 + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.get.json b/cmd/codeaf/testdata/workstreams-wire/workstream.get.json new file mode 100644 index 0000000000..cc8f857e22 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.get.json @@ -0,0 +1,69 @@ +{ + "method": "workstream.get", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamGetParams", + "result_type": "WorkstreamGetResult", + "examples": { + "params": [ + { + "id": "ws_8f2c1a" + } + ], + "result": [ + { + "workstream": { + "id": "ws_8f2c1a", + "name": "Usage-based billing", + "why": "Charge for recorded usage.", + "stage": "building", + "agent_id": "conv_8d22", + "origin_chat_id": "conv_5b9e", + "autonomy": "propose", + "wake": { + "morning": true, + "related_issues": false + }, + "budget_usd": 100.0, + "created_at": "2026-11-02T13:00:00Z", + "updated_at": "2026-11-02T14:05:00Z", + "started_at": "2026-11-02T14:05:00Z", + "total": 1, + "merged": 0, + "needs_you": 1, + "spend_usd": 2.5 + } + }, + { + "workstream": { + "id": "ws_8f2c1a", + "name": "Usage-based billing", + "why": "", + "stage": "shaping", + "agent_id": "conv_8d22", + "autonomy": "propose", + "wake": { + "morning": false, + "related_issues": false + }, + "created_at": "2026-11-02T14:05:00Z", + "updated_at": "2026-11-02T14:05:00Z", + "total": 0, + "merged": 0, + "needs_you": 0, + "spend_usd": 0.0 + } + } + ], + "error": [ + { + "code": "not_found", + "message": "workstream not found" + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.list.json b/cmd/codeaf/testdata/workstreams-wire/workstream.list.json new file mode 100644 index 0000000000..3a499f0c95 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.list.json @@ -0,0 +1,48 @@ +{ + "method": "workstream.list", + "errors": [ + "busy", + "unknown_method" + ], + "params_type": "WorkstreamListParams", + "result_type": "WorkstreamListResult", + "examples": { + "params": [ + {} + ], + "result": [ + { + "workstreams": [ + { + "id": "ws_8f2c1a", + "name": "Usage-based billing", + "stage": "building", + "total": 1, + "merged": 0, + "needs_you": 1, + "spend_usd": 2.5, + "budget_usd": 100.0, + "updated_at": "2026-11-02T14:05:00Z" + } + ] + }, + { + "workstreams": [ + { + "id": "ws_8f2c1a", + "name": "Usage-based billing", + "stage": "shaping", + "total": 1, + "updated_at": "2026-11-02T14:05:00Z" + } + ] + } + ], + "error": [ + { + "code": "unknown_method", + "message": "workstreams: not implemented yet" + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json b/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json new file mode 100644 index 0000000000..bae070bfbf --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json @@ -0,0 +1,47 @@ +{ + "method": "workstream.mark_done", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamMarkDoneParams", + "result_type": "WorkstreamMarkDoneResult", + "examples": { + "params": [ + { + "id": "ws_8f2c1a", + "how_it_went": "Metering shipped.", + "archive_chats": true + } + ], + "result": [ + { + "workstream": { + "id": "ws_8f2c1a", + "name": "Usage-based billing", + "why": "Charge for recorded usage.", + "stage": "done", + "agent_id": "conv_8d22", + "origin_chat_id": "conv_5b9e", + "autonomy": "propose", + "wake": { + "morning": true, + "related_issues": false + }, + "budget_usd": 100.0, + "created_at": "2026-11-02T13:00:00Z", + "updated_at": "2026-11-02T15:00:00Z", + "started_at": "2026-11-02T14:05:00Z", + "total": 1, + "merged": 0, + "needs_you": 1, + "spend_usd": 2.5, + "done_at": "2026-11-02T15:00:00Z", + "how_it_went": "Metering shipped." + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json new file mode 100644 index 0000000000..b5e457560e --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json @@ -0,0 +1,28 @@ +{ + "method": "workstream.plan_get", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPlanGetParams", + "result_type": "WorkstreamPlanGetResult", + "examples": { + "params": [ + { + "id": "ws_8f2c1a" + } + ], + "result": [ + { + "plan": { + "workstream_id": "ws_8f2c1a", + "version": 0, + "milestones": [], + "waves": [] + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json new file mode 100644 index 0000000000..ef9aacd6bf --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json @@ -0,0 +1,86 @@ +{ + "method": "workstream.plan_set", + "errors": [ + "bad_params", + "not_found", + "conflict", + "version_conflict", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamPlanSetParams", + "result_type": "WorkstreamPlanSetResult", + "examples": { + "params": [ + { + "id": "ws_8f2c1a", + "base_version": 0, + "milestones": [ + { + "id": "ms_usage", + "title": "Recorded usage" + } + ], + "waves": [ + { + "n": 1, + "title": "Metering", + "milestone_id": "ms_usage", + "auto_start": false, + "items": [ + { + "kind": "job", + "factory": "f_local", + "job": 1107 + }, + { + "kind": "draft", + "draft_id": "draft_418c", + "title": "Expose usage totals", + "repo": "acme/billing", + "text": "Show the recorded usage." + } + ] + } + ] + } + ], + "result": [ + { + "plan": { + "workstream_id": "ws_8f2c1a", + "version": 1, + "updated_at": "2026-11-02T14:05:00Z", + "milestones": [ + { + "id": "ms_usage", + "title": "Recorded usage" + } + ], + "waves": [ + { + "n": 1, + "title": "Metering", + "milestone_id": "ms_usage", + "auto_start": false, + "items": [ + { + "kind": "job", + "factory": "f_local", + "job": 1107 + }, + { + "kind": "draft", + "draft_id": "draft_418c", + "title": "Expose usage totals", + "repo": "acme/billing", + "text": "Show the recorded usage." + } + ] + } + ] + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json b/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json new file mode 100644 index 0000000000..8ad474a157 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json @@ -0,0 +1,43 @@ +{ + "method": "workstream.reopen", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamReopenParams", + "result_type": "WorkstreamReopenResult", + "examples": { + "params": [ + { + "id": "ws_8f2c1a" + } + ], + "result": [ + { + "workstream": { + "id": "ws_8f2c1a", + "name": "Usage-based billing", + "why": "Charge for recorded usage.", + "stage": "building", + "agent_id": "conv_8d22", + "origin_chat_id": "conv_5b9e", + "autonomy": "propose", + "wake": { + "morning": true, + "related_issues": false + }, + "budget_usd": 100.0, + "created_at": "2026-11-02T13:00:00Z", + "updated_at": "2026-11-02T15:00:00Z", + "started_at": "2026-11-02T14:05:00Z", + "total": 1, + "merged": 0, + "needs_you": 1, + "spend_usd": 2.5 + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.update.json b/cmd/codeaf/testdata/workstreams-wire/workstream.update.json new file mode 100644 index 0000000000..b9543ee5a1 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.update.json @@ -0,0 +1,52 @@ +{ + "method": "workstream.update", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamUpdateParams", + "result_type": "WorkstreamUpdateResult", + "examples": { + "params": [ + { + "id": "ws_8f2c1a", + "patch": { + "why": "", + "autonomy": "small", + "wake": { + "morning": false, + "related_issues": true + }, + "budget_usd": 0.0 + } + } + ], + "result": [ + { + "workstream": { + "id": "ws_8f2c1a", + "name": "Usage-based billing", + "why": "", + "stage": "building", + "agent_id": "conv_8d22", + "origin_chat_id": "conv_5b9e", + "autonomy": "small", + "wake": { + "morning": false, + "related_issues": true + }, + "created_at": "2026-11-02T13:00:00Z", + "updated_at": "2026-11-02T14:05:00Z", + "started_at": "2026-11-02T14:05:00Z", + "total": 1, + "merged": 0, + "needs_you": 1, + "spend_usd": 2.5 + } + } + ] + } +} diff --git a/docs/changes/unreleased/2288-workstreams-wire-conformance.md b/docs/changes/unreleased/2288-workstreams-wire-conformance.md new file mode 100644 index 0000000000..1aad7e6fc0 --- /dev/null +++ b/docs/changes/unreleased/2288-workstreams-wire-conformance.md @@ -0,0 +1,7 @@ +--- +kind: internal +title: Workstream methods are checked against the wire contract's examples +pr: 2288 +surface: [app] +invalidates: [] +--- From dd749e133410d524cdecf460ae9a94f6fc1102d4 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 02:50:29 -0500 Subject: [PATCH 06/20] app: workstream pages read by version, restorable delete, Spec/Decisions read by jobs (#2290) * workstream: deleted pages are kept and restorable; pages read by version Schema 3 (unreleased) gains deleted_at and the delete that removed each page. Delete marks a subtree; restore brings back what that delete removed, placed last under a live parent or at the root. A page reads any saved version. Read-by-jobs defaults on for pages titled Spec or Decisions at creation. Co-Authored-By: Claude Opus 5.5 * app: page.get by version, page.list include_deleted, page.restore of a deleted page The wire handlers serve the wire contract's v0.1 page additions and apply the Spec/Decisions read-by-jobs default when page.create leaves it unset. Co-Authored-By: Claude Opus 5.5 * tests: conformance covers the v0.1 page params and example pairs Co-Authored-By: Claude Opus 5.5 * docs: change entry for workstream page versions and restorable delete Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 35 ++- cmd/codeaf/appworkstreams_conformance_test.go | 67 +++- cmd/codeaf/appworkstreams_pages_test.go | 192 ++++++++++++ cmd/codeaf/appworkstreams_test.go | 4 +- .../testdata/workstreams-wire/contract.json | 24 +- .../testdata/workstreams-wire/page.get.json | 21 ++ .../testdata/workstreams-wire/page.list.json | 34 ++ .../workstreams-wire/page.restore.json | 21 ++ ...90-app-workstream-page-versions-restore.md | 24 ++ internal/appwire/workstreams.go | 13 +- internal/appwire/workstreams_check.go | 22 +- internal/appwire/workstreams_check_test.go | 10 + internal/workspace/schema3_test.go | 36 +++ internal/workspace/workstreams.go | 5 +- internal/workstream/pages.go | 206 ++++++++++-- internal/workstream/pages_test.go | 292 +++++++++++++++++- internal/workstream/workstream.go | 1 + 17 files changed, 952 insertions(+), 55 deletions(-) create mode 100644 cmd/codeaf/appworkstreams_pages_test.go create mode 100644 docs/changes/unreleased/2290-app-workstream-page-versions-restore.md diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 138e9753de..4be96ab2d6 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -480,11 +480,11 @@ func workstreamSource(p *appwire.WorkstreamSource) *workstream.Source { return &workstream.Source{Kind: p.Kind, ID: p.ID, ChatID: workstreamValue(p.ChatID), Factory: workstreamValue(p.Factory), Label: workstreamValue(p.Label)} } func appWorkstreamPageNode(p workstream.PageNode) appwire.WorkstreamPageNode { - return appwire.WorkstreamPageNode{ID: p.ID, WorkstreamID: p.WorkstreamID, ParentID: workstreamOptional(p.ParentID), Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs, Position: p.Position, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, UpdatedFrom: appWorkstreamSource(p.UpdatedFrom)} + return appwire.WorkstreamPageNode{ID: p.ID, WorkstreamID: p.WorkstreamID, ParentID: workstreamOptional(p.ParentID), Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs, Position: p.Position, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, UpdatedFrom: appWorkstreamSource(p.UpdatedFrom), DeletedAt: p.DeletedAt} } func appWorkstreamPage(p workstream.Page) appwire.WorkstreamPage { n := appWorkstreamPageNode(p.PageNode) - return appwire.WorkstreamPage{ID: n.ID, WorkstreamID: n.WorkstreamID, ParentID: n.ParentID, Title: n.Title, Owner: n.Owner, ReadByJobs: n.ReadByJobs, Position: n.Position, Version: n.Version, UpdatedAt: n.UpdatedAt, UpdatedBy: n.UpdatedBy, UpdatedFrom: n.UpdatedFrom, Doc: p.Doc, Markdown: p.Markdown} + return appwire.WorkstreamPage{ID: n.ID, WorkstreamID: n.WorkstreamID, ParentID: n.ParentID, Title: n.Title, Owner: n.Owner, ReadByJobs: n.ReadByJobs, Position: n.Position, Version: n.Version, UpdatedAt: n.UpdatedAt, UpdatedBy: n.UpdatedBy, UpdatedFrom: n.UpdatedFrom, DeletedAt: n.DeletedAt, Doc: p.Doc, Markdown: p.Markdown} } func (b *appBackend) WorkstreamPageList(p appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageListResult, workstream.Change, error) { @@ -493,12 +493,26 @@ func (b *appBackend) WorkstreamPageList(p appwire.WorkstreamPageListParams) (app for _, page := range pages { out.Pages = append(out.Pages, appWorkstreamPageNode(page)) } + if err == nil && workstreamValue(p.IncludeDeleted) { + var deleted []workstream.PageNode + deleted, err = s.DeletedPages(context.Background(), p.WorkstreamID) + out.Deleted = []appwire.WorkstreamPageNode{} + for _, page := range deleted { + out.Deleted = append(out.Deleted, appWorkstreamPageNode(page)) + } + } return out, workstream.Change{}, err }) } func (b *appBackend) WorkstreamPageGet(p appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageGetResult, workstream.Change, error) { - v, err := s.Page(context.Background(), p.ID, workstreamValue(p.Markdown)) + var v workstream.Page + var err error + if p.Version != nil { + v, err = s.PageAtVersion(context.Background(), p.ID, *p.Version, workstreamValue(p.Markdown)) + } else { + v, err = s.Page(context.Background(), p.ID, workstreamValue(p.Markdown)) + } return appwire.WorkstreamPageGetResult{Page: appWorkstreamPage(v)}, workstream.Change{}, err }) } @@ -510,7 +524,11 @@ func (b *appBackend) WorkstreamPageSave(p appwire.WorkstreamPageSaveParams) (app } func (b *appBackend) WorkstreamPageCreate(p appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageCreateResult, workstream.Change, error) { - v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: workstreamValue(p.ReadByJobs), Doc: p.Doc}) + read := workstream.ReadByJobsDefault(p.Title) + if p.ReadByJobs != nil { + read = *p.ReadByJobs + } + v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: read, Doc: p.Doc}) return appwire.WorkstreamPageCreateResult{Page: appWorkstreamPage(v)}, c, err }) } @@ -548,7 +566,14 @@ func (b *appBackend) WorkstreamPageHistory(p appwire.WorkstreamPageHistoryParams } func (b *appBackend) WorkstreamPageRestore(p appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) { return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageRestoreResult, workstream.Change, error) { - v, c, err := s.RestorePage(context.Background(), p.ID, p.Version, p.BaseVersion, workstream.Attribution{}) + var v workstream.Page + var c workstream.Change + var err error + if p.Version == nil { + v, c, err = s.UndeletePage(context.Background(), p.ID) + } else { + v, c, err = s.RestorePage(context.Background(), p.ID, *p.Version, *p.BaseVersion, workstream.Attribution{}) + } return appwire.WorkstreamPageRestoreResult{Page: appWorkstreamPage(v)}, c, err }) } diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go index f39b158764..b022e5e27f 100644 --- a/cmd/codeaf/appworkstreams_conformance_test.go +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -578,8 +578,24 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir if name == "page.delete" { fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"]) } - case "page.history", "page.restore": + case "page.history": fixture.saveByAgent(t) + // WP-F1 (g): prepare all three additive example_1 pairs with the exact + // optional keys illustrated by the wire contract. + case "page.list": + if situation == 1 { + fixture.deletedPage(t) + } + case "page.get": + if situation == 1 { + fixture.saveByAgent(t) + } + case "page.restore": + if situation == 1 { + fixture.deletedPage(t) + } else { + fixture.saveByAgent(t) + } } if name == "record.list" || name == "record.keep" || name == "record.drop" || name == "record.supersede" || name == "workstream.changed" { fixture.record(t) @@ -611,6 +627,18 @@ func (fixture *workstreamWireFixture) page(t *testing.T, alias, parent string) { fixture.IDs[alias] = page.Page.ID } +func (fixture *workstreamWireFixture) deletedPage(t *testing.T) { + t.Helper() + fixture.page(t, "pg_33f2", fixture.IDs["pg_31d0"]) + id := fixture.IDs["pg_33f2"] + if _, err := fixture.Backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { + t.Fatal(err) + } + if _, err := fixture.Backend.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); err != nil { + t.Fatal(err) + } +} + func (fixture *workstreamWireFixture) source() *appwire.WorkstreamSource { return &appwire.WorkstreamSource{Kind: "message", ID: "msg_19a2", ChatID: workstreamPtr(fixture.IDs["conv_5b9e"]), Label: workstreamPtr("Billing scope")} } @@ -746,10 +774,11 @@ func (contract *workstreamWireContract) checkError(t *testing.T, path string, va } type workstreamWireErrorCase struct { - Name string - Method string - Code string - Example string + Name string + Method string + Code string + Example string + ParamsExample int } // TestAppWorkstreamWireConformance keeps the table independent of the fixtures: @@ -767,6 +796,12 @@ func TestAppWorkstreamWireConformance(t *testing.T) { errorCases := []workstreamWireErrorCase{ {Name: "bad_params", Method: "workstream.get", Code: "bad_params"}, {Name: "not_found", Method: "workstream.get", Code: "not_found", Example: "workstream.get"}, + // WP-F1 (a), (b), (g): new version and restore error doors use real dispatch. + {Name: "get_unknown_version", Method: "page.get", Code: "not_found", ParamsExample: 1}, + {Name: "restore_version_only", Method: "page.restore", Code: "bad_params"}, + {Name: "restore_base_only", Method: "page.restore", Code: "bad_params"}, + {Name: "restore_live_id_only", Method: "page.restore", Code: "bad_params", ParamsExample: 1}, + {Name: "restore_deleted_version", Method: "page.restore", Code: "bad_params"}, {Name: "conflict", Method: "workstream.create", Code: "conflict"}, {Name: "collection_owner", Method: "collections.add", Code: "in_other_workstream", Example: "collections.add"}, {Name: "create_owner", Method: "workstream.create", Code: "in_other_workstream", Example: "collections.add"}, @@ -1155,14 +1190,29 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m fixture := workstreamWireSeed(t, "workstream.get", 0) backend := fixture.Backend examples := files[errorCase.Method] - params := fixture.prepare(t, examples.Examples.Params[0]) + params := fixture.prepare(t, examples.Examples.Params[errorCase.ParamsExample]) var engine appwire.Engine = &workstreamWireBackend{appBackend: backend} release := func() {} + changedShape := false switch errorCase.Name { case "bad_params": delete(params, "id") + changedShape = true case "not_found": params["id"] = "missing-workstream" + case "get_unknown_version": + params["version"] = json.Number("99") + case "restore_version_only": + delete(params, "base_version") + changedShape = true + case "restore_base_only": + delete(params, "version") + changedShape = true + case "restore_live_id_only": + params["id"] = fixture.IDs["pg_31d0"] + case "restore_deleted_version": + fixture.deletedPage(t) + params["id"] = fixture.IDs["pg_33f2"] case "conflict": params["name"] = fixture.Stream.Name case "collection_owner": @@ -1180,6 +1230,7 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m params["id"] = fixture.IDs["pg_32e1"] params["parent_id"] = fixture.IDs["conformance-descendant"] case "too_large": + changedShape = true params["doc"] = map[string]any{"type": "doc", "content": []any{ map[string]any{"type": "paragraph", "content": []any{ map[string]any{"type": "text", "text": strings.Repeat("x", (1<<20)+1)}, @@ -1192,9 +1243,9 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m default: t.Fatalf("unimplemented error case %s", errorCase.Name) } - if errorCase.Name != "bad_params" && errorCase.Name != "too_large" { + if !changedShape { comparison := &workstreamWireComparison{} - workstreamWireCheck(t, comparison.shape(errorCase.Method+" error params ", params, workstreamWireDecode(t, examples.Examples.Params[0]), workstreamWirePreparedShape)) + workstreamWireCheck(t, comparison.shape(errorCase.Method+" error params ", params, workstreamWireDecode(t, examples.Examples.Params[errorCase.ParamsExample]), workstreamWirePreparedShape)) } connection := workstreamWireConnect(t, engine) frame := connection.call(t, errorCase.Method, params) diff --git a/cmd/codeaf/appworkstreams_pages_test.go b/cmd/codeaf/appworkstreams_pages_test.go new file mode 100644 index 0000000000..6d89ba1d6f --- /dev/null +++ b/cmd/codeaf/appworkstreams_pages_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// WP-F1 (c): creation alone chooses the title default; explicit booleans win, +// renames retain the existing choice, and the initial Overview remains off. +func TestAppWorkstreamPageReadByJobsDefaults(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Read defaults") + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) + if err != nil || len(pages.Pages) != 1 || pages.Pages[0].ReadByJobs { + t.Fatalf("initial Overview: %+v %v", pages, err) + } + for _, tc := range []struct { + title string + value *bool + want bool + }{ + {"Spec", nil, true}, {" decisions ", nil, true}, {"DECISIONS", nil, true}, + {"Spec notes", nil, false}, {"Overview", nil, false}, {"Notes", nil, false}, + {"Spec", workstreamPtr(false), false}, {"Notes", workstreamPtr(true), true}, + } { + t.Run(tc.title, func(t *testing.T) { + made, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: tc.title, ReadByJobs: tc.value}) + if err != nil || made.Page.ReadByJobs != tc.want { + t.Fatalf("created: %+v %v, want read_by_jobs=%v", made, err, tc.want) + } + rename := "Spec" + if tc.want { + rename = "Notes" + } + updated, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: made.Page.ID, Title: &rename}) + if err != nil || updated.Page.ReadByJobs != tc.want { + t.Fatalf("rename changed read_by_jobs: %+v %v", updated, err) + } + }) + } +} + +// WP-F1 (a): handlers expose historical content and attribution, optional +// markdown and current metadata, including deleted_at for a deleted page. +func TestAppWorkstreamPageGetVersion(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Version handler") + store, err := b.workstreamStore(false) + if err != nil || store == nil { + 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, + Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: "old-source"}}}) + if err != nil { + t.Fatal(err) + } + if _, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: first.ID, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { + t.Fatal(err) + } + if _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: first.ID, Title: workstreamPtr("After"), ReadByJobs: workstreamPtr(true)}); err != nil { + t.Fatal(err) + } + before := len(workstreamEvents(rec)) + current, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID}) + if err != nil { + t.Fatal(err) + } + selected, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(2)}) + if err != nil || !reflect.DeepEqual(selected, current) { + t.Fatalf("current version: %+v, want %+v: %v", selected, current, err) + } + old, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(1), Markdown: workstreamPtr(true)}) + if err != nil || old.Page.Title != "Before" || string(old.Page.Doc) != string(doc) || old.Page.Version != 1 || old.Page.UpdatedBy != "agent" || !old.Page.UpdatedAt.Equal(first.UpdatedAt) || old.Page.UpdatedFrom == nil || old.Page.UpdatedFrom.ID != "old-source" || !old.Page.ReadByJobs || old.Page.Markdown == nil || *old.Page.Markdown != "Earlier\n" { + t.Fatalf("historical page: %+v %v", old, err) + } + _, err = b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(99)}) + workstreamCheckError(t, err, appwire.CodeNotFound) + for _, version := range []int{0, -1} { + _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: &version}) + workstreamCheckError(t, err, appwire.CodeBadParams) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("version reads emitted changes") + } + if _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: first.ID}); err != nil { + t.Fatal(err) + } + old, err = b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(1)}) + if err != nil || old.Page.DeletedAt.IsZero() || old.Page.Markdown != nil || old.Page.Title != "Before" { + t.Fatalf("deleted historical page: %+v %v", old, err) + } +} + +// WP-F1 (b): list presence, deleted write refusals, restore parameter dispatch +// and the committed pages event are visible through the backend handlers. +func TestAppWorkstreamDeletedPageHandlers(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Deleted handler") + list := func(include *bool) map[string]json.RawMessage { + t.Helper() + result, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID, IncludeDeleted: include}) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + return fields + } + for _, include := range []*bool{nil, workstreamPtr(false)} { + if _, present := list(include)["deleted"]; present { + t.Fatal("deleted present without include_deleted=true") + } + } + if raw := string(list(workstreamPtr(true))["deleted"]); raw != "[]" { + t.Fatalf("empty deleted list = %s", raw) + } + made, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Spec"}) + if err != nil { + t.Fatal(err) + } + id := made.Page.ID + _, err = b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeBadParams) + if _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); err != nil { + t.Fatal(err) + } + fields := list(workstreamPtr(true)) + var nodes []appwire.WorkstreamPageNode + if err := json.Unmarshal(fields["deleted"], &nodes); err != nil || len(nodes) != 1 || nodes[0].ID != id || nodes[0].DeletedAt.IsZero() { + t.Fatalf("deleted list: %s %v", fields["deleted"], err) + } + for name, call := range map[string]func() error{ + "save": func() error { + _, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: made.Page.Doc}) + return err + }, + "update": func() error { _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: id}); return err }, + "move": func() error { _, err := b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: id}); return err }, + "delete": func() error { _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); return err }, + "history": func() error { + _, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) + return err + }, + "create parent": func() error { + _, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, ParentID: &id, Title: "No"}) + return err + }, + "create after": func() error { + _, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, AfterID: &id, Title: "No"}) + return err + }, + } { + t.Run(name, func(t *testing.T) { workstreamCheckError(t, call(), appwire.CodeNotFound) }) + } + for _, p := range []appwire.WorkstreamPageRestoreParams{ + {ID: id, Version: workstreamPtr(1)}, {ID: id, BaseVersion: workstreamPtr(1)}, + {ID: id, Version: workstreamPtr(1), BaseVersion: workstreamPtr(1)}, + } { + _, err := b.WorkstreamPageRestore(p) + workstreamCheckError(t, err, appwire.CodeBadParams) + } + _, err = b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: "missing"}) + workstreamCheckError(t, err, appwire.CodeNotFound) + before := len(workstreamEvents(rec)) + restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id}) + if err != nil || restored.Page.Version != 1 || !restored.Page.DeletedAt.IsZero() || restored.Page.UpdatedBy != "you" || restored.Page.UpdatedFrom != nil { + t.Fatalf("undelete: %+v %v", restored, err) + } + events := workstreamEvents(rec) + if len(events) != before+1 || events[before].ID != w.ID || !reflect.DeepEqual(events[before].Parts, []string{"workstream", "pages"}) { + t.Fatalf("undelete event: %+v", events[before:]) + } + history, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) + if err != nil || len(history.Versions) != 1 { + t.Fatalf("undelete added history: %+v %v", history, err) + } + if raw := string(list(workstreamPtr(true))["deleted"]); raw != "[]" { + t.Fatalf("deleted list after restore = %s", raw) + } +} diff --git a/cmd/codeaf/appworkstreams_test.go b/cmd/codeaf/appworkstreams_test.go index 7351dc9d41..2e8baf8a64 100644 --- a/cmd/codeaf/appworkstreams_test.go +++ b/cmd/codeaf/appworkstreams_test.go @@ -464,7 +464,7 @@ func TestAppWorkstreamPageVersions(t *testing.T) { if len(workstreamEvents(rec)) != before { t.Fatal("stale save or reads emitted") } - restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id, Version: 1, BaseVersion: 2}) + restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id, Version: workstreamPtr(1), BaseVersion: workstreamPtr(2)}) if err != nil || restored.Page.Version != 3 || string(restored.Page.Doc) != `{"type":"doc","content":[]}` { t.Fatalf("restore %+v, %v", restored, err) } @@ -781,7 +781,7 @@ func TestAppWorkstreamEveryWriteEmitsOnce(t *testing.T) { return err }}, {"page.restore", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: f.page, Version: 1, BaseVersion: f.base}) + _, err := f.b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: f.page, Version: workstreamPtr(1), BaseVersion: workstreamPtr(f.base)}) return err }}, {"record.propose", []string{"workstream", "records"}, false, func(f *fixture) error { diff --git a/cmd/codeaf/testdata/workstreams-wire/contract.json b/cmd/codeaf/testdata/workstreams-wire/contract.json index df9349aa4f..6dd50cef8e 100644 --- a/cmd/codeaf/testdata/workstreams-wire/contract.json +++ b/cmd/codeaf/testdata/workstreams-wire/contract.json @@ -443,6 +443,10 @@ "updated_from": { "type": "WorkstreamSource", "required": false + }, + "deleted_at": { + "type": "string (RFC 3339)", + "required": false } }, "WorkstreamPage": { @@ -490,6 +494,10 @@ "type": "WorkstreamSource", "required": false }, + "deleted_at": { + "type": "string (RFC 3339)", + "required": false + }, "doc": { "type": "object", "required": true @@ -836,12 +844,20 @@ "workstream_id": { "type": "string", "required": true + }, + "include_deleted": { + "type": "boolean", + "required": false } }, "WorkstreamPageListResult": { "pages": { "type": "WorkstreamPageNode[]", "required": true + }, + "deleted": { + "type": "WorkstreamPageNode[]", + "required": false } }, "WorkstreamPageGetParams": { @@ -852,6 +868,10 @@ "markdown": { "type": "boolean", "required": false + }, + "version": { + "type": "integer", + "required": false } }, "WorkstreamPageGetResult": { @@ -995,11 +1015,11 @@ }, "version": { "type": "integer", - "required": true + "required": false }, "base_version": { "type": "integer", - "required": true + "required": false } }, "WorkstreamPageRestoreResult": { diff --git a/cmd/codeaf/testdata/workstreams-wire/page.get.json b/cmd/codeaf/testdata/workstreams-wire/page.get.json index fdcf420091..85cc68240a 100644 --- a/cmd/codeaf/testdata/workstreams-wire/page.get.json +++ b/cmd/codeaf/testdata/workstreams-wire/page.get.json @@ -13,6 +13,10 @@ { "id": "pg_31d0", "markdown": true + }, + { + "id": "pg_31d0", + "version": 1 } ], "result": [ @@ -33,6 +37,23 @@ }, "markdown": "" } + }, + { + "page": { + "id": "pg_31d0", + "workstream_id": "ws_8f2c1a", + "title": "Overview", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 1, + "updated_at": "2026-11-02T14:05:00Z", + "updated_by": "you", + "doc": { + "type": "doc", + "content": [] + } + } } ] } diff --git a/cmd/codeaf/testdata/workstreams-wire/page.list.json b/cmd/codeaf/testdata/workstreams-wire/page.list.json index 37550b606a..6238f1105b 100644 --- a/cmd/codeaf/testdata/workstreams-wire/page.list.json +++ b/cmd/codeaf/testdata/workstreams-wire/page.list.json @@ -12,6 +12,10 @@ "params": [ { "workstream_id": "ws_8f2c1a" + }, + { + "workstream_id": "ws_8f2c1a", + "include_deleted": true } ], "result": [ @@ -29,6 +33,36 @@ "updated_by": "you" } ] + }, + { + "pages": [ + { + "id": "pg_31d0", + "workstream_id": "ws_8f2c1a", + "title": "Overview", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 1, + "updated_at": "2026-11-02T14:05:00Z", + "updated_by": "you" + } + ], + "deleted": [ + { + "id": "pg_33f2", + "workstream_id": "ws_8f2c1a", + "parent_id": "pg_31d0", + "title": "Old metering notes", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 2, + "updated_at": "2026-11-02T15:10:00Z", + "updated_by": "you", + "deleted_at": "2026-11-02T15:20:00Z" + } + ] } ] } diff --git a/cmd/codeaf/testdata/workstreams-wire/page.restore.json b/cmd/codeaf/testdata/workstreams-wire/page.restore.json index 9cdf549c70..37da1c2804 100644 --- a/cmd/codeaf/testdata/workstreams-wire/page.restore.json +++ b/cmd/codeaf/testdata/workstreams-wire/page.restore.json @@ -16,6 +16,9 @@ "id": "pg_31d0", "version": 1, "base_version": 2 + }, + { + "id": "pg_33f2" } ], "result": [ @@ -35,6 +38,24 @@ "content": [] } } + }, + { + "page": { + "id": "pg_33f2", + "workstream_id": "ws_8f2c1a", + "parent_id": "pg_31d0", + "title": "Old metering notes", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 2, + "updated_at": "2026-11-02T15:30:00Z", + "updated_by": "you", + "doc": { + "type": "doc", + "content": [] + } + } } ] } diff --git a/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md b/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md new file mode 100644 index 0000000000..7a5cda50b7 --- /dev/null +++ b/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md @@ -0,0 +1,24 @@ +--- +kind: added +title: Workstream pages support version reads, restorable deletion and title-based read defaults +pr: 2290 +surface: [app] +invalidates: + - page.delete used to destroy pages and their history; it now marks pages deleted and restorable while retaining saved versions. + - page.create used to default read_by_jobs to false for every title; it now defaults to true for Spec and Decisions unless explicitly overridden. +--- + +`page.get` accepts a saved version and renders that version's document when +markdown is requested. Deleted pages remain readable, but leave the live tree +and refuse edits and history reads. `page.list` with `include_deleted: true` +adds deletion roots, newest first, including an empty list when none remain. + +`page.restore` with only an id restores a deleted page and the descendants +removed by the same deletion, preserving saved versions, nesting and order. +It appends under its live old parent or at the root. Restoring an old version +of a live page still requires both version parameters and creates a new version. + +Creation compares trimmed titles without regard to case when choosing Read by +jobs. Explicit choices win and renames keep the existing setting; the initial +Overview remains off. Deleted pages remain stored until their collection is +deleted. diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go index 6a7191bb17..498cb655a6 100644 --- a/internal/appwire/workstreams.go +++ b/internal/appwire/workstreams.go @@ -146,6 +146,7 @@ type WorkstreamPageNode struct { UpdatedAt time.Time `json:"updated_at"` UpdatedBy string `json:"updated_by" enum:"you,agent"` UpdatedFrom *WorkstreamSource `json:"updated_from,omitempty"` + DeletedAt time.Time `json:"deleted_at,omitzero"` } type WorkstreamPage struct { @@ -160,6 +161,7 @@ type WorkstreamPage struct { UpdatedAt time.Time `json:"updated_at"` UpdatedBy string `json:"updated_by" enum:"you,agent"` UpdatedFrom *WorkstreamSource `json:"updated_from,omitempty"` + DeletedAt time.Time `json:"deleted_at,omitzero"` Doc json.RawMessage `json:"doc"` Markdown *string `json:"markdown,omitempty"` } @@ -305,16 +307,19 @@ type WorkstreamPlanSetResult struct { } type WorkstreamPageListParams struct { - WorkstreamID string `json:"workstream_id"` + WorkstreamID string `json:"workstream_id"` + IncludeDeleted *bool `json:"include_deleted,omitempty"` } type WorkstreamPageListResult struct { - Pages []WorkstreamPageNode `json:"pages"` + Pages []WorkstreamPageNode `json:"pages"` + Deleted []WorkstreamPageNode `json:"deleted,omitzero"` } type WorkstreamPageGetParams struct { ID string `json:"id"` Markdown *bool `json:"markdown,omitempty"` + Version *int `json:"version,omitempty"` } type WorkstreamPageGetResult struct { @@ -385,8 +390,8 @@ type WorkstreamPageHistoryResult struct { type WorkstreamPageRestoreParams struct { ID string `json:"id"` - Version int `json:"version"` - BaseVersion int `json:"base_version"` + Version *int `json:"version,omitempty"` + BaseVersion *int `json:"base_version,omitempty"` } type WorkstreamPageRestoreResult struct { diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go index c549a4312e..c9418fdda0 100644 --- a/internal/appwire/workstreams_check.go +++ b/internal/appwire/workstreams_check.go @@ -197,12 +197,17 @@ func workstreamSource(p *WorkstreamSource) error { return p.Check() } -func (p WorkstreamListParams) Check() error { return nil } -func (p WorkstreamGetParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamReopenParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamPlanGetParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamPageListParams) Check() error { return workstreamID(p.WorkstreamID) } -func (p WorkstreamPageGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamListParams) Check() error { return nil } +func (p WorkstreamGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamReopenParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamPlanGetParams) Check() error { return workstreamID(p.ID) } +func (p WorkstreamPageListParams) Check() error { return workstreamID(p.WorkstreamID) } +func (p WorkstreamPageGetParams) Check() error { + if p.Version != nil && *p.Version < 1 { + return Errorf(CodeBadParams, "version must be positive") + } + return workstreamID(p.ID) +} func (p WorkstreamPageDeleteParams) Check() error { return workstreamID(p.ID) } func (p WorkstreamRecordKeepParams) Check() error { return workstreamID(p.ID) } func (p WorkstreamRecordDropParams) Check() error { return workstreamID(p.ID) } @@ -329,7 +334,10 @@ func (p WorkstreamPageHistoryParams) Check() error { return workstreamChecks(workstreamID(p.ID), err) } func (p WorkstreamPageRestoreParams) Check() error { - if p.Version < 1 || p.BaseVersion < 0 { + if (p.Version == nil) != (p.BaseVersion == nil) { + return Errorf(CodeBadParams, "version and base_version must be supplied together") + } + if p.Version != nil && (*p.Version < 1 || *p.BaseVersion < 0) { return Errorf(CodeBadParams, "restore requires positive version and nonnegative base_version") } return workstreamID(p.ID) diff --git a/internal/appwire/workstreams_check_test.go b/internal/appwire/workstreams_check_test.go index 117f128e14..2da32a2a7c 100644 --- a/internal/appwire/workstreams_check_test.go +++ b/internal/appwire/workstreams_check_test.go @@ -34,6 +34,12 @@ func TestWorkstreamBoundsBeforeDelegation(t *testing.T) { {"page.history", `{"id":"x","limit":0}`, CodeBadParams}, {"page.history", `{"id":"x","limit":201}`, CodeBadParams}, {"page.restore", `{"id":"x","version":0,"base_version":1}`, CodeBadParams}, + // WP-F1 (a), (b): version reads are positive and restore versions are paired. + {"page.get", `{"id":"x","version":0}`, CodeBadParams}, + {"page.get", `{"id":"x","version":-1}`, CodeBadParams}, + {"page.restore", `{"id":"x","version":1}`, CodeBadParams}, + {"page.restore", `{"id":"x","base_version":1}`, CodeBadParams}, + {"page.restore", `{"id":"x","version":1,"base_version":-1}`, CodeBadParams}, {"record.list", `{"workstream_id":"x","state":"active"}`, CodeBadParams}, {"record.propose", `{"workstream_id":"x","kind":"decision","text":""}`, CodeBadParams}, {"record.propose", `{"workstream_id":"x","kind":"decision","text":"` + strings.Repeat("t", 2001) + `"}`, CodeBadParams}, @@ -60,6 +66,10 @@ func TestWorkstreamBoundsPreserveExplicitZeroAndFalse(t *testing.T) { "workstream.plan_set": `{"id":"x","base_version":0,"milestones":[],"waves":[]}`, "workstream.update": `{"id":"x","patch":{"why":"","budget_usd":0,"wake":{"morning":false,"related_issues":false}}}`, "page.create": `{"workstream_id":"x","title":"x","read_by_jobs":false}`, + // WP-F1 (a), (b): additive params, including id-only restore, reach dispatch. + "page.get": `{"id":"x","version":1}`, + "page.list": `{"workstream_id":"x","include_deleted":true}`, + "page.restore": `{"id":"x"}`, } { _, err := methods[method].call(fake, json.RawMessage(params)) if err != ErrWorkstreamsNotYet { diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go index f61626e0d5..ac38029234 100644 --- a/internal/workspace/schema3_test.go +++ b/internal/workspace/schema3_test.go @@ -210,3 +210,39 @@ DELETE FROM memberships WHERE ref_id='gone';`); err != nil { }) } } + +// WP-F1 (b), Storage: schema 3 is amended in place; either missing deletion +// column identifies an obsolete schema-3 file and must fail at Open. +func TestWorkstreamSchemaThreeRequiresDeletionColumns(t *testing.T) { + for _, column := range []string{"deleted_at", "deleted_root"} { + t.Run(column, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "obsolete.db") + s := openTestStore(t, path) + createTestCollection(t, s, "Schema three") + if userVersion(t, s) != 3 { + t.Fatal("fixture is not schema 3") + } + if _, err := s.db.Exec("ALTER TABLE workstream_pages DROP COLUMN " + column); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + opened, err := Open(path) + if opened != nil { + _ = opened.Close() + } + if err == nil || !strings.Contains(err.Error(), column) { + t.Fatalf("obsolete schema 3 opened without naming %s: %v", column, err) + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) { + t.Fatalf("refusal changed obsolete schema 3: %v", err) + } + }) + } +} diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go index 46de35b2f5..3a4619608d 100644 --- a/internal/workspace/workstreams.go +++ b/internal/workspace/workstreams.go @@ -180,7 +180,8 @@ CREATE TABLE workstream_pages ( read_by_jobs INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL CHECK(position>=0), version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), updated_from TEXT, - doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)) + doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)), + deleted_at INTEGER, deleted_root TEXT ); CREATE INDEX workstream_page_tree ON workstream_pages(workstream_id,parent_id,position); CREATE UNIQUE INDEX workstream_overview ON workstream_pages(workstream_id) WHERE overview=1; @@ -225,7 +226,7 @@ CREATE TABLE workstream_plans ( var workstreamSchemaReads = []string{ "SELECT id,why,origin_chat_id,agent_id,autonomy,wake_morning,wake_related_issues,budget_usd,created_at,updated_at,started_at,done_at,how_it_went,client_id FROM workstreams LIMIT 0", "SELECT member_seq,workstream_id,factory,job FROM workstream_jobs LIMIT 0", - "SELECT id,workstream_id,parent_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview FROM workstream_pages LIMIT 0", + "SELECT id,workstream_id,parent_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview,deleted_at,deleted_root FROM workstream_pages LIMIT 0", "SELECT page_id,version,saved_at,saved_by,source,title,doc,bytes FROM workstream_page_versions LIMIT 0", "SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes,superseded_by FROM workstream_records LIMIT 0", "SELECT id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,end_reason,granted_at,ended_at FROM workstream_goaheads LIMIT 0", diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go index cc3a238bb2..c856f975ef 100644 --- a/internal/workstream/pages.go +++ b/internal/workstream/pages.go @@ -4,53 +4,89 @@ import ( "context" "database/sql" "encoding/json" + "errors" "strings" "unicode/utf8" "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" ) -const pageSelect = `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview FROM workstream_pages WHERE id=?` +const pageSelect = `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview,deleted_at FROM workstream_pages WHERE id=?` + +// ReadByJobsDefault chooses the creation default; later title changes do not +// alter an existing page's explicit Read by jobs setting. +func ReadByJobsDefault(title string) bool { + title = strings.TrimSpace(title) + return strings.EqualFold(title, "Spec") || strings.EqualFold(title, "Decisions") +} func pageIn(ctx context.Context, tx *sql.Tx, id string) (Page, bool, error) { var p Page var at int64 var source sql.NullString + var deletedAt sql.NullInt64 var overview bool var doc string - err := tx.QueryRowContext(ctx, pageSelect, id).Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &doc, &overview) + err := tx.QueryRowContext(ctx, pageSelect, id).Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &doc, &overview, &deletedAt) if err != nil { return p, false, found(err) } p.UpdatedAt = instant(at) + if deletedAt.Valid { + p.DeletedAt = instant(deletedAt.Int64) + } p.Doc = json.RawMessage(doc) p.UpdatedFrom, err = readSource(source) return p, overview, err } -func pageNodesIn(ctx context.Context, tx *sql.Tx, id string) ([]PageNode, error) { - rows, err := tx.QueryContext(ctx, `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from FROM workstream_pages WHERE workstream_id=? ORDER BY position,id`, id) - if err != nil { - return nil, err +func livePageIn(ctx context.Context, tx *sql.Tx, id string) (Page, bool, error) { + p, overview, err := pageIn(ctx, tx, id) + if err == nil && !p.DeletedAt.IsZero() { + err = ErrNotFound } + return p, overview, err +} + +const pageNodeColumns = `id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,deleted_at` + +func readPageNodes(rows *sql.Rows) ([]PageNode, error) { defer rows.Close() - siblings := map[string][]PageNode{} + out := make([]PageNode, 0) for rows.Next() { var p PageNode var at int64 var source sql.NullString - if err := rows.Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source); err != nil { + var deletedAt sql.NullInt64 + if err := rows.Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &deletedAt); err != nil { return nil, err } p.UpdatedAt = instant(at) + if deletedAt.Valid { + p.DeletedAt = instant(deletedAt.Int64) + } + var err error p.UpdatedFrom, err = readSource(source) if err != nil { return nil, err } - siblings[p.ParentID] = append(siblings[p.ParentID], p) + out = append(out, p) } - if err := rows.Err(); err != nil { + return out, rows.Err() +} + +func pageNodesIn(ctx context.Context, tx *sql.Tx, id string) ([]PageNode, error) { + rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_at IS NULL ORDER BY position,id`, id) + if err != nil { return nil, err } + nodes, err := readPageNodes(rows) + if err != nil { + return nil, err + } + siblings := map[string][]PageNode{} + for _, p := range nodes { + siblings[p.ParentID] = append(siblings[p.ParentID], p) + } out := make([]PageNode, 0) seen := map[string]bool{} // An explicit stack keeps exports safe for a very deep tree. @@ -89,7 +125,40 @@ func (s *Store) Pages(ctx context.Context, id string) ([]PageNode, error) { }) return out, err } + +// DeletedPages lists delete roots; descendants remain readable but are not +// separate entries unless they were deleted by their own delete operation. +func (s *Store) DeletedPages(ctx context.Context, id string) ([]PageNode, error) { + out := make([]PageNode, 0) + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_at IS NOT NULL AND deleted_root=id ORDER BY deleted_at DESC,id`, id) + if err != nil { + return err + } + out, err = readPageNodes(rows) + return err + }) + return out, err +} func (s *Store) Page(ctx context.Context, id string, markdown bool) (Page, error) { + return s.page(ctx, id, 0, markdown) +} + +// PageAtVersion reads saved content while preserving the page's current tree metadata. +func (s *Store) PageAtVersion(ctx context.Context, id string, version int, markdown bool) (Page, error) { + if version < 1 { + return Page{}, invalid("version must be positive") + } + return s.page(ctx, id, version, markdown) +} + +func (s *Store) page(ctx context.Context, id string, version int, markdown bool) (Page, error) { var p Page err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { if v < 3 { @@ -97,6 +166,19 @@ func (s *Store) Page(ctx context.Context, id string, markdown bool) (Page, error } var err error p, _, err = pageIn(ctx, tx, id) + if err != nil || version == 0 || version == p.Version { + return err + } + var at int64 + var source sql.NullString + var doc string + err = tx.QueryRowContext(ctx, `SELECT title,doc,version,saved_at,saved_by,source FROM workstream_page_versions WHERE page_id=? AND version=?`, id, version). + Scan(&p.Title, &doc, &p.Version, &at, &p.UpdatedBy, &source) + if err != nil { + return found(err) + } + p.Doc, p.UpdatedAt = json.RawMessage(doc), instant(at) + p.UpdatedFrom, err = readSource(source) return err }) if err == nil && markdown { @@ -118,7 +200,7 @@ func checkDoc(doc json.RawMessage) error { } func saveVersion(ctx context.Context, tx *sql.Tx, id string) error { _, err := tx.ExecContext(ctx, `INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,source,title,doc,bytes) - SELECT id,version,updated_at,updated_by,updated_from,title,doc,length(CAST(doc AS BLOB)) FROM workstream_pages WHERE id=?`, id) + SELECT id,version,updated_at,updated_by,updated_from,title,doc,length(CAST(doc AS BLOB)) FROM workstream_pages WHERE id=? AND deleted_at IS NULL`, id) return err } func (s *Store) SavePage(ctx context.Context, id string, base int, doc json.RawMessage, who Attribution) (Page, Change, error) { @@ -152,6 +234,12 @@ func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawM if err != nil { return err } + if !p.DeletedAt.IsZero() { + if restore > 0 { + return invalid("deleted page restore requires only id") + } + return ErrNotFound + } if p.Version != base { return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} } @@ -198,7 +286,7 @@ func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, la } seen[id] = true var workstreamID, next string - err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=?", id).Scan(&workstreamID, &next) + err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=? AND deleted_at IS NULL", id).Scan(&workstreamID, &next) if err != nil { return false, found(err) } @@ -210,7 +298,7 @@ func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, la } if after != "" { var workstreamID, siblingParent string - err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=?", after).Scan(&workstreamID, &siblingParent) + err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=? AND deleted_at IS NULL", after).Scan(&workstreamID, &siblingParent) if err != nil { return false, found(err) } @@ -315,7 +403,7 @@ func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, err if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc) VALUES (?,?,?,?,?,2147483647,1,?,?,?,?)`, id, p.WorkstreamID, p.Title, owner, p.ReadByJobs, at, by, source, string(p.Doc)); err != nil { return err } - page, _, err := pageIn(ctx, tx, id) + page, _, err := livePageIn(ctx, tx, id) if err != nil { return err } @@ -359,7 +447,7 @@ func (s *Store) UpdatePage(ctx context.Context, id string, p PagePatch) (PageNod var out PageNode var c Change err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - page, _, err := pageIn(ctx, tx, id) + page, _, err := livePageIn(ctx, tx, id) if err != nil { return err } @@ -398,7 +486,7 @@ func (s *Store) MovePage(ctx context.Context, id, parent, after string) (PageNod var out PageNode var c Change err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, overview, err := pageIn(ctx, tx, id) + p, overview, err := livePageIn(ctx, tx, id) if err != nil { return err } @@ -429,7 +517,7 @@ func (s *Store) DeletePage(ctx context.Context, id string) ([]string, Change, er deleted := []string{} var c Change err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, overview, err := pageIn(ctx, tx, id) + p, overview, err := livePageIn(ctx, tx, id) if err != nil { return err } @@ -447,10 +535,9 @@ func (s *Store) DeletePage(ctx context.Context, id string) ([]string, Change, er deleted = append(deleted, n.ID) } } - // Delete leaves before parents to avoid SQLite's trigger depth limit on a - // deep subtree; the cascade remains a backstop for ordinary SQL deletions. - for i := len(deleted) - 1; i >= 0; i-- { - if _, err := tx.ExecContext(ctx, "DELETE FROM workstream_pages WHERE id=?", deleted[i]); err != nil { + at := s.now().UnixMilli() + for _, deletedID := range deleted { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET deleted_at=?,deleted_root=? WHERE id=?", at, id, deletedID); err != nil { return err } } @@ -463,7 +550,7 @@ func (s *Store) DeletePage(ctx context.Context, id string) ([]string, Change, er i++ } } - if err := touch(ctx, tx, p.WorkstreamID, s.now().UnixMilli()); err != nil { + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { return err } c = changed(p.WorkstreamID, PartPages) @@ -474,6 +561,79 @@ func (s *Store) DeletePage(ctx context.Context, id string) ([]string, Change, er } return deleted, c, nil } + +// UndeletePage restores only descendants removed by the same delete. Like a +// move, it records the person as the actor and does not create a saved version. +func (s *Store) UndeletePage(ctx context.Context, id string) (Page, Change, error) { + var out Page + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + p, _, err := pageIn(ctx, tx, id) + if err != nil { + return err + } + if p.DeletedAt.IsZero() { + return invalid("live page restore requires version and base_version") + } + var root string + if err := tx.QueryRowContext(ctx, "SELECT deleted_root FROM workstream_pages WHERE id=?", id).Scan(&root); err != nil { + return found(err) + } + rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_root=? AND deleted_at=? ORDER BY position,id`, p.WorkstreamID, root, p.DeletedAt.UnixMilli()) + if err != nil { + return err + } + nodes, err := readPageNodes(rows) + if err != nil { + return err + } + children := map[string][]string{} + for _, n := range nodes { + children[n.ParentID] = append(children[n.ParentID], n.ID) + } + parent := p.ParentID + if parent != "" { + if _, _, err := livePageIn(ctx, tx, parent); errors.Is(err, ErrNotFound) { + parent = "" + } else if err != nil { + return err + } + } + at := s.now().UnixMilli() + stack := []string{id} + seen := map[string]bool{} + for len(stack) > 0 { + last := len(stack) - 1 + restoredID := stack[last] + stack = stack[:last] + if seen[restoredID] { + continue + } + seen[restoredID] = true + if _, err := tx.ExecContext(ctx, `UPDATE workstream_pages SET deleted_at=NULL,deleted_root=NULL,updated_at=?,updated_by='you',updated_from=NULL WHERE id=?`, at, restoredID); err != nil { + return err + } + stack = append(stack, children[restoredID]...) + } + // Force placement even when the old ordinal happens to equal the append + // index: the restored page still needs its parent and attribution written. + p.Position = -1 + if _, err := placePage(ctx, tx, p, parent, "", true, at, "you", nil); err != nil { + return err + } + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return err + } + out, _, err = pageIn(ctx, tx, id) + c = changed(p.WorkstreamID, PartPages) + return err + }) + if err != nil { + return Page{}, Change{}, err + } + return out, c, nil +} + func (s *Store) PageHistory(ctx context.Context, id string, limit int) ([]PageVersion, error) { if limit == 0 { limit = 50 @@ -486,7 +646,7 @@ func (s *Store) PageHistory(ctx context.Context, id string, limit int) ([]PageVe if v < 3 { return ErrNotFound } - if _, _, err := pageIn(ctx, tx, id); err != nil { + if _, _, err := livePageIn(ctx, tx, id); err != nil { return err } rows, err := tx.QueryContext(ctx, "SELECT version,saved_at,saved_by,source,title,bytes FROM workstream_page_versions WHERE page_id=? ORDER BY version DESC LIMIT ?", id, limit) diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go index 595ac51678..09eb222dc6 100644 --- a/internal/workstream/pages_test.go +++ b/internal/workstream/pages_test.go @@ -2,6 +2,7 @@ package workstream import ( "context" + "database/sql" "encoding/json" "errors" "reflect" @@ -118,8 +119,8 @@ func TestPartAPageTreePlacementCyclesAndDelete(t *testing.T) { if err != nil || !reflect.DeepEqual(deleted, []string{a.ID, child.ID}) { t.Fatalf("delete: %v %v", deleted, err) } - if _, err := s.Page(ctx, child.ID, false); !errors.Is(err, ErrNotFound) { - t.Fatalf("child remains: %v", err) + if page, err := s.Page(ctx, child.ID, false); err != nil || page.DeletedAt.IsZero() { + t.Fatalf("deleted child must remain readable: %+v %v", page, err) } } @@ -215,3 +216,290 @@ func TestPartAOverviewIdentitySurvivesRenaming(t *testing.T) { t.Fatalf("Overview order: %+v %v", pages, err) } } + +// WP-F1 (a): saved content and attribution combine with current page metadata; +// selecting the current version is identical to reading without a version. +func TestWorkstreamPageGetSavedVersion(t *testing.T) { + s, _, now := testStore(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, + Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: "first-source"}}}) + if err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + if _, _, err := s.SavePage(ctx, first.ID, 1, json.RawMessage(emptyDoc), Attribution{}); err != nil { + t.Fatal(err) + } + parent, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Parent"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MovePage(ctx, first.ID, parent.ID, ""); err != nil { + t.Fatal(err) + } + title, owner, read := "Current title", "agent", true + if _, _, err := s.UpdatePage(ctx, first.ID, PagePatch{Title: &title, Owner: &owner, ReadByJobs: &read}); err != nil { + t.Fatal(err) + } + current, err := s.Page(ctx, first.ID, false) + if err != nil { + t.Fatal(err) + } + selected, err := s.PageAtVersion(ctx, first.ID, current.Version, false) + if err != nil || !reflect.DeepEqual(selected, current) { + t.Fatalf("current version: %+v, want %+v: %v", selected, current, err) + } + check := func(deleted bool) { + t.Helper() + old, err := s.PageAtVersion(ctx, first.ID, 1, true) + if err != nil { + t.Fatal(err) + } + want := current + want.Title, want.Doc, want.Version = first.Title, first.Doc, first.Version + want.UpdatedAt, want.UpdatedBy, want.UpdatedFrom = first.UpdatedAt, first.UpdatedBy, first.UpdatedFrom + md, err := pagedoc.Markdown(firstDoc) + if err != nil { + t.Fatal(err) + } + want.Markdown = &md + if deleted { + want.DeletedAt = *now + } + if !reflect.DeepEqual(old, want) || old.Markdown == nil || !strings.Contains(*old.Markdown, "First") { + t.Fatalf("saved version: %+v, want %+v", old, want) + } + } + check(false) + if _, err := s.PageAtVersion(ctx, first.ID, 99, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown version: %v", err) + } + for _, version := range []int{0, -1} { + if _, err := s.PageAtVersion(ctx, first.ID, version, false); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid version %d: %v", version, err) + } + } + if _, _, err := s.DeletePage(ctx, first.ID); err != nil { + t.Fatal(err) + } + check(true) +} + +func createWorkstreamPage(t *testing.T, s *Store, workstreamID, parent, title string) Page { + t.Helper() + p, _, err := s.CreatePage(context.Background(), PageCreate{WorkstreamID: workstreamID, ParentID: parent, Title: title}) + if err != nil { + t.Fatal(err) + } + return p +} + +// WP-F1 (b): delete groups preserve separate deletions, history, nesting and order; +// undelete appends under a live parent and changes attribution without a save. +func TestWorkstreamDeletedPageTreeRestore(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Restorable tree") + deleted, err := s.DeletedPages(ctx, w.ID) + if err != nil || deleted == nil || len(deleted) != 0 { + t.Fatalf("initial deleted list: %+v %v", deleted, err) + } + parent := createWorkstreamPage(t, s, w.ID, "", "Parent") + root := createWorkstreamPage(t, s, w.ID, parent.ID, "Root") + earlier := createWorkstreamPage(t, s, w.ID, root.ID, "Earlier deletion") + first := createWorkstreamPage(t, s, w.ID, root.ID, "First") + grandchild := createWorkstreamPage(t, s, w.ID, first.ID, "Grandchild") + 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"}]}]}`) + if _, _, err := s.SavePage(ctx, root.ID, 1, doc, Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}); err != nil { + t.Fatal(err) + } + lastTitle := "Last title" + if _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{Title: &lastTitle, Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}}); err != nil { + t.Fatal(err) + } + if _, _, err := s.DeletePage(ctx, earlier.ID); err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + ids, change, err := s.DeletePage(ctx, root.ID) + if err != nil || !reflect.DeepEqual(ids, []string{root.ID, first.ID, grandchild.ID, second.ID}) || !reflect.DeepEqual(change.Parts, []ChangedPart{PartWorkstream, PartPages}) { + t.Fatalf("delete receipt: %v %+v %v", ids, change, err) + } + deleted, err = s.DeletedPages(ctx, w.ID) + if err != nil || len(deleted) != 2 || deleted[0].ID != root.ID || deleted[1].ID != earlier.ID || !deleted[0].DeletedAt.Equal(*now) || !deleted[0].DeletedAt.After(deleted[1].DeletedAt) { + t.Fatalf("delete roots: %+v %v", deleted, err) + } + nodes, err := s.Pages(ctx, w.ID) + if err != nil || len(nodes) != 3 || nodes[1].ID != parent.ID || nodes[2].ID != sibling.ID || nodes[2].Position != 0 { + t.Fatalf("live tree: %+v %v", nodes, err) + } + for _, id := range ids { + p, err := s.Page(ctx, id, true) + if err != nil || !p.DeletedAt.Equal(*now) { + t.Fatalf("deleted page %s: %+v %v", id, p, err) + } + } + for name, write := range map[string]func() error{ + "save": func() error { + _, _, err := s.SavePage(ctx, root.ID, 2, json.RawMessage(emptyDoc), Attribution{}) + return err + }, + "update": func() error { _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{}); return err }, + "move": func() error { _, _, err := s.MovePage(ctx, root.ID, "", ""); return err }, + "delete": func() error { _, _, err := s.DeletePage(ctx, root.ID); return err }, + "history": func() error { _, err := s.PageHistory(ctx, root.ID, 50); return err }, + "create parent": func() error { + _, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, ParentID: root.ID, Title: "No"}) + return err + }, + "create after": func() error { + _, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, AfterID: root.ID, Title: "No"}) + return err + }, + "move parent": func() error { _, _, err := s.MovePage(ctx, sibling.ID, root.ID, ""); return err }, + "move after": func() error { _, _, err := s.MovePage(ctx, sibling.ID, "", root.ID); return err }, + } { + t.Run(name, func(t *testing.T) { + if err := write(); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted target: %v", err) + } + }) + } + *now = now.Add(time.Hour) + restored, change, err := s.UndeletePage(ctx, root.ID) + if err != nil || restored.ParentID != parent.ID || restored.Position != 1 || restored.Title != lastTitle || restored.Version != 2 || string(restored.Doc) != string(doc) || !restored.DeletedAt.IsZero() || !restored.UpdatedAt.Equal(*now) || restored.UpdatedBy != "you" || restored.UpdatedFrom != nil || change.ID != w.ID || !reflect.DeepEqual(change.Parts, []ChangedPart{PartWorkstream, PartPages}) { + t.Fatalf("restore: %+v %+v %v", restored, change, err) + } + nodes, err = s.Pages(ctx, w.ID) + var liveIDs []string + for _, p := range nodes { + liveIDs = append(liveIDs, p.ID) + } + if err != nil || len(liveIDs) != 7 || !reflect.DeepEqual(liveIDs[1:], []string{parent.ID, sibling.ID, root.ID, first.ID, grandchild.ID, second.ID}) { + t.Fatalf("restored order: %+v %v", nodes, err) + } + for _, want := range []Page{first, grandchild, second} { + p, err := s.Page(ctx, want.ID, false) + if err != nil || p.ParentID != want.ParentID || p.Version != want.Version || !p.DeletedAt.IsZero() { + t.Fatalf("restored nesting: %+v %v", p, err) + } + } + deleted, err = s.DeletedPages(ctx, w.ID) + if err != nil || len(deleted) != 1 || deleted[0].ID != earlier.ID { + t.Fatalf("earlier deletion revived: %+v %v", deleted, err) + } + history, err := s.PageHistory(ctx, root.ID, 50) + if err != nil || len(history) != 2 || history[0].Title != root.Title || history[0].SavedBy != "agent" || history[0].From == nil || history[0].From.ID != "source" { + t.Fatalf("restore changed saved history: %+v %v", history, err) + } +} + +// WP-F1 (b): restoring a descendant of a deleted parent restores only its own +// group subtree, appends at the root, and remains permitted on a done workstream. +func TestWorkstreamDeletedDescendantRestoresAtRoot(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Detached restore") + root := createWorkstreamPage(t, s, w.ID, "", "Root") + child := createWorkstreamPage(t, s, w.ID, root.ID, "Child") + grandchild := createWorkstreamPage(t, s, w.ID, child.ID, "Grandchild") + other := createWorkstreamPage(t, s, w.ID, root.ID, "Other") + last := createWorkstreamPage(t, s, w.ID, "", "Last") + if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { + t.Fatal(err) + } + if _, _, err := s.DeletePage(ctx, root.ID); err != nil { + t.Fatal(err) + } + restored, _, err := s.UndeletePage(ctx, child.ID) + if err != nil || restored.ParentID != "" || restored.Position != 2 || restored.Version != 1 { + t.Fatalf("descendant restore: %+v %v", restored, err) + } + nodes, err := s.Pages(ctx, w.ID) + if err != nil || len(nodes) != 4 || nodes[1].ID != last.ID || nodes[2].ID != child.ID || nodes[3].ID != grandchild.ID { + t.Fatalf("detached tree: %+v %v", nodes, err) + } + for _, id := range []string{root.ID, other.ID} { + p, err := s.Page(ctx, id, false) + if err != nil || p.DeletedAt.IsZero() { + t.Fatalf("unrelated deleted page: %+v %v", p, err) + } + } + rootRestored, _, err := s.UndeletePage(ctx, root.ID) + if err != nil || rootRestored.ParentID != "" || rootRestored.Position != 3 { + t.Fatalf("root append: %+v %v", rootRestored, err) + } + p, err := s.Page(ctx, child.ID, false) + if err != nil || p.ParentID != "" || p.Position != 2 { + t.Fatalf("later root restore moved detached child: %+v %v", p, err) + } + stream, err := s.Get(ctx, w.ID) + if err != nil || stream.DoneAt.IsZero() { + t.Fatalf("restore changed done state: %+v %v", stream, err) + } +} + +// WP-F1 (b): collection deletion still purges deleted pages and their saved +// versions through the existing cascade, while page.delete retains both. +func TestWorkstreamDeletedPagesCollectionCascade(t *testing.T) { + s, storage, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Page retention") + root := createWorkstreamPage(t, s, w.ID, "", "Root") + child := createWorkstreamPage(t, s, w.ID, root.ID, "Child") + if _, _, err := s.DeletePage(ctx, root.ID); err != nil { + t.Fatal(err) + } + for _, id := range []string{root.ID, child.ID} { + if p, err := s.PageAtVersion(ctx, id, 1, false); err != nil || p.DeletedAt.IsZero() { + t.Fatalf("retained version: %+v %v", p, err) + } + } + if err := storage.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", w.ID) + return err + }); err != nil { + t.Fatal(err) + } + if err := storage.Read(ctx, func(tx *sql.Tx, _ int) error { + for _, table := range []string{"workstream_pages", "workstream_page_versions"} { + var count int + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM "+table).Scan(&count); err != nil { + return err + } + if count != 0 { + t.Errorf("%s retained %d rows after collection purge", table, count) + } + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +// WP-F1 (c): the exported creation default trims Unicode space and uses case +// folding, while the store's existing explicit boolean remains authoritative. +func TestWorkstreamReadByJobsDefault(t *testing.T) { + for _, tc := range []struct { + title string + want bool + }{ + {"Spec", true}, {" decisions ", true}, {"DECISIONS", true}, {"\u2003sPeC\u2003", true}, + {"Spec notes", false}, {"Overview", false}, {"Notes", false}, {"", false}, + } { + if got := ReadByJobsDefault(tc.title); got != tc.want { + t.Errorf("%q: %v, want %v", tc.title, got, tc.want) + } + } + s, _, _ := testStore(t) + w := createStream(t, s, "Explicit store default") + p := createWorkstreamPage(t, s, w.ID, "", "Spec") + if p.ReadByJobs { + t.Fatal("store changed an explicit false to the title default") + } +} diff --git a/internal/workstream/workstream.go b/internal/workstream/workstream.go index ce31042bb1..247132bedf 100644 --- a/internal/workstream/workstream.go +++ b/internal/workstream/workstream.go @@ -157,6 +157,7 @@ type PageNode struct { UpdatedAt time.Time UpdatedBy string UpdatedFrom *Source + DeletedAt time.Time } type Page struct { PageNode From 4dcd5150f7d17792aea53494f44ef0ff9b58ddd9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:11:38 -0500 Subject: [PATCH 07/20] app: workstream agent prompt and tools (proposals, drafts, its own pages) (#2289) * workstream: agent pages stay the agent's, lookup by agent, markdown import The store now refuses, inside the save transaction, an agent write to a page the person owns, and an agent create of a page it would not own, so a page made the person's between a read and a save is still refused. ByAgent finds the workstream whose agent is a conversation. pagedoc.FromMarkdown turns markdown into the editor JSON the page renderer reads, live-block markers included, and the contract's example round-trips byte for byte. Co-Authored-By: Claude Opus 5.5 * session: a workstream agent's prompt section and six tools behind a door A conversation handed a WorkstreamDoor carries workstream_read, propose_decision, propose_question, propose_jobs, propose_goahead and write_agent_page, and a short prompt section naming its workstream and the rules: it proposes and the person keeps, it writes only its own pages, it never invents numbers and it starts nothing. Arguments outside each schema are refused before the door is touched, propose_jobs only appends drafts and retries a version conflict once, and a go-ahead proposal is a proposed decision that grants nothing. Without a door the belt and the page are unchanged, and task nodes, errands and clarifications never carry it. Co-Authored-By: Claude Opus 5.5 * app: give a workstream's agent its door, and re-read spend after its turns The app sets the workstream door on the conversation whose created-by is workstream and on no other; the door finds its workstream at each call, since the agent is opened a moment before the workstream row exists. Its writes are attributed to the agent and send workstream.changed, and a workstream agent's settled turn sends one too so its spend is read again. Co-Authored-By: Claude Opus 5.5 * manual: what a workstream's agent can do Co-Authored-By: Claude Opus 5.5 * docs: change entry for the workstream agent's tools Co-Authored-By: Claude Opus 5.5 * app: gofmt the workstream agent door test Co-Authored-By: Claude Opus 5.5 * workstream: agent writes check done in their transaction; markdown import is linear An agent's record or page write now refuses a done workstream inside the same write transaction, so marking done between the agent's read and its write leaves nothing behind. Imported list items always begin with a paragraph, and unmatched backticks or brackets are consumed in one pass instead of rescanning the rest of the line for each one. Co-Authored-By: Claude Opus 5.5 * session: workstream tools follow the argument and retry laws The prompt's workstream brief is a snapshot that never touches the store, so a re-render under the session lock cannot wait on it. Arguments decode only through decodeToolArguments, a repeated key is refused at any depth, every argument refusal is marked, and the plan's one retry is written without a counting loop. A go-ahead's proposed cap is dollars, excused from the whole-number law in its own file. Co-Authored-By: Claude Opus 5.5 * app: keep each workstream agent's brief current without reading the store The app remembers each workstream's name and why by its agent when it creates, reads, updates, marks done or reopens one, and when the agent's door resolves it, so the prompt reads that snapshot. A done refusal from the store reaches the agent as the workstream being done. Co-Authored-By: Claude Opus 5.5 * docs: workstream agent tools refuse repeated keys; done is checked in the write Co-Authored-By: Claude Opus 5.5 * session: import the workstream types in the validation test Co-Authored-By: Claude Opus 5.5 * workstream: only an explicitly attributed agent proposal is refused when done The done check in ProposeRecord read the defaulted actor, so the wire's unattributed record.propose on a done workstream, which has always landed as a proposal, was refused. It now reads the attribution the caller gave, and the agent's door keeps passing its own. Co-Authored-By: Claude Opus 5.5 * session: the workstream agent keeps its "Where it stands" page current The app shows the agent's summary on the Overview from the agent page titled exactly "Where it stands". The prompt and write_agent_page now name that page and ask the agent to keep it current with what it was updated from. Co-Authored-By: Claude Opus 5.5 * session: a shorter Where it stands rule, and room for it in the prompt budget The rule now fits in one short line, and the workstream section's own budget test allows 1000 bytes for the worst-case name and why. Co-Authored-By: Claude Opus 5.5 * tests: the conformance seed lends the agent the Overview for its one save The store now refuses an agent write to a page the person owns, so the seed that reproduces the contract's agent-saved Overview hands the page to the agent for that save and back again, keeping the agent's attribution and version. Co-Authored-By: Claude Opus 5.5 * tests: page version and restore fixtures write as the agent only on its pages The store now refuses an agent write to a page the person owns, so the version-read and deleted-tree fixtures that save or create as the agent make those pages the agent's first; what they assert is unchanged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/app.go | 6 +- cmd/codeaf/appworkstreams.go | 109 ++-- cmd/codeaf/appworkstreams_agent.go | 357 ++++++++++++ cmd/codeaf/appworkstreams_agent_brief_test.go | 122 +++++ cmd/codeaf/appworkstreams_agent_done_test.go | 59 ++ cmd/codeaf/appworkstreams_agent_test.go | 329 +++++++++++ cmd/codeaf/appworkstreams_conformance_test.go | 16 +- cmd/codeaf/appworkstreams_pages_test.go | 2 +- cmd/codeaf/engine.go | 1 + .../2289-app-workstream-agent-tools.md | 25 + internal/manual/chat/collections.md | 32 ++ internal/session/actioncategory.go | 5 +- internal/session/inherit.go | 2 +- internal/session/manual_test.go | 18 + internal/session/prompt.go | 1 + internal/session/questionconversation.go | 3 +- internal/session/session.go | 3 + internal/session/toolargs_test.go | 2 + internal/session/tools.go | 1 + internal/session/workstream_contract.go | 91 ++++ internal/session/workstream_factory_test.go | 108 ++++ internal/session/workstream_limits.go | 15 + internal/session/workstream_schema.go | 173 ++++++ internal/session/workstream_tools.go | 512 ++++++++++++++++++ internal/session/workstream_tools_test.go | 433 +++++++++++++++ .../session/workstream_validation_test.go | 159 ++++++ internal/workstream/pagedoc/frommarkdown.go | 359 ++++++++++++ .../workstream/pagedoc/frommarkdown_test.go | 136 +++++ internal/workstream/pages.go | 31 +- internal/workstream/pages_test.go | 11 +- internal/workstream/records.go | 5 +- internal/workstream/workstream_agent.go | 57 ++ .../workstream/workstream_agent_done_test.go | 155 ++++++ internal/workstream/workstream_agent_test.go | 106 ++++ 34 files changed, 3383 insertions(+), 61 deletions(-) create mode 100644 cmd/codeaf/appworkstreams_agent.go create mode 100644 cmd/codeaf/appworkstreams_agent_brief_test.go create mode 100644 cmd/codeaf/appworkstreams_agent_done_test.go create mode 100644 cmd/codeaf/appworkstreams_agent_test.go create mode 100644 docs/changes/unreleased/2289-app-workstream-agent-tools.md create mode 100644 internal/session/workstream_contract.go create mode 100644 internal/session/workstream_factory_test.go create mode 100644 internal/session/workstream_limits.go create mode 100644 internal/session/workstream_schema.go create mode 100644 internal/session/workstream_tools.go create mode 100644 internal/session/workstream_tools_test.go create mode 100644 internal/session/workstream_validation_test.go create mode 100644 internal/workstream/pagedoc/frommarkdown.go create mode 100644 internal/workstream/pagedoc/frommarkdown_test.go create mode 100644 internal/workstream/workstream_agent.go create mode 100644 internal/workstream/workstream_agent_done_test.go create mode 100644 internal/workstream/workstream_agent_test.go 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) + } +} From c54b647065d2b76aa626161245b625d8183f7547 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 03:51:20 -0500 Subject: [PATCH 08/20] ci: cmd/codeaf finishes inside its touched leg (busy case 200 ms, codeaf leg 25m) (#2296) * tests: the conformance busy case waits 200 ms for the lock, not 10 s The busy error case held a real writer on a second handle and let the backend's store wait out the full 10-second SQLite busy timeout. That one subtest was 10.0 of the workstream tests' 11.2 seconds in cmd/codeaf. It now sets PRAGMA busy_timeout to 200 ms on the backend's own single connection, reads it back and fails unless it took, then holds the same real writer. The refusal still travels SQLITE_BUSY -> ErrBusy -> wire busy through the real store and dispatch. Production keeps its 10 s. Co-Authored-By: Claude Opus 5.5 * ci: the codeaf touched leg gets a 25-minute package timeout cmd/codeaf runs unsharded (sharding it failed in #1734). Its serial suite measured 1065 s (17.8 min) on the 4-vCPU runner on 2026-11-03, 1053 s of it in tests that predate the workstream work, so the leg hit the Makefile's 15-minute timeout and every test after the cut went unchecked. The base branch's own runs time out the same way. Only the codeaf leg passes --timeout 25m; tui3, session and rest keep the Makefile's 15m. A timeout is still red. docs/rules/ci.md records the exception and that it goes once the suite is back under 15 minutes. Co-Authored-By: Claude Opus 5.5 * docs: change entry for the codeaf leg's timeout Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 ++++- cmd/codeaf/appworkstreams_conformance_test.go | 27 +++++++++++++++++-- .../2296-codeaf-leg-finishes-in-ci.md | 11 ++++++++ docs/rules/ci.md | 3 +++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f53233663..119f258b11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -325,7 +325,12 @@ jobs: # -p 2 permits concurrent compilation/testing in rest on the public # 4-vCPU / 16-GB runner, leaving headroom for the linker. tui3 and # session use four shards; codeaf runs its suite without sharding. - ./scripts/touched-verdict.sh run --base "$BASE" --shards 4 --report "$TMPDIR/$LEG.json" $PACKAGES + # CODEAF'S ONE TEST BINARY GETS 25 MINUTES, AND ONLY IT. Sharding cmd/codeaf + # was tried and its sharded runs failed (#1734), and its serial suite measured + # 1065 s (17.8 min) on this runner on 2026-11-03, over the Makefile's 15m, so + # every test after the cut went unchecked. The other legs pass no --timeout and + # keep the Makefile's 15m. Drop this once the suite is back under 15 minutes. + ./scripts/touched-verdict.sh run --base "$BASE" --shards 4 ${{ matrix.leg == 'codeaf' && '--timeout 25m' || '' }} --report "$TMPDIR/$LEG.json" $PACKAGES - uses: actions/upload-artifact@v4 if: always() with: diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go index e3cb621e43..c236c94605 100644 --- a/cmd/codeaf/appworkstreams_conformance_test.go +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -1294,10 +1294,33 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m } } -// Hold a writer on a separate handle to the same real store. The backend must -// exhaust the normal busy timeout and translate that refusal on the wire. +// Hold a real writer on a second handle to the same store. Shorten only the +// backend's lock wait to 200 ms on its single connection so the case proves the +// real SQLITE_BUSY -> ErrBusy -> wire "busy" path without waiting ten seconds. +// Production keeps internal/workspace's 10-second wait. The refusal still says +// "(waited 10s)" because storeError uses that constant; conformance checks the +// code and a non-empty message, not that text. func workstreamWireHoldWriter(t *testing.T, backend *appBackend) func() { t.Helper() + if backend.collections == nil { + t.Fatal("busy fixture must seed the backend's collections store before holding a writer") + } + const workstreamBusyTimeoutMS = 200 + if err := backend.collections.Write(context.Background(), func(tx *sql.Tx) error { + _, err := tx.Exec(fmt.Sprintf("PRAGMA busy_timeout = %d", workstreamBusyTimeoutMS)) + return err + }); err != nil { + t.Fatalf("set backend busy timeout: %v", err) + } + var ms int + if err := backend.collections.Read(context.Background(), func(tx *sql.Tx, _ int) error { + return tx.QueryRow("PRAGMA busy_timeout").Scan(&ms) + }); err != nil { + t.Fatalf("read backend busy timeout: %v", err) + } + if ms != workstreamBusyTimeoutMS { + t.Fatalf("backend busy timeout = %d ms, want %d ms", ms, workstreamBusyTimeoutMS) + } other, err := workspace.Open(backend.collectionsPath) if err != nil { t.Fatal(err) diff --git a/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md b/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md new file mode 100644 index 0000000000..43ebe1f0cd --- /dev/null +++ b/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md @@ -0,0 +1,11 @@ +--- +kind: internal +title: The cmd/codeaf touched leg gets a 25-minute timeout; the conformance busy case waits 200 ms +pr: 2296 +surface: [build] +invalidates: [] +--- + +The package's serial suite measured 17.8 minutes on the CI runner, over the +15-minute timeout, so tests after the cut went unchecked. Only the codeaf leg +changes; the other legs keep 15 minutes. diff --git a/docs/rules/ci.md b/docs/rules/ci.md index 26957c0922..0430a2e3d1 100644 --- a/docs/rules/ci.md +++ b/docs/rules/ci.md @@ -15,6 +15,9 @@ root-module packages. Module changes select the whole tree; changes without Go or module edits select no package leg. Tests run fresh with `-count=1` in separate chat, session, command and remaining-package jobs. Heavy packages are sharded from one compiled test binary, with a 15-minute timeout per shard. +`cmd/codeaf` runs unsharded in its own leg; its serial suite measured 17.8 +minutes on 2026-11-03, so that leg alone passes a 25-minute package timeout. +Drop this exception once the suite is back under 15 minutes. ## Local verification From c1b540d56df1f6000385f0d518738519142dcebf Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 05:09:02 -0500 Subject: [PATCH 09/20] app: workstreams wire v0.2: draft size/budget/source, page sources, row agent ids, goahead proposals that grant when kept (#2297) * workstream: draft size/budget/source, and go-ahead proposals that grant when kept Draft plan items keep an optional size, budget and source. A goahead record carries its proposed scope, cap and end; keeping it grants exactly one go-ahead in the same write, dropping a kept one revokes it. Schema 3 gains the record columns in place. Co-Authored-By: Claude Opus 5.5 * app: workstreams wire v0.2 (row agent ids, page sources, goahead records) and conformance Co-Authored-By: Claude Opus 5.5 * session: the workstream agent proposes go-aheads as goahead records and sources its draft jobs Co-Authored-By: Claude Opus 5.5 * changes: number the workstreams wire v0.2 entry Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 37 +- cmd/codeaf/appworkstreams_agent.go | 61 +-- cmd/codeaf/appworkstreams_agent_test.go | 15 +- cmd/codeaf/appworkstreams_conformance_test.go | 58 ++- cmd/codeaf/appworkstreams_fields_v02_test.go | 308 ++++++++++++++ .../testdata/workstreams-wire/contract.json | 82 +++- .../workstreams-wire/page.create.json | 12 + .../testdata/workstreams-wire/page.save.json | 37 ++ .../workstreams-wire/record.keep.json | 28 ++ .../workstreams-wire/record.list.json | 29 ++ .../workstreams-wire/record.propose.json | 41 ++ .../workstreams-wire/workstream.list.json | 7 +- .../workstreams-wire/workstream.plan_set.json | 20 +- .../2297-app-workstreams-wire-v02.md | 24 ++ internal/appwire/workstreams.go | 111 +++-- internal/appwire/workstreams_check.go | 36 +- .../appwire/workstreams_fields_v02_test.go | 84 ++++ internal/manual/chat/collections.md | 45 +- internal/session/workstream_contract.go | 3 +- .../session/workstream_fields_v02_test.go | 90 ++++ internal/session/workstream_limits.go | 11 +- internal/session/workstream_schema.go | 6 +- internal/session/workstream_tools.go | 39 +- internal/session/workstream_tools_test.go | 40 +- .../session/workstream_validation_test.go | 3 +- internal/workspace/workstreams.go | 7 +- .../workspace/workstreams_fields_v02_test.go | 43 ++ internal/workstream/goaheads.go | 161 ++++--- internal/workstream/plan.go | 21 + internal/workstream/records.go | 95 ++++- internal/workstream/store.go | 8 + internal/workstream/workstream.go | 34 +- .../workstream/workstream_fields_v02_test.go | 398 ++++++++++++++++++ 33 files changed, 1744 insertions(+), 250 deletions(-) create mode 100644 cmd/codeaf/appworkstreams_fields_v02_test.go create mode 100644 docs/changes/unreleased/2297-app-workstreams-wire-v02.md create mode 100644 internal/appwire/workstreams_fields_v02_test.go create mode 100644 internal/session/workstream_fields_v02_test.go create mode 100644 internal/workspace/workstreams_fields_v02_test.go create mode 100644 internal/workstream/workstream_fields_v02_test.go diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index b504b922fe..d106961b61 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -192,7 +192,7 @@ func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.Wor } 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}) + 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, AgentID: v.AgentID, OriginChatID: v.OriginChatID}) } return out, nil } @@ -461,7 +461,7 @@ func appWorkstreamPlan(p workstream.Plan) appwire.WorkstreamPlan { for _, w := range p.Waves { wave := appwire.WorkstreamWave{N: w.N, Title: workstreamOptional(w.Title), MilestoneID: workstreamOptional(w.MilestoneID), AutoStart: w.AutoStart, Items: []appwire.WorkstreamPlanItem{}} for _, i := range w.Items { - wave.Items = append(wave.Items, appwire.WorkstreamPlanItem{Kind: i.Kind, Factory: workstreamOptional(i.Factory), Job: workstreamPositive(i.Job), DraftID: workstreamOptional(i.DraftID), Title: workstreamOptional(i.Title), Repo: workstreamOptional(i.Repo), Text: workstreamOptional(i.Text)}) + wave.Items = append(wave.Items, appwire.WorkstreamPlanItem{Kind: i.Kind, Factory: workstreamOptional(i.Factory), Job: workstreamPositive(i.Job), DraftID: workstreamOptional(i.DraftID), Title: workstreamOptional(i.Title), Repo: workstreamOptional(i.Repo), Text: workstreamOptional(i.Text), Size: workstreamOptional(i.Size), BudgetUSD: workstreamDraftBudget(i.BudgetUSD), Source: appWorkstreamSource(i.Source)}) } out.Waves = append(out.Waves, wave) } @@ -483,7 +483,7 @@ func (b *appBackend) WorkstreamPlanSet(p appwire.WorkstreamPlanSetParams) (appwi for _, w := range p.Waves { wave := workstream.Wave{N: w.N, Title: workstreamValue(w.Title), MilestoneID: workstreamValue(w.MilestoneID), AutoStart: w.AutoStart, Items: []workstream.PlanItem{}} for _, i := range w.Items { - wave.Items = append(wave.Items, workstream.PlanItem{Kind: i.Kind, Factory: workstreamValue(i.Factory), Job: workstreamValue(i.Job), DraftID: workstreamValue(i.DraftID), Title: workstreamValue(i.Title), Repo: workstreamValue(i.Repo), Text: workstreamValue(i.Text)}) + wave.Items = append(wave.Items, workstream.PlanItem{Kind: i.Kind, Factory: workstreamValue(i.Factory), Job: workstreamValue(i.Job), DraftID: workstreamValue(i.DraftID), Title: workstreamValue(i.Title), Repo: workstreamValue(i.Repo), Text: workstreamValue(i.Text), Size: workstreamValue(i.Size), BudgetUSD: workstreamValue(i.BudgetUSD), Source: workstreamSource(i.Source)}) } waves = append(waves, wave) } @@ -492,6 +492,13 @@ func (b *appBackend) WorkstreamPlanSet(p appwire.WorkstreamPlanSetParams) (appwi }) } +func workstreamDraftBudget(budget float64) *float64 { + if budget == 0 { + return nil + } + return &budget +} + func appWorkstreamSource(p *workstream.Source) *appwire.WorkstreamSource { if p == nil { return nil @@ -543,7 +550,7 @@ func (b *appBackend) WorkstreamPageGet(p appwire.WorkstreamPageGetParams) (appwi } func (b *appBackend) WorkstreamPageSave(p appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageSaveResult, workstream.Change, error) { - v, c, err := s.SavePage(context.Background(), p.ID, p.BaseVersion, p.Doc, workstream.Attribution{}) + v, c, err := s.SavePage(context.Background(), p.ID, p.BaseVersion, p.Doc, workstream.Attribution{From: workstreamSource(p.Source)}) return appwire.WorkstreamPageSaveResult{Page: appWorkstreamPage(v)}, c, err }) } @@ -553,7 +560,7 @@ func (b *appBackend) WorkstreamPageCreate(p appwire.WorkstreamPageCreateParams) if p.ReadByJobs != nil { read = *p.ReadByJobs } - v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: read, Doc: p.Doc}) + v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: read, Doc: p.Doc, Attribution: workstream.Attribution{From: workstreamSource(p.Source)}}) return appwire.WorkstreamPageCreateResult{Page: appWorkstreamPage(v)}, c, err }) } @@ -604,11 +611,25 @@ func (b *appBackend) WorkstreamPageRestore(p appwire.WorkstreamPageRestoreParams } func appWorkstreamRecord(r workstream.Record) appwire.WorkstreamRecord { - return appwire.WorkstreamRecord{ID: r.ID, WorkstreamID: r.WorkstreamID, Kind: r.Kind, Text: r.Text, State: r.State, By: r.By, Source: appWorkstreamSource(r.Source), CreatedAt: r.CreatedAt, DecidedAt: r.DecidedAt, Supersedes: workstreamOptional(r.Supersedes), SupersededBy: workstreamOptional(r.SupersededBy)} + return appwire.WorkstreamRecord{ID: r.ID, WorkstreamID: r.WorkstreamID, Kind: r.Kind, Text: r.Text, State: r.State, By: r.By, Source: appWorkstreamSource(r.Source), CreatedAt: r.CreatedAt, DecidedAt: r.DecidedAt, Supersedes: workstreamOptional(r.Supersedes), SupersededBy: workstreamOptional(r.SupersededBy), GoAhead: appWorkstreamGoAheadProposal(r.GoAhead), GoAheadID: workstreamOptional(r.GoAheadID)} +} + +func appWorkstreamGoAheadProposal(p *workstream.GoAheadProposal) *appwire.WorkstreamGoAheadProposal { + if p == nil { + return nil + } + return &appwire.WorkstreamGoAheadProposal{Scope: p.Scope, Factory: workstreamOptional(p.Factory), Job: workstreamPositive(p.Job), Wave: workstreamPositive(p.Wave), MilestoneID: workstreamOptional(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt} +} + +func workstreamGoAheadProposal(p *appwire.WorkstreamGoAheadProposal) *workstream.GoAheadProposal { + if p == nil { + return nil + } + return &workstream.GoAheadProposal{Scope: p.Scope, Factory: workstreamValue(p.Factory), Job: workstreamValue(p.Job), Wave: workstreamValue(p.Wave), MilestoneID: workstreamValue(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt} } func (b *appBackend) WorkstreamRecordList(p appwire.WorkstreamRecordListParams) (appwire.WorkstreamRecordListResult, error) { return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordListResult, workstream.Change, error) { - records, err := s.Records(context.Background(), p.WorkstreamID, workstreamValue(p.Kind), workstreamValue(p.State)) + records, err := s.Records(context.Background(), p.WorkstreamID, workstreamValue(p.Kind), workstreamValue(p.State), workstreamValue(p.IncludeGoAheads)) out := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} for _, r := range records { out.Records = append(out.Records, appWorkstreamRecord(r)) @@ -618,7 +639,7 @@ func (b *appBackend) WorkstreamRecordList(p appwire.WorkstreamRecordListParams) } func (b *appBackend) WorkstreamRecordPropose(p appwire.WorkstreamRecordProposeParams) (appwire.WorkstreamRecordProposeResult, error) { return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordProposeResult, workstream.Change, error) { - v, c, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: p.WorkstreamID, Kind: p.Kind, Text: p.Text, Source: workstreamSource(p.Source), Kept: workstreamValue(p.Kept)}) + v, c, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: p.WorkstreamID, Kind: p.Kind, Text: p.Text, Source: workstreamSource(p.Source), Kept: workstreamValue(p.Kept), GoAhead: workstreamGoAheadProposal(p.GoAhead)}) return appwire.WorkstreamRecordProposeResult{Record: appWorkstreamRecord(v)}, c, err }) } diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go index 9fa75788a6..94fbf8767e 100644 --- a/cmd/codeaf/appworkstreams_agent.go +++ b/cmd/codeaf/appworkstreams_agent.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/url" "strconv" "sync/atomic" + "time" "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/session" @@ -133,7 +133,7 @@ func workstreamAgentReadPages(ctx context.Context, s *workstream.Store, id strin } 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) + records, err := s.Records(ctx, id, ask.Kind, ask.State, true) if err != nil { return nil, err } @@ -169,26 +169,39 @@ func (d *workstreamAgentDoor) Propose(ctx context.Context, p session.WorkstreamP if err != nil { return session.WorkstreamProposed{}, err } + var proposal *workstream.GoAheadProposal if p.GoAhead != nil { - p.Kind = "decision" - p.Text, err = d.workstreamGoAheadText(ctx, s, w, *p.GoAhead) + proposal, err = d.workstreamGoAheadProposal(*p.GoAhead) if err != nil { return session.WorkstreamProposed{}, err } + p.Kind, p.Text = "goahead", p.GoAhead.Why } 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}) + r, change, err := s.ProposeRecord(ctx, workstream.RecordPropose{WorkstreamID: w.ID, Kind: p.Kind, Text: p.Text, Source: source, By: "agent", Kept: false, GoAhead: proposal}) d.backend.workstreamChanged(change) return session.WorkstreamProposed{ID: r.ID}, workstreamAgentError(err) } +// Source resolves a draft call once, so its conflict retry keeps that attribution. +func (d *workstreamAgentDoor) Source(ctx context.Context, asked *workstream.Source) (*workstream.Source, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return nil, err + } + return d.workstreamSource(ctx, s, w, asked) +} + 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 } + if err := appWorkstreamSource(asked).Check(); err != nil { + return nil, err + } source := *asked if source.ChatID != "" || source.Kind != "factory_job" && source.Factory != "" { return nil, errors.New("The source has fields for another kind.") @@ -256,42 +269,12 @@ func (d *workstreamAgentDoor) SetPlan(ctx context.Context, base int, p session.W return workstreamAgentPlan(saved), workstreamAgentError(err) } -func (d *workstreamAgentDoor) workstreamGoAheadText(ctx context.Context, s *workstream.Store, w workstream.Workstream, ask session.WorkstreamGoAheadAsk) (string, error) { +func (d *workstreamAgentDoor) workstreamGoAheadProposal(ask session.WorkstreamGoAheadAsk) (*workstream.GoAheadProposal, 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 nil, err } - return "", errors.New("The proposed go-ahead milestone must exist in the plan.") + end, _ := time.Parse("2006-01-02", ask.EndsOn) + return &workstream.GoAheadProposal{Scope: ask.Scope, Factory: ask.Factory, Job: ask.Job, Wave: ask.Wave, MilestoneID: ask.MilestoneID, CapUSD: ask.CapUSD, EndsAt: end.Add(24*time.Hour - time.Second)}, nil } func (d *workstreamAgentDoor) WritePage(ctx context.Context, ask session.WorkstreamPageWrite) (session.WorkstreamPageWritten, error) { diff --git a/cmd/codeaf/appworkstreams_agent_test.go b/cmd/codeaf/appworkstreams_agent_test.go index 4f0f1393cb..75016158a8 100644 --- a/cmd/codeaf/appworkstreams_agent_test.go +++ b/cmd/codeaf/appworkstreams_agent_test.go @@ -89,9 +89,9 @@ func TestWorkstreamAgentRecordsStayProposed(t *testing.T) { 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"}, + {session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 12.5, EndsOn: "2026-11-04", Why: "Review"}, "Review"}, + {session.WorkstreamGoAheadAsk{Scope: "wave", Wave: 2, CapUSD: 20, EndsOn: "2026-11-04", Why: "Read"}, "Read"}, + {session.WorkstreamGoAheadAsk{Scope: "milestone", MilestoneID: "m1", CapUSD: 30, EndsOn: "2026-11-04", Why: "Ship"}, "Ship"}, } s, _ := b.workstreamStore(false) for _, tc := range cases { @@ -99,12 +99,13 @@ func TestWorkstreamAgentRecordsStayProposed(t *testing.T) { 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, "goahead", "proposed") + want := &workstream.GoAheadProposal{Scope: tc.ask.Scope, Factory: tc.ask.Factory, Job: tc.ask.Job, Wave: tc.ask.Wave, MilestoneID: tc.ask.MilestoneID, CapUSD: tc.ask.CapUSD, EndsAt: time.Date(2026, 11, 4, 23, 59, 59, 0, time.UTC)} + if err != nil || len(rows) == 0 || rows[0].ID != p.ID || rows[0].Text != tc.text || !reflect.DeepEqual(rows[0].GoAhead, want) || rows[0].GoAheadID != "" { + t.Fatalf("go-ahead proposal: %+v %v", rows, err) } } - rows, err := s.Records(ctx, w.ID, "", "") + rows, err := s.Records(ctx, w.ID, "", "", true) if err != nil || len(rows) != 5 { t.Fatalf("records: %+v %v", rows, err) } diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go index c236c94605..28e307a029 100644 --- a/cmd/codeaf/appworkstreams_conformance_test.go +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -527,7 +527,10 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir unknown := name == "workstream.list" && situation == 1 params := appwire.WorkstreamCreateParams{Name: "Usage-based billing", Why: workstreamPtr("Charge for recorded usage.")} if !zero { - params.OriginChatID = &origin.ID + // WP-F2 (c), (f): the unknown list example deliberately has no origin. + if !unknown { + params.OriginChatID = &origin.ID + } if name != "collections.add" { ref := workstreamJobRef("1107") ref.Factory = workstreamPtr("f_local") @@ -580,6 +583,13 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir } case "page.history": fixture.saveByAgent(t) + case "page.save": + // WP-F2 (b), (f): example_1 saves version 3 with a source. + if situation == 1 { + if _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: fixture.IDs["pg_31d0"], BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { + t.Fatal(err) + } + } // WP-F1 (g): prepare all three additive example_1 pairs with the exact // optional keys illustrated by the wire contract. case "page.list": @@ -598,13 +608,22 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir } } if name == "record.list" || name == "record.keep" || name == "record.drop" || name == "record.supersede" || name == "workstream.changed" { - fixture.record(t) + // WP-F2 (d), (f): only the illustrated kind is present in each example. + if situation == 1 && (name == "record.list" || name == "record.keep") { + fixture.plan(t) + fixture.goaheadRecord(t) + } else { + fixture.record(t) + } if name == "record.supersede" { if _, err := backend.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: fixture.IDs["rec_7a1e"]}); err != nil { t.Fatal(err) } } } + if name == "record.propose" && situation == 1 { + fixture.plan(t) + } if strings.HasPrefix(name, "goahead.") || name == "workstream.changed" { fixture.plan(t) if name != "goahead.grant" { @@ -677,6 +696,18 @@ func (fixture *workstreamWireFixture) record(t *testing.T) { fixture.IDs["rec_7a1e"] = record.Record.ID } +func (fixture *workstreamWireFixture) goaheadRecord(t *testing.T) { + t.Helper() + record, err := fixture.Backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{ + WorkstreamID: fixture.Stream.ID, Kind: "goahead", Text: "Wave 1 is small; let it run without asking.", Source: fixture.source(), + GoAhead: &appwire.WorkstreamGoAheadProposal{Scope: "wave", Wave: workstreamPtr(1), CapUSD: 40, EndsAt: fixture.Now.Add(time.Hour)}, + }) + if err != nil { + t.Fatal(err) + } + fixture.IDs["rec_5c20"] = record.Record.ID +} + func (fixture *workstreamWireFixture) plan(t *testing.T) { t.Helper() _, err := fixture.Backend.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ @@ -1009,6 +1040,9 @@ func TestAppWorkstreamWireConformance(t *testing.T) { if name == "collections.add" { workstreamWireCollectionResult(t, result) } + if name == "record.keep" && i == 1 { + workstreamWireKeptGoAhead(t, connection, fixture.Stream.ID, result) + } }) } }) @@ -1035,6 +1069,26 @@ func TestAppWorkstreamWireConformance(t *testing.T) { }) } +// WP-F2 (d), (f): the keep example grants exactly its structured permission. +func workstreamWireKeptGoAhead(t *testing.T, connection *workstreamWireConnection, id string, result any) { + t.Helper() + record := result.(map[string]any)["record"].(map[string]any) + proposal := record["goahead"].(map[string]any) + listed := workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": id})).(map[string]any)["goaheads"].([]any) + if len(listed) != 1 { + t.Fatalf("keep granted %d go-aheads, want one", len(listed)) + } + grant := listed[0].(map[string]any) + if grant["id"] != record["goahead_id"] || grant["state"] != "active" { + t.Fatalf("keep grant identity/state: %v %v", record, grant) + } + for _, key := range []string{"scope", "wave", "cap_usd", "ends_at"} { + if !reflect.DeepEqual(grant[key], proposal[key]) { + t.Errorf("keep grant %s: got %v, want %v", key, grant[key], proposal[key]) + } + } +} + func workstreamWireCoverage(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, cases []string, errorCases []workstreamWireErrorCase) { t.Helper() if !reflect.DeepEqual(contract.Methods, appwire.WorkstreamMethods) { diff --git a/cmd/codeaf/appworkstreams_fields_v02_test.go b/cmd/codeaf/appworkstreams_fields_v02_test.go new file mode 100644 index 0000000000..17859777e6 --- /dev/null +++ b/cmd/codeaf/appworkstreams_fields_v02_test.go @@ -0,0 +1,308 @@ +package main + +import ( + "context" + "encoding/json" + "math" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// WP-F2 (a): real plan_set/get dispatch preserves draft fields and refuses atomically. +func TestAppWorkstreamDraftFieldsWireRoundTrip(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Draft wire", workstreamJobRef("7")) + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + item := map[string]any{"kind": "draft", "draft_id": "d", "title": "Draft", "size": "M", "budget_usd": 25.0, "source": map[string]any{"kind": "message", "id": "m", "chat_id": "chat", "label": "Scope"}} + params := map[string]any{"id": w.ID, "base_version": 0, "milestones": []any{}, "waves": []any{map[string]any{"n": 1, "auto_start": false, "items": []any{item}}}} + set := workstreamWireResult(t, connection.call(t, "workstream.plan_set", params)) + get := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) + if !reflect.DeepEqual(set, get) { + t.Fatalf("round trip: %v %v", set, get) + } + draft := get.(map[string]any)["plan"].(map[string]any)["waves"].([]any)[0].(map[string]any)["items"].([]any)[0] + want := workstreamWireDecode(t, json.RawMessage(`{"kind":"draft","draft_id":"d","title":"Draft","size":"M","budget_usd":25,"source":{"kind":"message","id":"m","chat_id":"chat","label":"Scope"}}`)) + if !reflect.DeepEqual(draft, want) { + t.Fatalf("draft fields: %v", draft) + } + before := len(workstreamEvents(rec)) + for _, invalid := range []map[string]any{ + {"kind": "job", "factory": "f", "job": 7, "size": "S"}, + {"kind": "job", "factory": "f", "job": 7, "budget_usd": 1}, + {"kind": "job", "factory": "f", "job": 7, "source": map[string]any{"kind": "chat", "id": "c"}}, + {"kind": "draft", "draft_id": "d", "title": "Draft", "size": "XL"}, + {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": 0}, + {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": -1}, + {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": 10000.01}, + {"kind": "draft", "draft_id": "d", "title": "Draft", "source": map[string]any{"kind": "message", "id": "m"}}, + } { + params["base_version"] = 1 + params["waves"] = []any{map[string]any{"n": 1, "auto_start": false, "items": []any{invalid}}} + frame := connection.call(t, "workstream.plan_set", params) + if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { + t.Fatalf("refusal: %v", frame) + } + after := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) + if !reflect.DeepEqual(after, get) { + t.Fatalf("refusal changed plan: %v", after) + } + } + _, err := b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: w.ID, BaseVersion: 1, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{{N: 1, Items: []appwire.WorkstreamPlanItem{{Kind: "draft", DraftID: workstreamPtr("d"), Title: workstreamPtr("Draft"), BudgetUSD: workstreamPtr(math.NaN())}}}}}) + workstreamCheckError(t, err, appwire.CodeBadParams) + after := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) + if !reflect.DeepEqual(after, get) { + t.Fatalf("NaN refusal changed plan: %v", after) + } + if len(workstreamEvents(rec)) != before { + t.Fatal("refused plan emitted a change") + } +} + +// WP-F2 (b): save/create source belongs to that version, and absent source clears it. +func TestAppWorkstreamPageSourcesThroughWire(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Page source") + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + source := map[string]any{"kind": "message", "id": "message", "chat_id": "chat", "label": "Scope"} + doc := map[string]any{"type": "doc", "content": []any{}} + created := workstreamWireResult(t, connection.call(t, "page.create", map[string]any{"workstream_id": w.ID, "title": "Notes", "source": source, "doc": doc})).(map[string]any)["page"].(map[string]any) + id := created["id"] + if !reflect.DeepEqual(created["updated_from"], source) { + t.Fatalf("create source: %v", created) + } + saved := workstreamWireResult(t, connection.call(t, "page.save", map[string]any{"id": id, "base_version": 1, "doc": doc, "source": source})).(map[string]any)["page"].(map[string]any) + if !reflect.DeepEqual(saved["updated_from"], source) { + t.Fatalf("save source: %v", saved) + } + last := workstreamWireResult(t, connection.call(t, "page.save", map[string]any{"id": id, "base_version": 2, "doc": doc})).(map[string]any)["page"].(map[string]any) + if _, exists := last["updated_from"]; exists { + t.Fatalf("source retained: %v", last) + } + history := workstreamWireResult(t, connection.call(t, "page.history", map[string]any{"id": id})).(map[string]any)["versions"].([]any) + if len(history) != 3 { + t.Fatalf("history: %v", history) + } + if _, exists := history[0].(map[string]any)["from"]; exists { + t.Fatalf("source retained in history: %v", history) + } + for _, version := range history[1:] { + if !reflect.DeepEqual(version.(map[string]any)["from"], source) { + t.Fatalf("version source: %v", version) + } + } + before := last + for _, method := range []string{"page.save", "page.create"} { + params := map[string]any{"id": id, "base_version": 3, "doc": doc, "workstream_id": w.ID, "title": "Invalid", "source": map[string]any{"kind": "message", "id": "m"}} + frame := connection.call(t, method, params) + if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { + t.Fatalf("invalid source: %v", frame) + } + } + current := workstreamWireResult(t, connection.call(t, "page.get", map[string]any{"id": id})).(map[string]any)["page"] + if !reflect.DeepEqual(current, before) { + t.Fatalf("invalid source changed page: %v", current) + } +} + +// WP-F2 (c): list gets both conversation identities from existing row data. +func TestAppWorkstreamListConversationIDsThroughWire(t *testing.T) { + b, _, _, origin := workstreamBackend(t) + first, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "With origin", OriginChatID: &origin.ID}) + if err != nil { + t.Fatal(err) + } + second := workstreamCreateForTest(t, b, "Without origin") + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + rows := workstreamWireResult(t, connection.call(t, "workstream.list", map[string]any{})).(map[string]any)["workstreams"].([]any) + if len(rows) != 2 { + t.Fatalf("rows: %v", rows) + } + for _, raw := range rows { + row := raw.(map[string]any) + get := workstreamWireResult(t, connection.call(t, "workstream.get", map[string]any{"id": row["id"]})).(map[string]any)["workstream"].(map[string]any) + if row["agent_id"] == "" || row["agent_id"] != get["agent_id"] { + t.Fatalf("agent id: %v %v", row, get) + } + if row["id"] == first.Workstream.ID && row["origin_chat_id"] != origin.ID { + t.Fatalf("origin: %v", row) + } + if row["id"] == second.ID { + if _, exists := row["origin_chat_id"]; exists { + t.Fatalf("absent origin: %v", row) + } + } + } +} + +// WP-F2 (e): the adapter resolves real sources before any draft can be added. +func TestAppWorkstreamAgentDraftSourceResolution(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Spec"}) + if err != nil { + t.Fatal(err) + } + for _, asked := range []*workstream.Source{nil, {Kind: "page", ID: page.Page.ID}} { + source, err := door.Source(ctx, asked) + if err != nil { + t.Fatal(err) + } + want := &workstream.Source{Kind: "chat", ID: w.AgentID} + if asked != nil { + want = asked + } + if !reflect.DeepEqual(source, want) { + t.Fatalf("source: %+v", source) + } + plan, err := door.Plan(ctx) + if err != nil { + t.Fatal(err) + } + n := len(plan.Waves) + 1 + plan.Waves = append(plan.Waves, workstream.Wave{N: n, Items: []workstream.PlanItem{{Kind: "draft", DraftID: source.Kind, Title: "Draft", Source: source}}}) + saved, err := door.SetPlan(ctx, plan.Version, plan) + if err != nil || !reflect.DeepEqual(saved.Waves[n-1].Items[0].Source, want) { + t.Fatalf("saved source: %+v %v", saved, err) + } + } + before, err := door.Plan(ctx) + if err != nil { + t.Fatal(err) + } + for _, source := range []*workstream.Source{{Kind: "page", ID: "missing"}, {Kind: "factory_job", ID: "99", Factory: "f"}, {Kind: "url", ID: "file:///tmp/source"}, {Kind: "page", ID: page.Page.ID, ChatID: "chat"}} { + if _, err := door.Source(ctx, source); err == nil { + t.Fatalf("accepted source: %+v", source) + } + } + after, err := door.Plan(ctx) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("source refusal changed plan: %+v %v", after, err) + } +} + +// WP-F2 (e), (f): agent reads include proposed and kept go-ahead structures. +func TestAppWorkstreamAgentReadsGoAheadRecords(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + ask := session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 10, EndsOn: b.now().AddDate(0, 0, 89).Format("2006-01-02"), Why: "Review"} + proposal, err := door.Propose(ctx, session.WorkstreamProposal{GoAhead: &ask}) + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"", "goahead"} { + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "records", Kind: kind}) + var records appwire.WorkstreamRecordListResult + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &records); err != nil || len(records.Records) != 1 || records.Records[0].Kind != "goahead" || records.Records[0].GoAhead == nil { + t.Fatalf("read: %s %v", raw, err) + } + } + kept, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: proposal.ID}) + if err != nil { + t.Fatal(err) + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "records"}) + var records appwire.WorkstreamRecordListResult + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &records); err != nil || len(records.Records) != 1 || !reflect.DeepEqual(records.Records[0], kept.Record) { + t.Fatalf("kept read: %s %v", raw, err) + } + grant, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID}) + if err != nil || len(grant.GoAheads) != 1 || grant.GoAheads[0].EndsAt.Hour() != 23 || grant.GoAheads[0].EndsAt.Minute() != 59 || grant.GoAheads[0].EndsAt.Second() != 59 { + t.Fatalf("end of day: %+v %v", grant, err) + } + ask.EndsOn = b.now().AddDate(0, 0, 90).Format("2006-01-02") + if _, err := door.Propose(ctx, session.WorkstreamProposal{GoAhead: &ask}); err == nil { + t.Fatal("90-day end accepted") + } +} + +// WP-F2 (d), (f): real wire dispatch exposes grants, undo and compatibility filters. +func TestAppWorkstreamGoAheadRecordsThroughWire(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) + b.now = func() time.Time { return now } + w := workstreamCreateForTest(t, b, "Record wire", workstreamJobRef("7")) + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + proposal := map[string]any{"scope": "job", "factory": "f", "job": 7, "cap_usd": 12.5, "ends_at": now.Add(time.Hour).Format(time.RFC3339)} + raw := workstreamWireResult(t, connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": "goahead", "text": "Why", "goahead": proposal})).(map[string]any)["record"].(map[string]any) + id := raw["id"] + if raw["kind"] != "goahead" || raw["state"] != "proposed" || raw["text"] != "Why" { + t.Fatalf("proposal: %v", raw) + } + for _, kind := range []string{"decision", "question"} { + workstreamWireResult(t, connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": kind, "text": kind})) + } + for _, tc := range []struct { + kind string + include *bool + count int + }{{"", nil, 2}, {"", workstreamPtr(false), 2}, {"", workstreamPtr(true), 3}, {"goahead", nil, 1}, {"question", workstreamPtr(true), 1}} { + params := map[string]any{"workstream_id": w.ID} + if tc.kind != "" { + params["kind"] = tc.kind + } + if tc.include != nil { + params["include_goaheads"] = *tc.include + } + rows := workstreamWireResult(t, connection.call(t, "record.list", params)).(map[string]any)["records"].([]any) + if len(rows) != tc.count { + t.Fatalf("filter %+v: %v", tc, rows) + } + for _, row := range rows { + kind := row.(map[string]any)["kind"] + if tc.kind != "" && kind != tc.kind || tc.kind == "" && (tc.include == nil || !*tc.include) && kind == "goahead" { + t.Fatalf("filter %+v: %v", tc, rows) + } + } + } + kept := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) + grants := workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID})).(map[string]any)["goaheads"].([]any) + if len(grants) != 1 || grants[0].(map[string]any)["id"] != kept["goahead_id"] { + t.Fatalf("keep: %v %v", kept, grants) + } + before := len(workstreamEvents(rec)) + again := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"] + grants = workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID})).(map[string]any)["goaheads"].([]any) + if !reflect.DeepEqual(again, kept) || len(grants) != 1 || len(workstreamEvents(rec)) != before { + t.Fatal("repeat keep changed state") + } + for _, kind := range []string{"", "decision", "goahead"} { + params := map[string]any{"id": id, "text": "Replacement"} + if kind != "" { + params["kind"] = kind + } + frame := connection.call(t, "record.supersede", params) + if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { + t.Fatalf("supersede: %v", frame) + } + } + dropped := workstreamWireResult(t, connection.call(t, "record.drop", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) + grants = workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID, "ended": true})).(map[string]any)["goaheads"].([]any) + if dropped["state"] != "dropped" || dropped["goahead_id"] != kept["goahead_id"] || len(grants) != 1 || grants[0].(map[string]any)["end_reason"] != "revoked" { + t.Fatalf("drop: %v %v", dropped, grants) + } + newKeep := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) + if newKeep["goahead_id"] == kept["goahead_id"] { + t.Fatal("re-keep reused the grant") + } + workstreamWireResult(t, connection.call(t, "record.drop", map[string]any{"id": id})) + workstreamWireResult(t, connection.call(t, "workstream.mark_done", map[string]any{"id": w.ID})) + frame := connection.call(t, "record.keep", map[string]any{"id": id}) + if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "conflict" { + t.Fatalf("keep done: %v", frame) + } + frame = connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": "goahead", "text": "Why", "goahead": proposal}) + if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "conflict" { + t.Fatalf("propose done: %v", frame) + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/contract.json b/cmd/codeaf/testdata/workstreams-wire/contract.json index 6dd50cef8e..b3959d0c9c 100644 --- a/cmd/codeaf/testdata/workstreams-wire/contract.json +++ b/cmd/codeaf/testdata/workstreams-wire/contract.json @@ -119,9 +119,15 @@ "job", "draft" ], + "WorkstreamDraftSize": [ + "S", + "M", + "L" + ], "WorkstreamRecordKind": [ "decision", - "question" + "question", + "goahead" ], "WorkstreamRecordState": [ "proposed", @@ -239,6 +245,14 @@ "updated_at": { "type": "string (RFC 3339)", "required": true + }, + "agent_id": { + "type": "string", + "required": true + }, + "origin_chat_id": { + "type": "string", + "required": false } }, "Workstream": { @@ -397,6 +411,18 @@ "text": { "type": "string", "required": false + }, + "size": { + "type": "WorkstreamDraftSize", + "required": false + }, + "budget_usd": { + "type": "number (USD)", + "required": false + }, + "source": { + "type": "WorkstreamSource", + "required": false } }, "WorkstreamPageNode": { @@ -577,6 +603,14 @@ "superseded_by": { "type": "string", "required": false + }, + "goahead": { + "type": "WorkstreamGoAheadProposal", + "required": false + }, + "goahead_id": { + "type": "string", + "required": false } }, "WorkstreamGoAhead": { @@ -637,6 +671,36 @@ "required": false } }, + "WorkstreamGoAheadProposal": { + "scope": { + "type": "WorkstreamGoAheadScope", + "required": true + }, + "factory": { + "type": "string", + "required": false + }, + "job": { + "type": "integer", + "required": false + }, + "wave": { + "type": "integer", + "required": false + }, + "milestone_id": { + "type": "string", + "required": false + }, + "cap_usd": { + "type": "number (USD)", + "required": true + }, + "ends_at": { + "type": "string (RFC 3339)", + "required": true + } + }, "WorkstreamChanged": { "id": { "type": "string", @@ -892,6 +956,10 @@ "base_version": { "type": "integer", "required": true + }, + "source": { + "type": "WorkstreamSource", + "required": false } }, "WorkstreamPageSaveResult": { @@ -928,6 +996,10 @@ "doc": { "type": "object", "required": false + }, + "source": { + "type": "WorkstreamSource", + "required": false } }, "WorkstreamPageCreateResult": { @@ -1040,6 +1112,10 @@ "state": { "type": "WorkstreamRecordState", "required": false + }, + "include_goaheads": { + "type": "boolean", + "required": false } }, "WorkstreamRecordListResult": { @@ -1068,6 +1144,10 @@ "kept": { "type": "boolean", "required": false + }, + "goahead": { + "type": "WorkstreamGoAheadProposal", + "required": false } }, "WorkstreamRecordProposeResult": { diff --git a/cmd/codeaf/testdata/workstreams-wire/page.create.json b/cmd/codeaf/testdata/workstreams-wire/page.create.json index 10deddafb8..f9674dcc8e 100644 --- a/cmd/codeaf/testdata/workstreams-wire/page.create.json +++ b/cmd/codeaf/testdata/workstreams-wire/page.create.json @@ -20,6 +20,12 @@ "doc": { "type": "doc", "content": [] + }, + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" } } ], @@ -35,6 +41,12 @@ "version": 1, "updated_at": "2026-11-02T14:05:00Z", "updated_by": "you", + "updated_from": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, "parent_id": "pg_31d0", "doc": { "type": "doc", diff --git a/cmd/codeaf/testdata/workstreams-wire/page.save.json b/cmd/codeaf/testdata/workstreams-wire/page.save.json index 914a68b84e..51a913faff 100644 --- a/cmd/codeaf/testdata/workstreams-wire/page.save.json +++ b/cmd/codeaf/testdata/workstreams-wire/page.save.json @@ -19,6 +19,20 @@ "content": [] }, "base_version": 1 + }, + { + "id": "pg_31d0", + "doc": { + "type": "doc", + "content": [] + }, + "base_version": 2, + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + } } ], "result": [ @@ -38,6 +52,29 @@ "content": [] } } + }, + { + "page": { + "id": "pg_31d0", + "workstream_id": "ws_8f2c1a", + "title": "Overview", + "owner": "you", + "read_by_jobs": false, + "position": 0, + "version": 3, + "updated_at": "2026-11-02T15:10:00Z", + "updated_by": "you", + "updated_from": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "doc": { + "type": "doc", + "content": [] + } + } } ], "error": [ diff --git a/cmd/codeaf/testdata/workstreams-wire/record.keep.json b/cmd/codeaf/testdata/workstreams-wire/record.keep.json index faffcccb6b..9d11ab970d 100644 --- a/cmd/codeaf/testdata/workstreams-wire/record.keep.json +++ b/cmd/codeaf/testdata/workstreams-wire/record.keep.json @@ -13,6 +13,9 @@ "params": [ { "id": "rec_7a1e" + }, + { + "id": "rec_5c20" } ], "result": [ @@ -33,6 +36,31 @@ "created_at": "2026-11-02T14:05:00Z", "decided_at": "2026-11-02T15:00:00Z" } + }, + { + "record": { + "id": "rec_5c20", + "workstream_id": "ws_8f2c1a", + "kind": "goahead", + "text": "Wave 1 is small; let it run without asking.", + "state": "kept", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T14:05:00Z", + "decided_at": "2026-11-02T15:00:00Z", + "goahead": { + "scope": "wave", + "wave": 1, + "cap_usd": 40.0, + "ends_at": "2026-11-09T23:59:59Z" + }, + "goahead_id": "ga_7d31" + } } ] } diff --git a/cmd/codeaf/testdata/workstreams-wire/record.list.json b/cmd/codeaf/testdata/workstreams-wire/record.list.json index 06b669638b..5ba63ccd82 100644 --- a/cmd/codeaf/testdata/workstreams-wire/record.list.json +++ b/cmd/codeaf/testdata/workstreams-wire/record.list.json @@ -14,6 +14,10 @@ "workstream_id": "ws_8f2c1a", "kind": "question", "state": "proposed" + }, + { + "workstream_id": "ws_8f2c1a", + "include_goaheads": true } ], "result": [ @@ -35,6 +39,31 @@ "created_at": "2026-11-02T14:05:00Z" } ] + }, + { + "records": [ + { + "id": "rec_5c20", + "workstream_id": "ws_8f2c1a", + "kind": "goahead", + "text": "Wave 1 is small; let it run without asking.", + "state": "proposed", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T14:05:00Z", + "goahead": { + "scope": "wave", + "wave": 1, + "cap_usd": 40.0, + "ends_at": "2026-11-09T23:59:59Z" + } + } + ] } ] } diff --git a/cmd/codeaf/testdata/workstreams-wire/record.propose.json b/cmd/codeaf/testdata/workstreams-wire/record.propose.json index 1ef07f72ea..c0e8eb86ac 100644 --- a/cmd/codeaf/testdata/workstreams-wire/record.propose.json +++ b/cmd/codeaf/testdata/workstreams-wire/record.propose.json @@ -3,6 +3,7 @@ "errors": [ "bad_params", "not_found", + "conflict", "busy", "unknown_method" ], @@ -21,6 +22,23 @@ "label": "Billing scope" }, "kept": false + }, + { + "workstream_id": "ws_8f2c1a", + "kind": "goahead", + "text": "Wave 1 is small; let it run without asking.", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "goahead": { + "scope": "wave", + "wave": 1, + "cap_usd": 40.0, + "ends_at": "2026-11-09T23:59:59Z" + } } ], "result": [ @@ -40,6 +58,29 @@ }, "created_at": "2026-11-02T14:05:00Z" } + }, + { + "record": { + "id": "rec_5c20", + "workstream_id": "ws_8f2c1a", + "kind": "goahead", + "text": "Wave 1 is small; let it run without asking.", + "state": "proposed", + "by": "agent", + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + }, + "created_at": "2026-11-02T14:05:00Z", + "goahead": { + "scope": "wave", + "wave": 1, + "cap_usd": 40.0, + "ends_at": "2026-11-09T23:59:59Z" + } + } } ] } diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.list.json b/cmd/codeaf/testdata/workstreams-wire/workstream.list.json index 3a499f0c95..23ca14dc02 100644 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.list.json +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.list.json @@ -22,7 +22,9 @@ "needs_you": 1, "spend_usd": 2.5, "budget_usd": 100.0, - "updated_at": "2026-11-02T14:05:00Z" + "updated_at": "2026-11-02T14:05:00Z", + "agent_id": "conv_8d22", + "origin_chat_id": "conv_5b9e" } ] }, @@ -33,7 +35,8 @@ "name": "Usage-based billing", "stage": "shaping", "total": 1, - "updated_at": "2026-11-02T14:05:00Z" + "updated_at": "2026-11-02T14:05:00Z", + "agent_id": "conv_8d22" } ] } diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json index ef9aacd6bf..0130dd891c 100644 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json @@ -38,7 +38,15 @@ "draft_id": "draft_418c", "title": "Expose usage totals", "repo": "acme/billing", - "text": "Show the recorded usage." + "text": "Show the recorded usage.", + "size": "M", + "budget_usd": 25.0, + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + } } ] } @@ -74,7 +82,15 @@ "draft_id": "draft_418c", "title": "Expose usage totals", "repo": "acme/billing", - "text": "Show the recorded usage." + "text": "Show the recorded usage.", + "size": "M", + "budget_usd": 25.0, + "source": { + "kind": "message", + "id": "msg_19a2", + "chat_id": "conv_5b9e", + "label": "Billing scope" + } } ] } diff --git a/docs/changes/unreleased/2297-app-workstreams-wire-v02.md b/docs/changes/unreleased/2297-app-workstreams-wire-v02.md new file mode 100644 index 0000000000..cde1cbc7bd --- /dev/null +++ b/docs/changes/unreleased/2297-app-workstreams-wire-v02.md @@ -0,0 +1,24 @@ +--- +kind: added +title: Workstream drafts and pages carry sources and kept proposals grant go-aheads +pr: 2297 +surface: [app] +invalidates: + - The agent's go-ahead proposals used to be decision records with a text prefix and are now goahead records. + - record.list without a kind used to return every record and now leaves goahead records out unless asked. + - collections.db files from earlier workstreams builds are now refused because schema 3 changed in place. +--- + +The wire contract adds draft size, budget and source, source attribution on page +creation and saves, and agent and origin conversation IDs on workstream rows. +Draft fields round-trip through plans; job items refuse them. Page history keeps +the source of each version, and a save without one clears the current source. + +Go-ahead records carry structured proposals. Keeping one grants exactly once in +the same transaction after checking the current target, end and workstream state. +Dropping it revokes its active grant; keeping it again grants a new one. Go-ahead +records cannot be superseded. Marking done ends grants without changing records. + +The agent uses these structured proposals, with end dates at most 89 UTC days +ahead, and stamps added draft jobs with a validated source. Agent reads include +go-ahead records; ordinary record lists include them only when requested. diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go index 498cb655a6..cddf6df198 100644 --- a/internal/appwire/workstreams.go +++ b/internal/appwire/workstreams.go @@ -71,15 +71,17 @@ type WorkstreamSource struct { } type WorkstreamRow struct { - ID string `json:"id"` - Name string `json:"name"` - Stage string `json:"stage" enum:"shaping,building,done"` - Total int `json:"total"` - Merged *int `json:"merged,omitempty"` - NeedsYou *int `json:"needs_you,omitempty"` - SpendUSD *float64 `json:"spend_usd,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Stage string `json:"stage" enum:"shaping,building,done"` + Total int `json:"total"` + Merged *int `json:"merged,omitempty"` + NeedsYou *int `json:"needs_you,omitempty"` + SpendUSD *float64 `json:"spend_usd,omitempty"` + BudgetUSD *float64 `json:"budget_usd,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + AgentID string `json:"agent_id"` + OriginChatID *string `json:"origin_chat_id,omitempty"` } type Workstream struct { @@ -125,13 +127,16 @@ type WorkstreamWave struct { } type WorkstreamPlanItem struct { - Kind string `json:"kind" enum:"job,draft"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - DraftID *string `json:"draft_id,omitempty"` - Title *string `json:"title,omitempty"` - Repo *string `json:"repo,omitempty"` - Text *string `json:"text,omitempty"` + Kind string `json:"kind" enum:"job,draft"` + Factory *string `json:"factory,omitempty"` + Job *int `json:"job,omitempty"` + DraftID *string `json:"draft_id,omitempty"` + Title *string `json:"title,omitempty"` + Repo *string `json:"repo,omitempty"` + Text *string `json:"text,omitempty"` + Size *string `json:"size,omitempty" enum:"S,M,L"` + BudgetUSD *float64 `json:"budget_usd,omitempty"` + Source *WorkstreamSource `json:"source,omitempty"` } type WorkstreamPageNode struct { @@ -176,17 +181,29 @@ type WorkstreamPageVersion struct { } type WorkstreamRecord struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"decision,question"` - Text string `json:"text"` - State string `json:"state" enum:"proposed,kept,dropped,superseded"` - By string `json:"by" enum:"you,agent"` - Source *WorkstreamSource `json:"source,omitempty"` - CreatedAt time.Time `json:"created_at"` - DecidedAt time.Time `json:"decided_at,omitzero"` - Supersedes *string `json:"supersedes,omitempty"` - SupersededBy *string `json:"superseded_by,omitempty"` + ID string `json:"id"` + WorkstreamID string `json:"workstream_id"` + Kind string `json:"kind" enum:"decision,question,goahead"` + Text string `json:"text"` + State string `json:"state" enum:"proposed,kept,dropped,superseded"` + By string `json:"by" enum:"you,agent"` + Source *WorkstreamSource `json:"source,omitempty"` + CreatedAt time.Time `json:"created_at"` + DecidedAt time.Time `json:"decided_at,omitzero"` + Supersedes *string `json:"supersedes,omitempty"` + SupersededBy *string `json:"superseded_by,omitempty"` + GoAhead *WorkstreamGoAheadProposal `json:"goahead,omitempty"` + GoAheadID *string `json:"goahead_id,omitempty"` +} + +type WorkstreamGoAheadProposal struct { + Scope string `json:"scope" enum:"job,wave,milestone"` + 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"` + EndsAt time.Time `json:"ends_at"` } type WorkstreamGoAhead struct { @@ -327,9 +344,10 @@ type WorkstreamPageGetResult struct { } type WorkstreamPageSaveParams struct { - ID string `json:"id"` - Doc json.RawMessage `json:"doc"` - BaseVersion int `json:"base_version"` + ID string `json:"id"` + Doc json.RawMessage `json:"doc"` + BaseVersion int `json:"base_version"` + Source *WorkstreamSource `json:"source,omitempty"` } type WorkstreamPageSaveResult struct { @@ -337,13 +355,14 @@ type WorkstreamPageSaveResult struct { } type WorkstreamPageCreateParams struct { - WorkstreamID string `json:"workstream_id"` - ParentID *string `json:"parent_id,omitempty"` - AfterID *string `json:"after_id,omitempty"` - Title string `json:"title"` - Owner *string `json:"owner,omitempty" enum:"you,agent"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` - Doc json.RawMessage `json:"doc,omitempty"` + WorkstreamID string `json:"workstream_id"` + ParentID *string `json:"parent_id,omitempty"` + AfterID *string `json:"after_id,omitempty"` + Title string `json:"title"` + Owner *string `json:"owner,omitempty" enum:"you,agent"` + ReadByJobs *bool `json:"read_by_jobs,omitempty"` + Doc json.RawMessage `json:"doc,omitempty"` + Source *WorkstreamSource `json:"source,omitempty"` } type WorkstreamPageCreateResult struct { @@ -399,9 +418,10 @@ type WorkstreamPageRestoreResult struct { } type WorkstreamRecordListParams struct { - WorkstreamID string `json:"workstream_id"` - Kind *string `json:"kind,omitempty" enum:"decision,question"` - State *string `json:"state,omitempty" enum:"proposed,kept,dropped,superseded"` + WorkstreamID string `json:"workstream_id"` + Kind *string `json:"kind,omitempty" enum:"decision,question,goahead"` + State *string `json:"state,omitempty" enum:"proposed,kept,dropped,superseded"` + IncludeGoAheads *bool `json:"include_goaheads,omitempty"` } type WorkstreamRecordListResult struct { @@ -409,11 +429,12 @@ type WorkstreamRecordListResult struct { } type WorkstreamRecordProposeParams struct { - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"decision,question"` - Text string `json:"text"` - Source *WorkstreamSource `json:"source,omitempty"` - Kept *bool `json:"kept,omitempty"` + WorkstreamID string `json:"workstream_id"` + Kind string `json:"kind" enum:"decision,question,goahead"` + Text string `json:"text"` + Source *WorkstreamSource `json:"source,omitempty"` + Kept *bool `json:"kept,omitempty"` + GoAhead *WorkstreamGoAheadProposal `json:"goahead,omitempty"` } type WorkstreamRecordProposeResult struct { diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go index c9418fdda0..409ebb3bf9 100644 --- a/internal/appwire/workstreams_check.go +++ b/internal/appwire/workstreams_check.go @@ -171,6 +171,9 @@ func (p WorkstreamSource) Check() error { if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Label, 0, 200, "source label")); err != nil { return err } + if p.Kind != "message" && p.ChatID != nil || p.Kind != "factory_job" && p.Factory != nil { + return Errorf(CodeBadParams, "chat_id is only for message and factory is only for factory_job") + } switch p.Kind { case "chat", "page", "url": case "message": @@ -280,6 +283,9 @@ func (p WorkstreamPlanSetParams) Check() error { return err } for _, item := range w.Items { + if err := workstreamDraftFields(item); err != nil { + return err + } if item.Job != nil && *item.Job <= 0 { return Errorf(CodeBadParams, "plan job must be positive") } @@ -318,10 +324,10 @@ func (p WorkstreamPageSaveParams) Check() error { if p.BaseVersion < 0 { return Errorf(CodeBadParams, "base_version must be nonnegative") } - return workstreamChecks(workstreamID(p.ID), workstreamDoc(p.Doc, true)) + return workstreamChecks(workstreamID(p.ID), workstreamDoc(p.Doc, true), workstreamSource(p.Source)) } func (p WorkstreamPageCreateParams) Check() error { - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID), workstreamLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent"), workstreamDoc(p.Doc, false)) + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID), workstreamLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent"), workstreamDoc(p.Doc, false), workstreamSource(p.Source)) } func (p WorkstreamPageUpdateParams) Check() error { return workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent")) @@ -343,10 +349,18 @@ func (p WorkstreamPageRestoreParams) Check() error { return workstreamID(p.ID) } func (p WorkstreamRecordListParams) Check() error { - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalEnum(p.Kind, "decision", "question"), workstreamOptionalEnum(p.State, "proposed", "kept", "dropped", "superseded")) + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalEnum(p.Kind, "decision", "question", "goahead"), workstreamOptionalEnum(p.State, "proposed", "kept", "dropped", "superseded")) } func (p WorkstreamRecordProposeParams) Check() error { - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamEnum(p.Kind, "decision", "question"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) + if (p.Kind == "goahead") != (p.GoAhead != nil) || p.Kind == "goahead" && p.Kept != nil && *p.Kept { + return Errorf(CodeBadParams, "goahead is required only for kind goahead, which cannot be proposed kept") + } + if p.GoAhead != nil { + if err := p.GoAhead.Check(); err != nil { + return err + } + } + return workstreamChecks(workstreamID(p.WorkstreamID), workstreamEnum(p.Kind, "decision", "question", "goahead"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) } func (p WorkstreamRecordSupersedeParams) Check() error { return workstreamChecks(workstreamID(p.ID), workstreamOptionalEnum(p.Kind, "decision", "question"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) @@ -355,6 +369,10 @@ func (p WorkstreamGoAheadGrantParams) Check() error { if err := workstreamID(p.WorkstreamID); err != nil { return err } + return (WorkstreamGoAheadProposal{Scope: p.Scope, Factory: p.Factory, Job: p.Job, Wave: p.Wave, MilestoneID: p.MilestoneID, CapUSD: p.CapUSD, EndsAt: p.EndsAt}).Check() +} + +func (p WorkstreamGoAheadProposal) Check() error { if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || p.EndsAt.IsZero() { return Errorf(CodeBadParams, "go-ahead requires cap_usd greater than 0 and at most %d, and ends_at", workspace.WorkstreamGoAheadCapMax) } @@ -378,3 +396,13 @@ func (p WorkstreamGoAheadGrantParams) Check() error { } return nil } + +func workstreamDraftFields(item WorkstreamPlanItem) error { + if item.Kind != "draft" && (item.Size != nil || item.BudgetUSD != nil || item.Source != nil) { + return Errorf(CodeBadParams, "size, budget_usd and source are allowed only on drafts") + } + if item.BudgetUSD != nil && (math.IsNaN(*item.BudgetUSD) || math.IsInf(*item.BudgetUSD, 0) || *item.BudgetUSD <= 0 || *item.BudgetUSD > workspace.WorkstreamGoAheadCapMax) { + return Errorf(CodeBadParams, "draft budget_usd must be greater than 0 and at most %d", workspace.WorkstreamGoAheadCapMax) + } + return workstreamChecks(workstreamOptionalEnum(item.Size, "S", "M", "L"), workstreamSource(item.Source)) +} diff --git a/internal/appwire/workstreams_fields_v02_test.go b/internal/appwire/workstreams_fields_v02_test.go new file mode 100644 index 0000000000..3436aaff90 --- /dev/null +++ b/internal/appwire/workstreams_fields_v02_test.go @@ -0,0 +1,84 @@ +package appwire + +import ( + "encoding/json" + "math" + "strings" + "testing" +) + +// WP-F2 (a), (b), (d), (f): typed dispatch refuses malformed additive fields. +func TestWorkstreamV02ParamsBeforeDelegation(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + for _, fields := range []string{`"size":"XL"`, `"size":""`, `"budget_usd":0`, `"budget_usd":-1`, `"budget_usd":10000.01`, `"source":{"kind":"message","id":"m"}`} { + params := `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft",` + fields + `}]}]}` + _, err := methods["workstream.plan_set"].call(fake, json.RawMessage(params)) + workstreamV02BadParams(t, err) + } + for _, fields := range []string{`"size":"S"`, `"size":""`, `"budget_usd":1`, `"budget_usd":0`, `"source":{"kind":"chat","id":"c"}`} { + params := `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"job","factory":"f","job":1,` + fields + `}]}]}` + _, err := methods["workstream.plan_set"].call(fake, json.RawMessage(params)) + workstreamV02BadParams(t, err) + } + proposal := `{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"2026-11-04T00:00:00Z"}` + for _, tc := range []struct{ method, params string }{ + {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why"}`}, + {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why","kept":true,"goahead":` + proposal + `}`}, + {"record.propose", `{"workstream_id":"w","kind":"decision","text":"Why","goahead":` + proposal + `}`}, + {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why","goahead":{"scope":"wave","wave":1,"cap_usd":1}}`}, + {"record.supersede", `{"id":"r","kind":"goahead","text":"Why"}`}, + {"page.save", `{"id":"p","base_version":1,"doc":{},"source":{"kind":"message","id":"m"}}`}, + {"page.create", `{"workstream_id":"w","title":"Page","source":{"kind":"chat","id":"c","factory":"f"}}`}, + {"page.create", `{"workstream_id":"w","title":"Page","source":{"kind":"page","id":"p","chat_id":"c"}}`}, + } { + _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) + workstreamV02BadParams(t, err) + } + for _, value := range []float64{math.NaN(), math.Inf(1)} { + id, title := "d", "Draft" + p := WorkstreamPlanSetParams{ID: "w", Milestones: []WorkstreamMilestone{}, Waves: []WorkstreamWave{{N: 1, Items: []WorkstreamPlanItem{{Kind: "draft", DraftID: &id, Title: &title, BudgetUSD: &value}}}}} + workstreamV02BadParams(t, p.Check()) + } +} + +func workstreamV02BadParams(t *testing.T, err error) { + t.Helper() + wire, ok := err.(*Error) + if !ok || wire.Code != CodeBadParams { + t.Fatalf("want bad_params, got %v", err) + } +} + +// WP-F2 (a), (b), (d), (f): each valid additive field reaches the typed delegate. +func TestWorkstreamV02ParamsReachDelegation(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + source := `{"kind":"message","id":"m","chat_id":"c","label":"Scope"}` + for method, params := range map[string]string{ + "workstream.plan_set": `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft","size":"M","budget_usd":10000,"source":` + source + `}]}]}`, + "page.save": `{"id":"p","doc":{},"base_version":1,"source":` + source + `}`, + "page.create": `{"workstream_id":"w","title":"Page","source":` + source + `}`, + "record.list": `{"workstream_id":"w","kind":"goahead","include_goaheads":false}`, + "record.propose": `{"workstream_id":"w","kind":"goahead","text":"Why","kept":false,"goahead":{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"2026-11-04T00:00:00Z"}}`, + } { + _, err := methods[method].call(fake, json.RawMessage(params)) + if err != ErrWorkstreamsNotYet { + t.Fatalf("%s: %v", method, err) + } + } + for _, kind := range []string{"chat", "message", "factory_job", "page", "url"} { + p := WorkstreamSource{Kind: kind, ID: "7"} + chat, factory := "chat", "factory" + if kind == "message" { + p.ChatID = &chat + } + if kind == "factory_job" { + p.Factory = &factory + } + if err := p.Check(); err != nil { + t.Fatalf("%s: %v", kind, err) + } + label := strings.Repeat("x", 201) + p.Label = &label + workstreamV02BadParams(t, p.Check()) + } +} diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index ae3ef989f3..8962ef972c 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -156,7 +156,7 @@ Client IDs, titles and source labels refuse control characters. Why, how it went record text and draft text allow newline and tab, and refuse other control characters. Malformed app requests return `bad_params`. -Records can be proposed, kept, dropped and replaced. Keep can undo a drop; +Decisions and questions can be proposed, kept, dropped and replaced. Keep can undo a drop; a replaced record cannot change again. Plans store milestones, ordered waves and job or draft items. Wave auto-start is stored without starting jobs. @@ -174,7 +174,7 @@ settings do not themselves start work. 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 +decisions, questions, go-ahead proposals, plan and grants. 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. @@ -184,12 +184,6 @@ 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 @@ -201,3 +195,38 @@ 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. + +## Workstream draft size, budget and sources + +Draft plan items accept optional size `S`, `M` or `L`, a budget greater than zero +and at most $10,000, and a source. Job plan items refuse these fields. Older +plans without them still read normally. These fields are stored without +starting work. The agent cannot set draft size or budget. `propose_jobs` gives +each added draft the agent conversation as its source, or an explicit page, +member job or HTTP(S) link. Existing items and a conflict retry keep their sources. + +App page creation and saves accept an optional source: chat, message, Factory +job, page or URL. A message source needs its chat ID; a job source needs its +Factory ID. Source labels take at most 200 bytes on one line. The source becomes +the page's `updated_from` and that version's `from` in history. A later save +without a source clears `updated_from` and has no `from` on its version. + +## Workstream go-ahead proposals, keeping and undo + +`propose_goahead` makes a proposed `goahead` record with the reason as its text +and structured scope, target, cap and end. It grants nothing. Keeping it grants +exactly one go-ahead atomically; keeping it again changes nothing. Dropping a +kept proposal revokes its still-active grant. Keeping it after that grants a +new one. Records retain the latest grant ID, including after a drop. Go-ahead +records cannot be replaced with `record.supersede`. + +The proposed cap is greater than zero and at most $10,000, including fractional +dollars such as $12.50. The agent's `ends_on` date must be after today in UTC and +at most 89 days ahead; the end is that day's 23:59:59 UTC. Proposal and keep both +check that the target exists and the end is future and within 90 days. Proposing +or keeping on a done workstream conflicts. A refused keep changes nothing. +Expiry, revocation and marking done leave a kept record kept while ending its grant. + +`record.list` returns decisions and questions by default. Use +`include_goaheads: true` for every kind, or `kind: goahead` for only go-ahead +records. The agent's record reads include go-ahead proposals and their grant IDs. diff --git a/internal/session/workstream_contract.go b/internal/session/workstream_contract.go index a7d5d61a73..d20e62dc2c 100644 --- a/internal/session/workstream_contract.go +++ b/internal/session/workstream_contract.go @@ -14,6 +14,7 @@ type WorkstreamDoor interface { Brief() (WorkstreamBrief, bool) Read(context.Context, WorkstreamReadAsk) (json.RawMessage, error) Propose(context.Context, WorkstreamProposal) (WorkstreamProposed, error) + Source(context.Context, *workstream.Source) (*workstream.Source, error) Plan(context.Context) (WorkstreamPlanState, error) SetPlan(context.Context, int, WorkstreamPlanState) (WorkstreamPlanState, error) WritePage(context.Context, WorkstreamPageWrite) (WorkstreamPageWritten, error) @@ -29,8 +30,6 @@ var ErrWorkstreamNotAgentPage = errors.New("That page is the person's. You write 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" diff --git a/internal/session/workstream_fields_v02_test.go b/internal/session/workstream_fields_v02_test.go new file mode 100644 index 0000000000..31ec08cf71 --- /dev/null +++ b/internal/session/workstream_fields_v02_test.go @@ -0,0 +1,90 @@ +package session + +import ( + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// WP-F2 (e): the last second of day 89 fits every grant clock on today's UTC day. +func TestWorkstreamGoAheadDateEdgeIs89Days(t *testing.T) { + for _, hour := range []int{0, 12, 23} { + now := time.Date(2026, 11, 3, hour, 0, 0, 0, time.UTC) + ask := WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 1, Why: "Review"} + ask.EndsOn = now.AddDate(0, 0, 89).Format("2006-01-02") + if err := WorkstreamValidateGoAhead(ask, now); err != nil { + t.Fatal(err) + } + ask.EndsOn = now.AddDate(0, 0, 90).Format("2006-01-02") + if err := WorkstreamValidateGoAhead(ask, now); err == nil || !strings.Contains(err.Error(), "at most 89 days ahead") { + t.Fatalf("90-day edge: %v", err) + } + } + if !strings.Contains(workstreamGoAheadSchema, "at most 89 days ahead") { + t.Fatal("schema date bound drifted") + } +} + +// WP-F2 (e): resolve once before retry, stamp every added draft, preserve old items. +func TestWorkstreamDraftSourcesSurviveTheSingleRetry(t *testing.T) { + for _, source := range []*workstream.Source{nil, {Kind: "page", ID: "page", Label: "Spec"}} { + d := &workstreamFakeDoor{conflicts: 1, plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 1, Items: []workstream.PlanItem{{Kind: "draft", DraftID: "old", Title: "Old", Size: "L", BudgetUSD: 10, Source: &workstream.Source{Kind: "message", ID: "m", ChatID: "chat"}}}}}}} + before := d.plan.Waves[0] + args := `{"jobs":[{"title":"One"},{"title":"Two"}]}` + want := &workstream.Source{Kind: "chat", ID: "agent-chat"} + if source != nil { + args = `{"jobs":[{"title":"One"},{"title":"Two"}],"source":{"kind":"page","id":"page","label":"Spec"}}` + want = source + } + workstreamCallForTest(t, d, "propose_jobs", args, false) + if d.sourceCalls != 1 || len(d.saves) != 2 { + t.Fatalf("resolve/retry calls: %+v", d) + } + for _, saved := range d.saves { + if !reflect.DeepEqual(saved.Waves[0], before) { + t.Fatal("old item metadata changed") + } + wave := saved.Waves[len(saved.Waves)-1] + for _, item := range wave.Items { + if !reflect.DeepEqual(item.Source, want) || item.Size != "" || item.BudgetUSD != 0 { + t.Fatalf("draft: %+v", item) + } + } + } + } +} + +// WP-F2 (e): invalid explicit source refuses without a plan write. +func TestWorkstreamDraftSourceRefusalAddsNothing(t *testing.T) { + d := &workstreamFakeDoor{sourceError: errors.New("source page does not exist")} + workstreamCallForTest(t, d, "propose_jobs", `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"missing"}}`, true) + if d.sourceCalls != 1 || d.reads != 0 || len(d.saves) != 0 { + t.Fatalf("refusal wrote: %+v", d) + } + for _, args := range []string{ + `{"jobs":[{"title":"Draft","size":"S"}]}`, + `{"jobs":[{"title":"Draft","budget_usd":1}]}`, + `{"jobs":[{"title":"Draft"}],"source":{"kind":"message","id":"m"}}`, + `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"p","extra":true}}`, + `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"p","factory":""}}`, + } { + d := &workstreamFakeDoor{} + workstreamCallForTest(t, d, "propose_jobs", args, true) + if d.sourceCalls != 0 || d.reads != 0 || len(d.saves) != 0 { + t.Fatal("invalid source reached the door") + } + } +} + +// WP-F2 (f): the agent's read grammar accepts the structured proposal kind. +func TestWorkstreamReadAcceptsGoAheadKind(t *testing.T) { + d := &workstreamFakeDoor{read: []byte(`{"records":[]}`)} + workstreamCallForTest(t, d, "workstream_read", `{"what":"records","kind":"goahead"}`, false) + if d.readAsk.Kind != "goahead" { + t.Fatalf("kind: %+v", d.readAsk) + } +} diff --git a/internal/session/workstream_limits.go b/internal/session/workstream_limits.go index c6d4564438..eb58f40c90 100644 --- a/internal/session/workstream_limits.go +++ b/internal/session/workstream_limits.go @@ -1,5 +1,11 @@ package session +import ( + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + const workstreamLineMax = 200 const workstreamRecordMax = 2000 @@ -8,8 +14,9 @@ const workstreamDraftTextMax = 4000 const workstreamJobsMax = 20 -const workstreamGoAheadCapMax = 10000 +const workstreamGoAheadCapMax = workspace.WorkstreamGoAheadCapMax -const workstreamGoAheadDaysMax = 90 +// Ending at the UTC day's last second keeps the proposal within the grant limit. +const workstreamGoAheadDaysMax = int(workspace.WorkstreamGoAheadDurationMax/(24*time.Hour)) - 1 const workstreamGoAheadWhyMax = 1000 diff --git a/internal/session/workstream_schema.go b/internal/session/workstream_schema.go index 52051534ed..9541ef873a 100644 --- a/internal/session/workstream_schema.go +++ b/internal/session/workstream_schema.go @@ -11,13 +11,13 @@ import ( 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}` +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","goahead"]},"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 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"},"source":%s},"required":["jobs"],"additionalProperties":false}`, workstreamJobsMax, workstreamLineMax, workstreamLineMax, workstreamDraftTextMax, workstreamLineMax, workstreamSourceSchema) -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 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","description":"YYYY-MM-DD, after today in UTC and at most %d days ahead."},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["scope","cap_usd","ends_on","why"],"additionalProperties":false}`, workstreamGoAheadCapMax, workstreamGoAheadDaysMax, 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) diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go index 2a7308c3e4..e53f2b1cbe 100644 --- a/internal/session/workstream_tools.go +++ b/internal/session/workstream_tools.go @@ -24,7 +24,7 @@ const workstreamPromptRules = "\n- You propose; the person keeps. Decisions, que "- 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." + "- Keeping a go-ahead proposal grants it. You start nothing; the person launches jobs." func (c Config) mayWorkstream() bool { return c.Workstream != nil && !c.readingTask() && !c.InTask && !c.Errand @@ -79,7 +79,7 @@ func (a *Agent) workstreamToolsAt(now func() time.Time) []bare.Tool { 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) { + workstreamTool("propose_goahead", "Propose a go-ahead record. It grants nothing until the person keeps it.", 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) { @@ -152,6 +152,7 @@ func workstreamReferenceFields(fields map[string]any, ask any) error { ids["page_id"] = ask.PageID case workstreamJobsAsk: ids["milestone_id"] = ask.MilestoneID + source = ask.Source case WorkstreamGoAheadAsk: ids["milestone_id"] = ask.MilestoneID case WorkstreamPageWrite: @@ -287,6 +288,7 @@ type workstreamJobsAsk struct { Wave *int `json:"wave,omitempty"` WaveTitle string `json:"wave_title,omitempty"` MilestoneID string `json:"milestone_id,omitempty"` + Source *workstream.Source `json:"source,omitempty"` } func workstreamValidateJobs(ask workstreamJobsAsk) error { @@ -304,7 +306,7 @@ func workstreamValidateJobs(ask workstreamJobsAsk) error { return err } } - return nil + return workstreamSourceText(ask.Source) } func workstreamValidateDraft(job workstreamDraftAsk) error { @@ -321,7 +323,11 @@ func workstreamProposeJobs(ctx context.Context, door WorkstreamDoor, ask workstr if err := workstreamValidateJobs(ask); err != nil { return "", &toolArgumentError{repair: err.Error()} } - drafts, err := workstreamDrafts(ask.Jobs) + source, err := door.Source(ctx, ask.Source) + if err != nil { + return "", err + } + drafts, err := workstreamDrafts(ask.Jobs, source) if err != nil { return "", err } @@ -355,14 +361,14 @@ func workstreamSaveDrafts(ctx context.Context, door WorkstreamDoor, plan Workstr return wave, err } -func workstreamDrafts(jobs []workstreamDraftAsk) ([]workstream.PlanItem, error) { +func workstreamDrafts(jobs []workstreamDraftAsk, source *workstream.Source) ([]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}) + out = append(out, workstream.PlanItem{Kind: "draft", DraftID: "draft_" + hex.EncodeToString(id[:]), Title: job.Title, Repo: job.Repo, Text: job.Text, Source: source}) } return out, nil } @@ -402,16 +408,15 @@ func workstreamProposeGoAhead(ctx context.Context, door WorkstreamDoor, ask Work if err := WorkstreamValidateGoAhead(ask, now); err != nil { return "", &toolArgumentError{repair: err.Error()} } - scope, err := workstreamGoAheadScope(ctx, door, ask) + 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}) + p, err := door.Propose(ctx, WorkstreamProposal{Kind: "goahead", Text: ask.Why, 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 + return "Proposed go-ahead " + p.ID + ". It grants nothing; the person grants it by keeping it.", nil } // WorkstreamValidateGoAhead is shared with the app so its clock validates proposals too. @@ -465,28 +470,28 @@ func workstreamGoAheadOtherFields(ask WorkstreamGoAheadAsk) bool { return false } -func workstreamGoAheadScope(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk) (string, error) { +func workstreamGoAheadScope(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk) error { if ask.Scope == "job" { - return fmt.Sprintf("job %s#%d", ask.Factory, ask.Job), nil + return nil } plan, err := door.Plan(ctx) if err != nil { - return "", err + 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 nil } } - return "", errors.New("wave must name an existing plan wave") + 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 nil } } - return "", errors.New("milestone_id must name an existing plan milestone") + return errors.New("milestone_id must name an existing plan milestone") } func workstreamWritePage(ctx context.Context, door WorkstreamDoor, ask WorkstreamPageWrite) (string, error) { diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go index 6859db2b51..8cf44e6a9a 100644 --- a/internal/session/workstream_tools_test.go +++ b/internal/session/workstream_tools_test.go @@ -14,19 +14,29 @@ import ( ) 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 + 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 + sourceCalls int + sourceError error +} + +func (d *workstreamFakeDoor) Source(_ context.Context, source *workstream.Source) (*workstream.Source, error) { + d.sourceCalls++ + if source == nil { + source = &workstream.Source{Kind: "chat", ID: "agent-chat"} + } + return source, d.sourceError } func (d *workstreamFakeDoor) Brief() (WorkstreamBrief, bool) { @@ -273,7 +283,7 @@ func TestWorkstreamGoAheadDatesAndCapsAreOnlyProposals(t *testing.T) { 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 = "2027-02-02" }, func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-05T00:00:00Z" }, func(a *WorkstreamGoAheadAsk) { a.Why = "" }, } { @@ -297,7 +307,7 @@ func TestWorkstreamProposalsAreOnlyWords(t *testing.T) { } 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" { + if got != "Proposed go-ahead record-1. It grants nothing; the person grants it by keeping it." || p.Kind != "goahead" || p.Text != "Review the result" || p.GoAhead == nil || p.GoAhead.Scope != "wave" || p.GoAhead.Wave != 2 || p.GoAhead.CapUSD != 12.5 || p.GoAhead.EndsOn != "2026-11-04" { t.Fatalf("go-ahead: %q %+v", got, p) } } diff --git a/internal/session/workstream_validation_test.go b/internal/session/workstream_validation_test.go index 46317e6ce2..e5542f52b7 100644 --- a/internal/session/workstream_validation_test.go +++ b/internal/session/workstream_validation_test.go @@ -151,8 +151,7 @@ func TestWorkstreamGoAheadDecodesFractionalDollarsAndWholeTargets(t *testing.T) 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" { + if proposal.GoAhead == nil || proposal.GoAhead.CapUSD != 12.5 || proposal.Text != "Review" || proposal.Kind != "goahead" || proposal.GoAhead.Scope != tc.name || (tc.name == "job" && (proposal.GoAhead.Factory != "f" || proposal.GoAhead.Job != 7)) || (tc.name == "wave" && proposal.GoAhead.Wave != 2) { t.Fatalf("fractional dollars changed: %+v", proposal) } } diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go index 3a4619608d..1f50e80b1f 100644 --- a/internal/workspace/workstreams.go +++ b/internal/workspace/workstreams.go @@ -193,10 +193,11 @@ CREATE TABLE workstream_page_versions ( ); CREATE TABLE workstream_records ( id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK(kind IN ('decision','question')), text TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('decision','question','goahead')), text TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped','superseded')), actor TEXT NOT NULL CHECK(actor IN ('you','agent')), source TEXT, created_at INTEGER NOT NULL, - decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id) + decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id), + goahead TEXT, goahead_id TEXT ); CREATE INDEX workstream_record_list ON workstream_records(workstream_id,created_at); CREATE TABLE workstream_goaheads ( @@ -228,7 +229,7 @@ var workstreamSchemaReads = []string{ "SELECT member_seq,workstream_id,factory,job FROM workstream_jobs LIMIT 0", "SELECT id,workstream_id,parent_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview,deleted_at,deleted_root FROM workstream_pages LIMIT 0", "SELECT page_id,version,saved_at,saved_by,source,title,doc,bytes FROM workstream_page_versions LIMIT 0", - "SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes,superseded_by FROM workstream_records LIMIT 0", + "SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes,superseded_by,goahead,goahead_id FROM workstream_records LIMIT 0", "SELECT id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,end_reason,granted_at,ended_at FROM workstream_goaheads LIMIT 0", "SELECT workstream_id,version,updated_at,updated_by,milestones,waves FROM workstream_plans LIMIT 0", } diff --git a/internal/workspace/workstreams_fields_v02_test.go b/internal/workspace/workstreams_fields_v02_test.go new file mode 100644 index 0000000000..3311c87639 --- /dev/null +++ b/internal/workspace/workstreams_fields_v02_test.go @@ -0,0 +1,43 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// WP-F2 (d), Storage: missing proposal columns refuse at Open without repair. +func TestWorkstreamSchemaThreeRequiresGoAheadRecordColumns(t *testing.T) { + for _, column := range []string{"goahead", "goahead_id"} { + t.Run(column, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "obsolete.db") + s := openTestStore(t, path) + createTestCollection(t, s, "Schema three") + if userVersion(t, s) != 3 { + t.Fatal("fixture is not schema 3") + } + if _, err := s.db.Exec("ALTER TABLE workstream_records DROP COLUMN " + column); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + opened, err := Open(path) + if opened != nil { + _ = opened.Close() + } + if err == nil || !strings.Contains(err.Error(), column) { + t.Fatalf("obsolete schema opened: %v", err) + } + after, err := os.ReadFile(path) + if err != nil || string(before) != string(after) { + t.Fatalf("refusal changed file: %v", err) + } + }) + } +} diff --git a/internal/workstream/goaheads.go b/internal/workstream/goaheads.go index c21531abd1..8dd562bfdf 100644 --- a/internal/workstream/goaheads.go +++ b/internal/workstream/goaheads.go @@ -77,124 +77,153 @@ func (s *Store) GoAheads(ctx context.Context, id string, ended bool) ([]GoAhead, }) return out, err } -func (s *Store) GrantGoAhead(ctx context.Context, p GoAheadGrant) (GoAhead, Change, error) { +func workstreamGoAheadFields(p GoAheadGrant, now time.Time) error { // Store millisecond precision in the validation too: an end within the same // stored millisecond as the grant is not a future authorization. - now := s.now() ends := p.EndsAt if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || ends.IsZero() || !ends.After(now) || ends.After(now.Add(workspace.WorkstreamGoAheadDurationMax)) || ends.UnixMilli() <= now.UnixMilli() { - return GoAhead{}, Change{}, invalid("go-ahead needs a cap greater than 0 and at most 10000, and a future end within 90 days") + return invalid("go-ahead needs a cap greater than 0 and at most 10000, and a future end within 90 days") } switch p.Scope { case "job": if p.Job <= 0 || p.Wave != 0 || p.MilestoneID != "" { - return GoAhead{}, Change{}, invalid("job scope needs only factory and job") + return invalid("job scope needs only factory and job") } if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: strconv.Itoa(p.Job), Factory: p.Factory}).Validate(); err != nil { - return GoAhead{}, Change{}, err + return err } case "wave": if p.Wave <= 0 || p.Factory != "" || p.Job != 0 || p.MilestoneID != "" { - return GoAhead{}, Change{}, invalid("wave scope needs only wave") + return invalid("wave scope needs only wave") } case "milestone": if p.MilestoneID == "" || p.Factory != "" || p.Job != 0 || p.Wave != 0 { - return GoAhead{}, Change{}, invalid("milestone scope needs only milestone_id") + return invalid("milestone scope needs only milestone_id") } default: - return GoAhead{}, Change{}, invalid("go-ahead needs a scope") + return invalid("go-ahead needs a scope") } - var out GoAhead - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, p.WorkstreamID) + return nil +} + +func workstreamGoAheadAllowedIn(ctx context.Context, tx *sql.Tx, p GoAheadGrant, at time.Time) error { + w, err := getIn(ctx, tx, p.WorkstreamID) + if err != nil { + return err + } + if !w.DoneAt.IsZero() { + return ErrConflict + } + if err := workstreamGoAheadFields(p, at); err != nil { + return err + } + if p.Scope == "job" { + for _, j := range w.Jobs() { + if j.Factory == p.Factory && j.Number == p.Job { + return nil + } + } + } else { + plan, err := planIn(ctx, tx, p.WorkstreamID) if err != nil { return err } - if !w.DoneAt.IsZero() { - return ErrConflict + if workstreamGoAheadInPlan(p, plan) { + return nil } - // Recheck after waiting for the writer, so an expired grant cannot sneak in. - at := s.now() - if p.EndsAt.UnixMilli() <= at.UnixMilli() || p.EndsAt.After(at.Add(workspace.WorkstreamGoAheadDurationMax)) { - return invalid("go-ahead end must be future and within 90 days") - } - valid := false - switch p.Scope { - case "job": - for _, j := range w.Jobs() { - if j.Factory == p.Factory && j.Number == p.Job { - valid = true - break - } - } - case "wave", "milestone": - plan, err := planIn(ctx, tx, p.WorkstreamID) - if err != nil { - return err - } - if p.Scope == "wave" { - for _, v := range plan.Waves { - if v.N == p.Wave { - valid = true - break - } - } - } else { - for _, v := range plan.Milestones { - if v.ID == p.MilestoneID { - valid = true - break - } - } - } + } + return invalid("scope target is not a member or does not exist in the plan") +} + +func workstreamGoAheadInPlan(p GoAheadGrant, plan Plan) bool { + for _, wave := range plan.Waves { + if p.Scope == "wave" && wave.N == p.Wave { + return true } - if !valid { - return invalid("scope target is not a member or does not exist in the plan") + } + for _, milestone := range plan.Milestones { + if p.Scope == "milestone" && milestone.ID == p.MilestoneID { + return true } - id, err := s.newID() + } + return false +} + +// Both direct grants and kept proposals validate and insert under the writer. +func (s *Store) grantGoAheadIn(ctx context.Context, tx *sql.Tx, p GoAheadGrant, at time.Time) (GoAhead, error) { + if err := workstreamGoAheadAllowedIn(ctx, tx, p, at); err != nil { + return GoAhead{}, err + } + id, err := s.newID() + if err != nil { + return GoAhead{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_goaheads(id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,granted_at) VALUES (?,?,?,?,?,?,?,?,?,'active',?)`, id, p.WorkstreamID, p.Scope, nullString(p.Factory), nullInt(p.Job), nullInt(p.Wave), nullString(p.MilestoneID), p.CapUSD, p.EndsAt.UnixMilli(), at.UnixMilli()); err != nil { + return GoAhead{}, err + } + return goaheadIn(ctx, tx, id) +} + +func (s *Store) GrantGoAhead(ctx context.Context, p GoAheadGrant) (GoAhead, Change, error) { + if err := workstreamGoAheadFields(p, s.now()); err != nil { + return GoAhead{}, Change{}, err + } + var out GoAhead + var c Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + at := s.now() + var err error + out, err = s.grantGoAheadIn(ctx, tx, p, at) if err != nil { return err } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_goaheads(id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,granted_at) VALUES (?,?,?,?,?,?,?,?,?,'active',?)`, id, p.WorkstreamID, p.Scope, nullString(p.Factory), nullInt(p.Job), nullInt(p.Wave), nullString(p.MilestoneID), p.CapUSD, p.EndsAt.UnixMilli(), at.UnixMilli()); err != nil { - return err - } if err := touch(ctx, tx, p.WorkstreamID, at.UnixMilli()); err != nil { return err } - out, err = goaheadIn(ctx, tx, id) c = changed(p.WorkstreamID, PartGoAheads) - return err + return nil }) if err != nil { return GoAhead{}, Change{}, err } return out, c, nil } + +func revokeGoAheadIn(ctx context.Context, tx *sql.Tx, id string, now time.Time) (GoAhead, bool, error) { + g, err := goaheadIn(ctx, tx, id) + if err != nil { + return GoAhead{}, false, err + } + g = expire(g, now) + if g.State == "ended" { + return g, false, nil + } + if _, err := tx.ExecContext(ctx, "UPDATE workstream_goaheads SET state='ended',end_reason='revoked',ended_at=? WHERE id=?", now.UnixMilli(), id); err != nil { + return GoAhead{}, false, err + } + g, err = goaheadIn(ctx, tx, id) + return g, true, err +} + func (s *Store) RevokeGoAhead(ctx context.Context, id string) (GoAhead, Change, error) { var out GoAhead var c Change err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - g, err := goaheadIn(ctx, tx, id) + now := s.now() + g, ended, err := revokeGoAheadIn(ctx, tx, id, now) if err != nil { return err } - now := s.now() - g = expire(g, now) - if g.State == "ended" { - out = g + out = g + if !ended { return nil } at := now.UnixMilli() - if _, err := tx.ExecContext(ctx, "UPDATE workstream_goaheads SET state='ended',end_reason='revoked',ended_at=? WHERE id=?", at, id); err != nil { - return err - } if err := touch(ctx, tx, g.WorkstreamID, at); err != nil { return err } - out, err = goaheadIn(ctx, tx, id) c = changed(g.WorkstreamID, PartGoAheads) - return err + return nil }) if err != nil { return GoAhead{}, Change{}, err diff --git a/internal/workstream/plan.go b/internal/workstream/plan.go index b3b0443243..d0f97be7ab 100644 --- a/internal/workstream/plan.go +++ b/internal/workstream/plan.go @@ -5,6 +5,10 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" + "math" + + "github.com/Agent-Field/codeaf/internal/workspace" ) func planIn(ctx context.Context, tx *sql.Tx, id string) (Plan, error) { @@ -109,6 +113,9 @@ func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []M return invalid("wave milestone does not exist") } for _, item := range wave.Items { + if err := workstreamDraftFields(item); err != nil { + return err + } switch item.Kind { case "job": if item.Job <= 0 || !jobs[Job{Factory: item.Factory, Number: item.Job}] { @@ -154,3 +161,17 @@ func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []M } return out, c, nil } + +func workstreamDraftFields(item PlanItem) error { + if item.Kind != "draft" && (item.Size != "" || item.BudgetUSD != 0 || item.Source != nil) { + return invalid("size, budget_usd and source are allowed only on drafts") + } + if item.Size != "" && item.Size != "S" && item.Size != "M" && item.Size != "L" { + return invalid("draft size must be S, M or L") + } + if math.IsNaN(item.BudgetUSD) || math.IsInf(item.BudgetUSD, 0) || item.BudgetUSD < 0 || item.BudgetUSD > workspace.WorkstreamGoAheadCapMax { + return invalid(fmt.Sprintf("draft budget_usd must be greater than 0 and at most %d when set", workspace.WorkstreamGoAheadCapMax)) + } + _, err := sourceJSON(item.Source) + return err +} diff --git a/internal/workstream/records.go b/internal/workstream/records.go index 40262f3e96..a2abfdf2ee 100644 --- a/internal/workstream/records.go +++ b/internal/workstream/records.go @@ -3,41 +3,50 @@ package workstream import ( "context" "database/sql" + "encoding/json" "strings" + "time" ) -const recordSelect = `SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,coalesce(supersedes,''),coalesce(superseded_by,'') FROM workstream_records` +const recordSelect = `SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,coalesce(supersedes,''),coalesce(superseded_by,''),goahead,coalesce(goahead_id,'') FROM workstream_records` func recordIn(ctx context.Context, tx *sql.Tx, id string) (Record, error) { var r Record var source sql.NullString + var proposal sql.NullString var at int64 var decided sql.NullInt64 - err := tx.QueryRowContext(ctx, recordSelect+" WHERE id=?", id).Scan(&r.ID, &r.WorkstreamID, &r.Kind, &r.Text, &r.State, &r.By, &source, &at, &decided, &r.Supersedes, &r.SupersededBy) + err := tx.QueryRowContext(ctx, recordSelect+" WHERE id=?", id).Scan(&r.ID, &r.WorkstreamID, &r.Kind, &r.Text, &r.State, &r.By, &source, &at, &decided, &r.Supersedes, &r.SupersededBy, &proposal, &r.GoAheadID) if err != nil { return r, found(err) } r.CreatedAt, r.DecidedAt = instant(at), optionalInstant(decided) + if proposal.Valid { + if err := json.Unmarshal([]byte(proposal.String), &r.GoAhead); err != nil { + return r, err + } + } r.Source, err = readSource(source) return r, err } func recordText(kind, text string) error { - if kind != "decision" && kind != "question" { - return invalid("record kind must be decision or question") + if kind != "decision" && kind != "question" && kind != "goahead" { + return invalid("record kind must be decision, question or goahead") } if strings.TrimSpace(text) == "" || !textWithin(text, 2000) { return invalid("record text must be 1–2000 bytes") } return nil } -func (s *Store) Records(ctx context.Context, id, kind, state string) ([]Record, error) { - if kind != "" && kind != "decision" && kind != "question" { +func (s *Store) Records(ctx context.Context, id, kind, state string, includeGoAheads ...bool) ([]Record, error) { + if kind != "" && kind != "decision" && kind != "question" && kind != "goahead" { return nil, invalid("unknown record kind") } if state != "" && state != "proposed" && state != "kept" && state != "dropped" && state != "superseded" { return nil, invalid("unknown record state") } out := []Record{} + include := len(includeGoAheads) > 0 && includeGoAheads[0] err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { if v < 3 { return ErrNotFound @@ -45,7 +54,7 @@ func (s *Store) Records(ctx context.Context, id, kind, state string) ([]Record, if _, err := getIn(ctx, tx, id); err != nil { return err } - rows, err := tx.QueryContext(ctx, `SELECT id FROM workstream_records WHERE workstream_id=? AND (?='' OR kind=?) AND (?='' OR state=?) ORDER BY created_at DESC,rowid DESC`, id, kind, kind, state, state) + rows, err := tx.QueryContext(ctx, `SELECT id FROM workstream_records WHERE workstream_id=? AND (?='' OR kind=?) AND (?!='' OR ? OR kind!='goahead') AND (?='' OR state=?) ORDER BY created_at DESC,rowid DESC`, id, kind, kind, kind, include, state, state) if err != nil { return err } @@ -80,8 +89,17 @@ 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. + if (p.Kind == "goahead") != (p.GoAhead != nil) || p.Kind == "goahead" && p.Kept { + return Record{}, Change{}, invalid("goahead is required only for kind goahead, which cannot be proposed kept") + } + if p.GoAhead != nil { + // Store the end at the grant's millisecond precision, so keeping it grants the same end. + proposal := *p.GoAhead + proposal.EndsAt = instant(proposal.EndsAt.UnixMilli()) + p.GoAhead = &proposal + } + // Decisions and questions preserve the wire's unattributed done behavior. + // Go-ahead proposals check done with the grant rules inside the transaction. asked := p.By if p.By == "" { if p.Kept { @@ -101,21 +119,35 @@ 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 := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, asked); err != nil { - return err + if p.Kind != "goahead" { + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, asked); err != nil { + return err + } + } + now := s.now() + var proposal any + if p.GoAhead != nil { + if err := workstreamGoAheadAllowedIn(ctx, tx, p.GoAhead.grant(p.WorkstreamID), now); err != nil { + return err + } + encoded, err := json.Marshal(p.GoAhead) + if err != nil { + return err + } + proposal = string(encoded) } id, err := s.newID() if err != nil { return err } - at := s.now().UnixMilli() + at := now.UnixMilli() state := "proposed" var decided any if p.Kept { state = "kept" decided = at } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at) VALUES (?,?,?,?,?,?,?,?,?)`, id, p.WorkstreamID, p.Kind, p.Text, state, by, source, at, decided); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at,goahead) VALUES (?,?,?,?,?,?,?,?,?,?)`, id, p.WorkstreamID, p.Kind, p.Text, state, by, source, at, decided, proposal); err != nil { return err } if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { @@ -151,8 +183,13 @@ func (s *Store) setRecordState(ctx context.Context, id, state string) (Record, C out = r return nil } - at := s.now().UnixMilli() - if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state=?,decided_at=? WHERE id=?", state, at, id); err != nil { + now := s.now() + goaheadChanged, err := s.workstreamRecordGoAheadIn(ctx, tx, &r, state, now) + if err != nil { + return err + } + at := now.UnixMilli() + if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state=?,decided_at=?,goahead_id=? WHERE id=?", state, at, nullString(r.GoAheadID), id); err != nil { return err } if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { @@ -160,6 +197,9 @@ func (s *Store) setRecordState(ctx context.Context, id, state string) (Record, C } out, err = recordIn(ctx, tx, id) c = changed(r.WorkstreamID, PartRecords) + if goaheadChanged { + c.Parts = append(c.Parts, PartGoAheads) + } return err }) if err != nil { @@ -187,6 +227,9 @@ func (s *Store) SupersedeRecord(ctx context.Context, id string, p RecordReplacem if err != nil { return err } + if r.Kind == "goahead" || p.Kind == "goahead" { + return invalid("goahead records cannot supersede or be superseded") + } if r.State == "superseded" { return ErrConflict } @@ -224,3 +267,25 @@ func (s *Store) SupersedeRecord(ctx context.Context, id string, p RecordReplacem } return out, old, c, nil } + +func (s *Store) workstreamRecordGoAheadIn(ctx context.Context, tx *sql.Tx, r *Record, state string, now time.Time) (bool, error) { + if r.Kind != "goahead" { + return false, nil + } + if state == "kept" { + if r.GoAhead == nil { + return false, invalid("goahead record requires a proposal") + } + g, err := s.grantGoAheadIn(ctx, tx, r.GoAhead.grant(r.WorkstreamID), now) + if err != nil { + return false, err + } + r.GoAheadID = g.ID + return true, nil + } + if r.State == "kept" && r.GoAheadID != "" { + _, ended, err := revokeGoAheadIn(ctx, tx, r.GoAheadID, now) + return ended, err + } + return false, nil +} diff --git a/internal/workstream/store.go b/internal/workstream/store.go index 1d7ecebf7b..d57b476aea 100644 --- a/internal/workstream/store.go +++ b/internal/workstream/store.go @@ -93,6 +93,14 @@ func sourceJSON(source *Source) (any, error) { if source.ID == "" || !textWithin(source.ID, 4096) || !lineWithin(source.Label, 200) { return nil, invalid("source needs an id and a label of at most 200 bytes") } + if source.Kind != "message" && source.ChatID != "" || source.Kind != "factory_job" && source.Factory != "" { + return nil, invalid("source chat_id is only for message and factory is only for factory_job") + } + if source.ChatID != "" { + if err := (workspace.Ref{Kind: workspace.ConversationKind, ID: source.ChatID}).Validate(); err != nil { + return nil, err + } + } switch source.Kind { case "chat", "page", "url": case "message": diff --git a/internal/workstream/workstream.go b/internal/workstream/workstream.go index 247132bedf..e6d426ee6a 100644 --- a/internal/workstream/workstream.go +++ b/internal/workstream/workstream.go @@ -122,13 +122,16 @@ type Milestone struct { Title string `json:"title"` } type PlanItem struct { - Kind string `json:"kind"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - DraftID string `json:"draft_id,omitempty"` - Title string `json:"title,omitempty"` - Repo string `json:"repo,omitempty"` - Text string `json:"text,omitempty"` + Kind string `json:"kind"` + Factory string `json:"factory,omitempty"` + Job int `json:"job,omitempty"` + DraftID string `json:"draft_id,omitempty"` + Title string `json:"title,omitempty"` + Repo string `json:"repo,omitempty"` + Text string `json:"text,omitempty"` + Size string `json:"size,omitempty"` + BudgetUSD float64 `json:"budget_usd,omitempty"` + Source *Source `json:"source,omitempty"` } type Wave struct { N int `json:"n"` @@ -198,12 +201,15 @@ type Record struct { Source *Source CreatedAt, DecidedAt time.Time Supersedes, SupersededBy string + GoAhead *GoAheadProposal + GoAheadID string } type RecordPropose struct { WorkstreamID, Kind, Text string Source *Source Kept bool By string + GoAhead *GoAheadProposal } type RecordReplacement struct { Kind, Text string @@ -232,3 +238,17 @@ type GoAheadGrant struct { CapUSD float64 EndsAt time.Time } + +type GoAheadProposal 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"` + EndsAt time.Time `json:"ends_at"` +} + +func (p GoAheadProposal) grant(id string) GoAheadGrant { + return GoAheadGrant{WorkstreamID: id, Scope: p.Scope, Factory: p.Factory, Job: p.Job, Wave: p.Wave, MilestoneID: p.MilestoneID, CapUSD: p.CapUSD, EndsAt: p.EndsAt} +} diff --git a/internal/workstream/workstream_fields_v02_test.go b/internal/workstream/workstream_fields_v02_test.go new file mode 100644 index 0000000000..e4256d3a71 --- /dev/null +++ b/internal/workstream/workstream_fields_v02_test.go @@ -0,0 +1,398 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "math" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// WP-F2 (a): draft metadata survives storage, while old plan JSON stays optional. +func TestWorkstreamDraftFieldsRoundTripAndOldJSON(t *testing.T) { + s, db, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Drafts") + item := PlanItem{Kind: "draft", DraftID: "draft", Title: "Draft", Size: "M", BudgetUSD: 10000, Source: &Source{Kind: "message", ID: "message", ChatID: "chat", Label: "Scope"}} + waves := []Wave{{N: 1, Items: []PlanItem{item}}} + p, _, err := s.SetPlan(ctx, w.ID, 0, nil, waves, Attribution{}) + if err != nil || !reflect.DeepEqual(p.Waves, waves) { + t.Fatalf("set: %+v %v", p, err) + } + p, err = s.Plan(ctx, w.ID) + if err != nil || !reflect.DeepEqual(p.Waves, waves) { + t.Fatalf("get: %+v %v", p, err) + } + err = db.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `UPDATE workstream_plans SET waves=? WHERE workstream_id=?`, `[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"old","title":"Old"}]}]`, w.ID) + return err + }) + if err != nil { + t.Fatal(err) + } + p, err = s.Plan(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(p.Waves[0].Items[0]) + if err != nil || strings.Contains(string(raw), "size") || strings.Contains(string(raw), "budget_usd") || strings.Contains(string(raw), "source") { + t.Fatalf("old fields must stay absent: %s %v", raw, err) + } +} + +// WP-F2 (a): each invalid draft field refuses the entire plan without a write. +func TestWorkstreamDraftFieldRefusalsAreAtomic(t *testing.T) { + for name, item := range map[string]PlanItem{ + "job size": {Kind: "job", Factory: "f", Job: 7, Size: "S"}, + "job budget": {Kind: "job", Factory: "f", Job: 7, BudgetUSD: 1}, + "job source": {Kind: "job", Factory: "f", Job: 7, Source: &Source{Kind: "chat", ID: "chat"}}, + "size XL": {Kind: "draft", DraftID: "d", Title: "Draft", Size: "XL"}, + "negative": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: -1}, + "over cap": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: 10000.01}, + "NaN": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: math.NaN()}, + "infinite": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: math.Inf(1)}, + "message without chat": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "message", ID: "msg"}}, + "chat with factory": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: "chat", Factory: "f"}}, + "page with chat": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "page", ID: "p", ChatID: "chat"}}, + "bad source kind": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "other", ID: "p"}}, + "empty source id": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat"}}, + "long source id": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: strings.Repeat("x", 4097)}}, + "multiline label": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: "chat", Label: "a\nb"}}, + } { + t.Run(name, func(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Refusals", workspace.Ref{Kind: workspace.FactoryJobKind, Factory: "f", ID: "7"}) + before, _ := s.Plan(ctx, w.ID) + _, c, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{item}}}, Attribution{}) + if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { + t.Fatalf("refusal: %+v %v", c, err) + } + after, err := s.Plan(ctx, w.ID) + row, rowErr := s.Get(ctx, w.ID) + if err != nil || rowErr != nil || !reflect.DeepEqual(before, after) || !row.UpdatedAt.Equal(w.UpdatedAt) { + t.Fatalf("refusal changed plan: %+v %v %v", after, err, rowErr) + } + }) + } +} + +func workstreamProposalFixture(t *testing.T, s *Store, now time.Time) (Workstream, GoAheadProposal) { + t.Helper() + w := createStream(t, s, "Proposal", workspace.Ref{Kind: workspace.FactoryJobKind, Factory: "f", ID: "7"}) + if _, _, err := s.SetPlan(context.Background(), w.ID, 0, []Milestone{{ID: "ship", Title: "Ship"}}, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { + t.Fatal(err) + } + return w, GoAheadProposal{Scope: "wave", Wave: 1, CapUSD: 12.5, EndsAt: now.Add(time.Hour)} +} + +// WP-F2 (d): keeping grants once, dropping revokes, re-keeping replaces the id. +func TestWorkstreamGoAheadRecordLifecycle(t *testing.T) { + for _, target := range []GoAheadProposal{{Scope: "job", Factory: "f", Job: 7}, {Scope: "wave", Wave: 1}, {Scope: "milestone", MilestoneID: "ship"}} { + t.Run(target.Scope, func(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w, p := workstreamProposalFixture(t, s, *now) + target.CapUSD, target.EndsAt = p.CapUSD, p.EndsAt + r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &target}) + if err != nil || r.State != "proposed" || r.GoAheadID != "" || !reflect.DeepEqual(r.GoAhead, &target) { + t.Fatalf("propose: %+v %v", r, err) + } + if grants, err := s.GoAheads(ctx, w.ID, true); err != nil || len(grants) != 0 { + t.Fatalf("proposal granted: %+v %v", grants, err) + } + r, c, err := s.KeepRecord(ctx, r.ID) + if err != nil || r.State != "kept" || r.DecidedAt.IsZero() || !slices.Contains(c.Parts, PartRecords) || !slices.Contains(c.Parts, PartGoAheads) { + t.Fatalf("keep: %+v %+v %v", r, c, err) + } + grants, err := s.GoAheads(ctx, w.ID, false) + if err != nil || len(grants) != 1 { + t.Fatalf("grants: %+v %v", grants, err) + } + g := grants[0] + if g.ID != r.GoAheadID || g.Scope != target.Scope || g.Factory != target.Factory || g.Job != target.Job || g.Wave != target.Wave || g.MilestoneID != target.MilestoneID || g.CapUSD != target.CapUSD || !g.EndsAt.Equal(target.EndsAt) { + t.Fatalf("grant differs: %+v %+v", g, target) + } + again, c, err := s.KeepRecord(ctx, r.ID) + grants, listErr := s.GoAheads(ctx, w.ID, true) + if err != nil || listErr != nil || !reflect.DeepEqual(again, r) || len(c.Parts) != 0 || len(grants) != 1 { + t.Fatalf("repeat keep: %+v %+v %v %v", again, c, err, listErr) + } + dropped, c, err := s.DropRecord(ctx, r.ID) + grants, listErr = s.GoAheads(ctx, w.ID, true) + if err != nil || listErr != nil || dropped.State != "dropped" || dropped.GoAheadID != r.GoAheadID || len(grants) != 1 || grants[0].State != "ended" || grants[0].EndReason != "revoked" || !slices.Contains(c.Parts, PartGoAheads) { + t.Fatalf("drop: %+v %+v %+v %v %v", dropped, c, grants, err, listErr) + } + kept, _, err := s.KeepRecord(ctx, r.ID) + if err != nil || kept.GoAheadID == r.GoAheadID || kept.GoAheadID == "" { + t.Fatalf("re-keep: %+v %v", kept, err) + } + if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { + t.Fatal(err) + } + grants, err = s.GoAheads(ctx, w.ID, true) + if err != nil || len(grants) != 2 || grants[0].ID != kept.GoAheadID || grants[0].EndReason != "workstream_done" { + t.Fatalf("done: %+v %v", grants, err) + } + records, err := s.Records(ctx, w.ID, "goahead", "") + if err != nil || len(records) != 1 || !reflect.DeepEqual(records[0], kept) { + t.Fatalf("done changed kept record: %+v %v", records, err) + } + again, c, err = s.KeepRecord(ctx, r.ID) + if err != nil || len(c.Parts) != 0 || !reflect.DeepEqual(again, kept) { + t.Fatalf("already kept on done: %+v %+v %v", again, c, err) + } + }) + } +} + +// WP-F2 (d): keep checks current state in its transaction and rolls back refusal. +func TestWorkstreamGoAheadKeepRefusalsAreAtomic(t *testing.T) { + for _, reason := range []string{"done", "wave removed", "expired"} { + t.Run(reason, func(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w, p := workstreamProposalFixture(t, s, *now) + r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) + if err != nil { + t.Fatal(err) + } + want := ErrInvalid + switch reason { + case "done": + _, _, err = s.MarkDone(ctx, w.ID, "") + want = ErrConflict + case "wave removed": + _, _, err = s.SetPlan(ctx, w.ID, 1, nil, nil, Attribution{}) + case "expired": + *now = p.EndsAt + } + if err != nil { + t.Fatal(err) + } + before, _ := s.Get(ctx, w.ID) + _, c, err := s.KeepRecord(ctx, r.ID) + if !errors.Is(err, want) || len(c.Parts) != 0 { + t.Fatalf("keep: %+v %v", c, err) + } + records, err := s.Records(ctx, w.ID, "goahead", "") + grants, grantErr := s.GoAheads(ctx, w.ID, true) + after, rowErr := s.Get(ctx, w.ID) + if err != nil || grantErr != nil || rowErr != nil || len(records) != 1 || !reflect.DeepEqual(records[0], r) || len(grants) != 0 || !after.UpdatedAt.Equal(before.UpdatedAt) { + t.Fatalf("refusal wrote: %+v %+v %v %v %v", records, grants, err, grantErr, rowErr) + } + }) + } +} + +// WP-F2 (d), (f): invalid proposals/supersedes leave records and grants unchanged. +func TestWorkstreamGoAheadProposeRefusalsAndListFilters(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w, p := workstreamProposalFixture(t, s, *now) + for _, kind := range []string{"decision", "question", "goahead"} { + ask := RecordPropose{WorkstreamID: w.ID, Kind: kind, Text: "Why"} + if kind == "goahead" { + ask.GoAhead = &p + } + if _, _, err := s.ProposeRecord(ctx, ask); err != nil { + t.Fatal(err) + } + } + for _, tc := range []struct { + kind string + include bool + count int + }{{"", false, 2}, {"", true, 3}, {"goahead", false, 1}, {"question", true, 1}} { + records, err := s.Records(ctx, w.ID, tc.kind, "", tc.include) + if err != nil || len(records) != tc.count { + t.Fatalf("filter %+v: %+v %v", tc, records, err) + } + } + before, _ := s.Records(ctx, w.ID, "", "", true) + defaults, err := s.Records(ctx, w.ID, "", "") + if err != nil || len(defaults) != 2 { + t.Fatalf("default list included goaheads: %+v %v", defaults, err) + } + missing := p + missing.Wave = 2 + for _, ask := range []RecordPropose{ + {Kind: "goahead"}, {Kind: "goahead", GoAhead: &p, Kept: true}, + {Kind: "decision", GoAhead: &p}, {Kind: "question", GoAhead: &p}, + {Kind: "goahead", GoAhead: &missing}, + } { + ask.WorkstreamID, ask.Text = w.ID, "Why" + if _, c, err := s.ProposeRecord(ctx, ask); !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { + t.Fatalf("propose %+v: %+v %v", ask, c, err) + } + } + for _, tc := range []struct{ id, kind string }{{before[0].ID, ""}, {before[0].ID, "decision"}, {before[1].ID, "goahead"}} { + if _, _, c, err := s.SupersedeRecord(ctx, tc.id, RecordReplacement{Kind: tc.kind, Text: "Replacement"}); !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { + t.Fatalf("supersede %+v: %+v %v", tc, c, err) + } + } + after, err := s.Records(ctx, w.ID, "", "", true) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("refusals wrote: %+v %v", after, err) + } + if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { + t.Fatal(err) + } + for _, by := range []string{"", "you", "agent"} { + if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p, By: by}); !errors.Is(err, ErrConflict) { + t.Fatalf("done attribution %q: %v", by, err) + } + } +} + +// WP-F2 (d): proposal validation uses the same cap, end and scope rules as grants. +func TestWorkstreamGoAheadProposalsUseGrantBounds(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w, good := workstreamProposalFixture(t, s, *now) + for name, mutate := range map[string]func(*GoAheadProposal){ + "cap zero": func(p *GoAheadProposal) { p.CapUSD = 0 }, + "cap negative": func(p *GoAheadProposal) { p.CapUSD = -1 }, + "cap too large": func(p *GoAheadProposal) { p.CapUSD = 10000.01 }, + "cap NaN": func(p *GoAheadProposal) { p.CapUSD = math.NaN() }, + "cap infinite": func(p *GoAheadProposal) { p.CapUSD = math.Inf(1) }, + "end zero": func(p *GoAheadProposal) { p.EndsAt = time.Time{} }, + "end now": func(p *GoAheadProposal) { p.EndsAt = *now }, + "end too far": func(p *GoAheadProposal) { p.EndsAt = now.Add(90*24*time.Hour + time.Millisecond) }, + "mixed scope": func(p *GoAheadProposal) { p.Factory = "f" }, + "missing job": func(p *GoAheadProposal) { p.Scope = "job"; p.Factory = "f"; p.Job = 8; p.Wave = 0 }, + "missing milestone": func(p *GoAheadProposal) { p.Scope = "milestone"; p.MilestoneID = "gone"; p.Wave = 0 }, + } { + t.Run(name, func(t *testing.T) { + p := good + mutate(&p) + _, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) + if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { + t.Fatalf("proposal: %+v %v", c, err) + } + }) + } + all, err := s.Records(ctx, w.ID, "", "", true) + grants, grantErr := s.GoAheads(ctx, w.ID, true) + if err != nil || grantErr != nil || len(all) != 0 || len(grants) != 0 { + t.Fatalf("refusals wrote: %+v %+v %v %v", all, grants, err, grantErr) + } + good.CapUSD, good.EndsAt = 10000, now.Add(90*24*time.Hour) + if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &good}); err != nil { + t.Fatalf("inclusive bounds: %v", err) + } +} + +// WP-F2 (d): a failed record update rolls back the preceding grant or revocation. +func TestWorkstreamGoAheadRecordTransactionRollsBackGrantChanges(t *testing.T) { + s, db, now := testStore(t) + ctx := context.Background() + w, p := workstreamProposalFixture(t, s, *now) + r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) + if err != nil { + t.Fatal(err) + } + for _, state := range []string{"kept", "dropped"} { + if state == "dropped" { + r, _, err = s.KeepRecord(ctx, r.ID) + if err != nil { + t.Fatal(err) + } + } + before, err := s.GoAheads(ctx, w.ID, true) + if err != nil { + t.Fatal(err) + } + err = db.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `CREATE TRIGGER workstream_record_fail BEFORE UPDATE ON workstream_records BEGIN SELECT RAISE(ABORT,'fixture refuses record write'); END`) + return err + }) + if err != nil { + t.Fatal(err) + } + var c Change + if state == "kept" { + _, c, err = s.KeepRecord(ctx, r.ID) + } else { + _, c, err = s.DropRecord(ctx, r.ID) + } + if err == nil || len(c.Parts) != 0 { + t.Fatalf("failed record write: %+v %v", c, err) + } + after, err := s.GoAheads(ctx, w.ID, true) + records, recordErr := s.Records(ctx, w.ID, "goahead", "") + if err != nil || recordErr != nil || !reflect.DeepEqual(before, after) || len(records) != 1 || !reflect.DeepEqual(records[0], r) { + t.Fatalf("partial commit: %+v %+v %v %v", after, records, err, recordErr) + } + err = db.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "DROP TRIGGER workstream_record_fail") + return err + }) + if err != nil { + t.Fatal(err) + } + } +} + +// WP-F2 (d): expiry and explicit revocation leave a kept record unchanged. +func TestWorkstreamGoAheadDropAfterExpiryOrRevocation(t *testing.T) { + for _, reason := range []string{"expired", "revoked"} { + t.Run(reason, func(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w, p := workstreamProposalFixture(t, s, *now) + r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) + if err != nil { + t.Fatal(err) + } + r, _, err = s.KeepRecord(ctx, r.ID) + if err != nil { + t.Fatal(err) + } + if reason == "expired" { + *now = p.EndsAt + } else { + _, _, err = s.RevokeGoAhead(ctx, r.GoAheadID) + } + if err != nil { + t.Fatal(err) + } + records, err := s.Records(ctx, w.ID, "goahead", "") + if err != nil || len(records) != 1 || !reflect.DeepEqual(records[0], r) { + t.Fatalf("ended changed record: %+v %v", records, err) + } + dropped, c, err := s.DropRecord(ctx, r.ID) + grants, grantErr := s.GoAheads(ctx, w.ID, true) + if err != nil || grantErr != nil || dropped.GoAheadID != r.GoAheadID || slices.Contains(c.Parts, PartGoAheads) || len(grants) != 1 || grants[0].EndReason != reason { + t.Fatalf("drop: %+v %+v %+v %v %v", dropped, c, grants, err, grantErr) + } + }) + } +} + +// WP-F2 (d): a proposal's end is stored at the grant's precision, so keep grants the same end. +func TestWorkstreamGoAheadKeepGrantsTheProposedEndExactly(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w, p := workstreamProposalFixture(t, s, *now) + p.EndsAt = p.EndsAt.Add(123456 * time.Microsecond) + r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) + if err != nil { + t.Fatal(err) + } + r, _, err = s.KeepRecord(ctx, r.ID) + grants, listErr := s.GoAheads(ctx, w.ID, false) + if err != nil || listErr != nil || len(grants) != 1 || r.GoAhead == nil { + t.Fatalf("keep: %+v %+v %v %v", r, grants, err, listErr) + } + if !grants[0].EndsAt.Equal(r.GoAhead.EndsAt) || r.GoAhead.EndsAt.Nanosecond() != 123000000 { + t.Fatalf("proposal end %v, grant end %v", r.GoAhead.EndsAt, grants[0].EndsAt) + } +} From b79efbf558c315dea2258c8dcc3cc51c2a537b8d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 05:46:22 -0500 Subject: [PATCH 10/20] workstreams: the database refuses deleting a workstream's collection; chat resume skips the per-chat database scan (#2303) * workspace: SQLite refuses deleting a workstream's collection, for any writer An engine that opened collections.db at schema 2 before another engine migrated it keeps its cached version and its old Delete, which has no workstream check: its DELETE FROM collections would cascade away the workstream, its pages, saved versions, records, go-aheads and plan. A BEFORE DELETE trigger now refuses that delete in the database itself. New and migrated files get it with the schema; schema-3 files from earlier integration builds gain it (IF NOT EXISTS) inside their next write. Plain collections delete and cascade as before; Store.Delete keeps its message. The deleted-pages purge test deletes the workstream row before its collection, since the collection alone can no longer be deleted. Co-Authored-By: Claude Opus 5.5 * chat: resume asks collections.db about workstream agents only for empty chats v3EmptyUnassignedSession opened collections.db before its cheap local checks, so resuming in a project scanned the database once per saved conversation, including every conversation somebody spoke in. The ownership question now comes last, for folders the local checks already found empty, and still keeps a folder whose owner cannot be read. Co-Authored-By: Claude Opus 5.5 * changes: entry for the workstreams stability fixes Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/chatv3_layout.go | 7 +- cmd/codeaf/chatv3_layout_stab_test.go | 85 ++++++++ .../2303-workstreams-stability-fixes.md | 13 ++ internal/workspace/schema3_test.go | 203 ++++++++++++++++++ internal/workspace/store.go | 1 + internal/workspace/workstreams.go | 10 +- internal/workstream/pages_test.go | 7 +- 7 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 cmd/codeaf/chatv3_layout_stab_test.go create mode 100644 docs/changes/unreleased/2303-workstreams-stability-fixes.md diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 9b0c82e262..ca38a344a6 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -718,10 +718,9 @@ func v3EmptySession(dir string) bool { return v3EmptyUnassignedSession(dir) } +var v3WorkstreamKeeps = workstreamKeepsConversation + func v3EmptyUnassignedSession(dir string) bool { - if workstreamKeepsConversation(dir) { - return false - } place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) if spoken || !sure || session.HasSavedTasks(dir) { @@ -734,7 +733,7 @@ func v3EmptyUnassignedSession(dir string) bool { return false } } - return true + return !v3WorkstreamKeeps(dir) } // v3ReapEmpty removes the empty session folders a launch left behind, except diff --git a/cmd/codeaf/chatv3_layout_stab_test.go b/cmd/codeaf/chatv3_layout_stab_test.go new file mode 100644 index 0000000000..aa0ae2c616 --- /dev/null +++ b/cmd/codeaf/chatv3_layout_stab_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "reflect" + "testing" + "time" +) + +// E-M3-a: a spoken transcript proves the folder must stay without consulting +// the workstream owner, even when its last-spoken metadata is absent. +func TestStabEM3SpokenSessionsSkipWorkstreamOwnership(t *testing.T) { + dir := writeV3Session(t, t.TempDir(), "spoken", "Keep this conversation", time.Time{}) + previous := v3WorkstreamKeeps + t.Cleanup(func() { v3WorkstreamKeeps = previous }) + lookups := 0 + v3WorkstreamKeeps = func(string) bool { + lookups++ + return false + } + if v3EmptyUnassignedSession(dir) { + t.Fatal("a spoken conversation was considered empty") + } + if lookups != 0 { + t.Fatalf("spoken conversation made %d ownership lookups, want 0", lookups) + } +} + +// E-M3-b: only a provably empty folder needs an ownership lookup, and a kept +// answer must prevent its reuse or removal. +func TestStabEM3EmptySessionsCheckWorkstreamOwnershipOnce(t *testing.T) { + dir := writeV3Session(t, t.TempDir(), "empty", "", time.Time{}) + for _, tc := range []struct { + name string + kept bool + empty bool + }{ + {"kept", true, false}, + {"unowned", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + previous := v3WorkstreamKeeps + t.Cleanup(func() { v3WorkstreamKeeps = previous }) + lookups := 0 + v3WorkstreamKeeps = func(got string) bool { + lookups++ + if got != dir { + t.Errorf("ownership lookup for %q, want %q", got, dir) + } + return tc.kept + } + if empty := v3EmptyUnassignedSession(dir); empty != tc.empty { + t.Fatalf("empty = %v, want %v when kept = %v", empty, tc.empty, tc.kept) + } + if lookups != 1 { + t.Fatalf("empty conversation made %d ownership lookups, want 1", lookups) + } + }) + } +} + +// E-M3-c: scanning several spoken folders and one empty folder asks about +// workstream ownership only for the empty candidate. +func TestStabEM3BucketScanOnlyLooksUpEmptyCandidates(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROFILE_DIR", t.TempDir()) + bucket := t.TempDir() + for _, id := range []string{"00000000000000a1", "00000000000000a2", "00000000000000a3"} { + writeV3Session(t, bucket, id, "Keep this conversation", time.Time{}) + } + dir := writeV3Session(t, bucket, "00000000000000a4", "", time.Time{}) + previous := v3WorkstreamKeeps + t.Cleanup(func() { v3WorkstreamKeeps = previous }) + var lookups []string + v3WorkstreamKeeps = func(dir string) bool { + lookups = append(lookups, dir) + return false + } + spoken, empty := v3ScanBucket(bucket) + if len(spoken) != 3 || len(empty) != 1 || empty[0].dir != dir { + t.Fatalf("bucket scan returned spoken %v, empty %v", spoken, empty) + } + if !reflect.DeepEqual(lookups, []string{dir}) { + t.Fatalf("ownership lookups = %v, want only %q", lookups, dir) + } +} diff --git a/docs/changes/unreleased/2303-workstreams-stability-fixes.md b/docs/changes/unreleased/2303-workstreams-stability-fixes.md new file mode 100644 index 0000000000..a42d7db78e --- /dev/null +++ b/docs/changes/unreleased/2303-workstreams-stability-fixes.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: A workstream's collection cannot be deleted by any writer; chat resume skips per-chat database reads +pr: 2303 +surface: [app, chat] +invalidates: [] +--- + +An engine that opened the collections database before another engine +migrated it could delete a workstream's collection and with it the +workstream's pages, history, records, go-aheads and plan. The database now +refuses that delete itself. Resuming a chat no longer opens the collections +database once for every saved conversation; it asks only about empty ones. diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go index ac38029234..c5d63d8900 100644 --- a/internal/workspace/schema3_test.go +++ b/internal/workspace/schema3_test.go @@ -8,6 +8,8 @@ import ( "reflect" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/sqlitefile" ) // Part A (a): this literal is schema 2, independent of the schema being written. @@ -246,3 +248,204 @@ func TestWorkstreamSchemaThreeRequiresDeletionColumns(t *testing.T) { }) } } + +func workstreamSchemaTwoWriter(t *testing.T, path string) *sql.Conn { + t.Helper() + db, err := sql.Open("sqlite", sqlitefile.URI(path, "_pragma=foreign_keys(1)")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + conn, err := db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + var version, foreignKeys int + if err := conn.QueryRowContext(context.Background(), "PRAGMA user_version").Scan(&version); err != nil || version != 2 { + t.Fatalf("older writer did not open schema 2: %d, %v", version, err) + } + if err := conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil || foreignKeys != 1 { + t.Fatalf("older writer has foreign keys disabled: %d, %v", foreignKeys, err) + } + return conn +} + +func workstreamStoredRows(t *testing.T, db *sql.DB, table string) [][]any { + t.Helper() + rows, err := db.Query("SELECT * FROM " + table + " ORDER BY 1,2") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + t.Fatal(err) + } + var out [][]any + for rows.Next() { + values := make([]any, len(columns)) + pointers := make([]any, len(columns)) + for i := range values { + pointers[i] = &values[i] + } + if err := rows.Scan(pointers...); err != nil { + t.Fatal(err) + } + out = append(out, values) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +// E-B1-a: a connection opened before migration must not cascade away a +// workstream, even when its writer bypasses the current Store.Delete guard. +func TestStabEB1AnOlderWriterCannotDeleteAWorkstream(t *testing.T) { + ctx := context.Background() + path := writeSchemaTwo(t) + older := workstreamSchemaTwoWriter(t, path) + s := openTestStore(t, path) + seedWorkstream(t, s, "protected", "Protected") + if userVersion(t, s) != 3 { + t.Fatal("workstream creation did not migrate schema 2") + } + // The domain store imports workspace, so this fixture uses its shared SQL + // transaction to create the dependent rows without an import cycle. + if err := s.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES + ('protected','conversation','agent-protected','',''),('protected','factory_job','7','','f'); +INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection) + VALUES ('aaaa','collection','protected','','protected'); +INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) + VALUES ('page','protected','Notes','you',0,2,200,'you','{"type":"doc","content":[]}'); +INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES + ('page',1,100,'you','Notes','{"type":"doc","content":[]}',27), + ('page',2,200,'you','Notes','{"type":"doc","content":[]}',27); +INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) + VALUES ('record','protected','decision','Keep this decision','kept','you',100); +INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) + VALUES ('grant','protected','wave',1,1,200,'active',100); +INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) + VALUES ('protected',1,100,'you','[]','[]'); +`) + return err + }); err != nil { + t.Fatal(err) + } + tables := []struct { + name string + count int + }{ + {"collections", 3}, {"memberships", 6}, {"workstreams", 1}, {"workstream_jobs", 1}, + {"workstream_pages", 1}, {"workstream_page_versions", 2}, {"workstream_records", 1}, + {"workstream_goaheads", 1}, {"workstream_plans", 1}, + } + before := make(map[string][][]any, len(tables)) + for _, table := range tables { + before[table.name] = workstreamStoredRows(t, s.db, table.name) + if len(before[table.name]) != table.count { + t.Fatalf("%s fixture has %d rows, want %d", table.name, len(before[table.name]), table.count) + } + } + if _, err := older.ExecContext(ctx, "DELETE FROM collections WHERE id=?", "protected"); err == nil || !strings.Contains(err.Error(), ErrWorkstreamDelete.Error()) { + t.Fatalf("older writer was not refused with the workstream explanation: %v", err) + } + for _, table := range tables { + if after := workstreamStoredRows(t, s.db, table.name); !reflect.DeepEqual(after, before[table.name]) { + t.Errorf("refused delete changed %s: before %v, after %v", table.name, before[table.name], after) + } + } +} + +// E-B1-b: the SQL guard preserves plain collection deletion and its cascades, +// and Store.Delete still returns the friendly workstream refusal. +func TestStabEB1PlainCollectionsStillDelete(t *testing.T) { + ctx := context.Background() + path := writeSchemaTwo(t) + older := workstreamSchemaTwoWriter(t, path) + s := openTestStore(t, path) + seedWorkstream(t, s, "protected", "Protected") + for _, writer := range []string{"older writer", "store"} { + t.Run(writer, func(t *testing.T) { + plain := createTestCollection(t, s, "Plain "+writer) + if err := s.Add(ctx, plain.ID, Ref{Kind: ConversationKind, ID: "plain-chat"}); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, "aaaa", Ref{Kind: CollectionKind, ID: plain.ID}); err != nil { + t.Fatal(err) + } + var members int + if err := s.db.QueryRow("SELECT count(*) FROM memberships WHERE collection_id=? OR target_collection=?", plain.ID, plain.ID).Scan(&members); err != nil || members != 2 { + t.Fatalf("plain membership fixture: %d, %v", members, err) + } + if writer == "older writer" { + result, err := older.ExecContext(ctx, "DELETE FROM collections WHERE id=?", plain.ID) + if err != nil { + t.Fatal(err) + } + if n, err := result.RowsAffected(); err != nil || n != 1 { + t.Fatalf("older writer deleted %d collections: %v", n, err) + } + } else if parents, err := s.Delete(ctx, plain.ID); err != nil || !reflect.DeepEqual(parents, []string{"aaaa"}) { + t.Fatalf("plain Store.Delete: %v, %v", parents, err) + } + var collections int + if err := s.db.QueryRow("SELECT count(*) FROM collections WHERE id=?", plain.ID).Scan(&collections); err != nil || collections != 0 { + t.Fatalf("plain collection survived: %d, %v", collections, err) + } + if err := s.db.QueryRow("SELECT count(*) FROM memberships WHERE collection_id=? OR target_collection=?", plain.ID, plain.ID).Scan(&members); err != nil || members != 0 { + t.Fatalf("plain memberships did not cascade: %d, %v", members, err) + } + }) + } + if _, err := s.Delete(ctx, "protected"); err != ErrWorkstreamDelete { + t.Fatalf("Store.Delete lost its friendly workstream refusal: %v", err) + } +} + +// E-B1-c: earlier schema-3 files remain readable without modification and gain +// the database guard in the transaction of their next write. +func TestStabEB1AnEarlierSchemaThreeFileGainsTheGuardOnItsNextWrite(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "earlier.db") + fixture := openTestStore(t, path) + plain := createTestCollection(t, fixture, "Earlier") + if _, err := fixture.db.Exec("DROP TRIGGER IF EXISTS workstream_collection_keep"); err != nil { + t.Fatal(err) + } + if err := fixture.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + if userVersion(t, s) != 3 { + t.Fatal("fixture is not schema 3") + } + list, err := s.List(ctx, nil) + if err != nil || len(list) != 1 || list[0].ID != plain.ID { + t.Fatalf("earlier schema-3 read: %+v, %v", list, err) + } + var triggers int + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='workstream_collection_keep'").Scan(&triggers); err != nil || triggers != 0 { + t.Fatalf("read installed the guard: %d, %v", triggers, err) + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) { + t.Fatalf("open or read changed earlier schema 3: %v", err) + } + if err := s.Rename(ctx, plain.ID, "Updated"); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='workstream_collection_keep'").Scan(&triggers); err != nil || triggers != 1 { + t.Fatalf("first write did not install the guard: %d, %v", triggers, err) + } + if err := s.Rename(ctx, plain.ID, "Updated again"); err != nil { + t.Fatalf("installing the guard again was not idempotent: %v", err) + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index 6f2a5c33bc..e6d7f8f1d9 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -311,6 +311,7 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { if err == nil { switch state { case schemaReady: + _, err = tx.ExecContext(ctx, workstreamCollectionKeep) case schemaLegacy, schemaTwo: err = migrateLegacy(ctx, tx, state.version()) case schemaBlank: diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go index 1f50e80b1f..07ad6cbf36 100644 --- a/internal/workspace/workstreams.go +++ b/internal/workspace/workstreams.go @@ -220,7 +220,15 @@ CREATE TABLE workstream_plans ( updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), milestones TEXT NOT NULL, waves TEXT NOT NULL ); -`, WorkstreamGoAheadCapMax, WorkstreamGoAheadDurationMax.Milliseconds()) +`, WorkstreamGoAheadCapMax, WorkstreamGoAheadDurationMax.Milliseconds()) + workstreamCollectionKeep + +// Older writers may already hold a connection opened before workstreams existed. +// The database must enforce the same deletion refusal as Store.Delete. +const workstreamCollectionKeep = ` +CREATE TRIGGER IF NOT EXISTS workstream_collection_keep BEFORE DELETE ON collections + WHEN EXISTS(SELECT 1 FROM workstreams WHERE id=OLD.id) + BEGIN SELECT RAISE(ABORT,'mark a workstream done instead'); END; +` // Opening a schema-3 file verifies the tables needed by both domain reads and // collection ownership checks. A partial schema is refused rather than repaired. diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go index 3b5b84c257..22b15f8a4a 100644 --- a/internal/workstream/pages_test.go +++ b/internal/workstream/pages_test.go @@ -453,8 +453,8 @@ func TestWorkstreamDeletedDescendantRestoresAtRoot(t *testing.T) { } } -// WP-F1 (b): collection deletion still purges deleted pages and their saved -// versions through the existing cascade, while page.delete retains both. +// WP-F1 (b): explicitly purging a workstream and its collection removes deleted +// pages and their saved versions through the cascade, while page.delete retains both. func TestWorkstreamDeletedPagesCollectionCascade(t *testing.T) { s, storage, _ := testStore(t) ctx := context.Background() @@ -470,6 +470,9 @@ func TestWorkstreamDeletedPagesCollectionCascade(t *testing.T) { } } if err := storage.Write(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, "DELETE FROM workstreams WHERE id=?", w.ID); err != nil { + return err + } _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", w.ID) return err }); err != nil { From 5cc5a2838d14e86a721bf53dfef2fdd02504190a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 06:56:02 -0500 Subject: [PATCH 11/20] workstreams: size limits on plans, pages and records; agent Spec/Decisions pages read by jobs (#2310) * workstreams: size limits on plans, pages and records (wire v0.2.1) Draft text was bounded only by its own length, plans had no count or encoded-size bound, and a workstream could accumulate pages and records without limit, so every list read materialized all of them. The store now refuses, inside its write transaction and before anything is written: draft text over 80,000 bytes; a plan over 500 milestones, 500 waves, 5,000 items or 4 MiB of encoded milestones and waves; a 5,001st page (deleted pages included) or record. The new ErrLimit names the bound and answers too_large on the wire; the plan_set check refuses the counts and draft text early. The conformance testdata is regenerated from the contract (too_large on plan_set, record.propose, record.supersede). The agent manual states the limits and that new agent pages titled Spec or Decisions are read by jobs. Co-Authored-By: Claude Opus 5.5 * workstreams: agent pages titled Spec or Decisions are read by jobs, as page.create makes them Co-Authored-By: Claude Opus 5.5 * workstreams: control characters stay bad_params past the draft text bound; the manual separates the tool's draft limit Co-Authored-By: Claude Opus 5.5 * workstreams: tests for the size limits and the agent's Read-by-jobs default TestStabEm1AgentSpecAndDecisionsPagesAreReadByJobs: agent pages titled Spec or Decisions (any case, surrounding space) are read by jobs, others not; updates keep the flag. TestStabEM4*: plan bounds through the real wire and the store, exact bounds accepted and one past refused with too_large and no event; page and record counts (deleted pages included) refuse wire and agent writes; plan_set and record.propose too_large conformance cases; early wire refusals keep bad_params for control characters. Co-Authored-By: Claude Opus 5.5 * docs: change entry for the workstream size limits Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 2 +- cmd/codeaf/appworkstreams_agent.go | 2 +- cmd/codeaf/appworkstreams_agent_test.go | 253 ++++++++++++++++++ cmd/codeaf/appworkstreams_conformance_test.go | 10 + .../workstreams-wire/record.propose.json | 1 + .../workstreams-wire/record.supersede.json | 1 + .../workstreams-wire/workstream.plan_set.json | 1 + .../2310-workstreams-size-limits.md | 13 + internal/appwire/workstreams_check.go | 17 ++ internal/appwire/workstreams_check_test.go | 66 +++++ internal/manual/chat/collections.md | 18 +- internal/workstream/limits_test.go | 134 ++++++++++ internal/workstream/pages.go | 8 + internal/workstream/plan.go | 20 ++ internal/workstream/records.go | 19 ++ internal/workstream/workstream.go | 12 +- 16 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 docs/changes/unreleased/2310-workstreams-size-limits.md create mode 100644 internal/workstream/limits_test.go diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index d106961b61..f53e5600f3 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -98,7 +98,7 @@ func appWorkstreamError(err error) error { return appwire.Errorf(appwire.CodeConflict, "%s", err) case errors.Is(err, workstream.ErrCycle): return appwire.Errorf(appwire.CodeCycle, "%s", err) - case errors.Is(err, workstream.ErrTooLarge): + case errors.Is(err, workstream.ErrTooLarge), errors.Is(err, workstream.ErrLimit): return appwire.Errorf(appwire.CodeTooLarge, "%s", err) case errors.Is(err, workstream.ErrNotAgentPage): return appwire.Errorf(appwire.CodeBadParams, "%s", err) diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go index 94fbf8767e..7ddd4ba7bb 100644 --- a/cmd/codeaf/appworkstreams_agent.go +++ b/cmd/codeaf/appworkstreams_agent.go @@ -298,7 +298,7 @@ func (d *workstreamAgentDoor) WritePage(ctx context.Context, ask session.Workstr 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}) + return s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: id, ParentID: ask.ParentID, Title: ask.Title, Owner: "agent", ReadByJobs: workstream.ReadByJobsDefault(ask.Title), Doc: doc, Attribution: who}) } p, err := workstreamAgentPage(ctx, s, id, ask.PageID, false) if err != nil { diff --git a/cmd/codeaf/appworkstreams_agent_test.go b/cmd/codeaf/appworkstreams_agent_test.go index 75016158a8..df93039c21 100644 --- a/cmd/codeaf/appworkstreams_agent_test.go +++ b/cmd/codeaf/appworkstreams_agent_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "encoding/json" "errors" "path/filepath" @@ -195,6 +196,258 @@ func TestWorkstreamAgentPagesAreOwnedAndMarked(t *testing.T) { } } +func TestStabEm1AgentSpecAndDecisionsPagesAreReadByJobs(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + s, err := b.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + title string + want bool + }{{"Spec", true}, {" decisions ", true}, {" sPeC ", true}, {"DECISIONS", true}, {"Notes", false}} { + t.Run(tc.title, func(t *testing.T) { + made, err := door.WritePage(ctx, session.WorkstreamPageWrite{Title: tc.title, Markdown: "First", UpdatedFrom: "Discussion"}) + if err != nil { + t.Fatal(err) + } + page, err := s.Page(ctx, made.ID, false) + if err != nil || page.ReadByJobs != tc.want { + t.Fatalf("agent default: read_by_jobs=%v, want %v: %v", page.ReadByJobs, tc.want, err) + } + wire, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: tc.title}) + if err != nil || wire.Page.ReadByJobs != tc.want { + t.Fatalf("page.create default: read_by_jobs=%v, want %v: %v", wire.Page.ReadByJobs, tc.want, err) + } + // Explicit choices survive later agent writes, even for these default titles. + for _, read := range []bool{false, true} { + if _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: page.ID, ReadByJobs: &read}); err != nil { + t.Fatal(err) + } + if _, err := door.WritePage(ctx, session.WorkstreamPageWrite{PageID: page.ID, Markdown: "Updated", UpdatedFrom: "Review"}); err != nil { + t.Fatal(err) + } + page, err = s.Page(ctx, page.ID, false) + if err != nil || page.ReadByJobs != read { + t.Fatalf("agent update: read_by_jobs=%v, want %v: %v", page.ReadByJobs, read, err) + } + } + }) + } +} + +func TestStabEM4PlanWireBoundsAreAtomic(t *testing.T) { + for _, name := range []string{"draft text", "milestones", "waves", "items", "encoded JSON"} { + t.Run(name, func(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + params := appwire.WorkstreamPlanSetParams{ID: w.ID, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{{N: 1, Items: []appwire.WorkstreamPlanItem{{Kind: "draft", DraftID: workstreamPtr("d"), Title: workstreamPtr("Draft")}}}}} + switch name { + case "draft text": + params.Waves[0].Items[0].Text = workstreamPtr(strings.Repeat("x", workstream.PlanDraftTextMax)) + case "milestones": + for range workstream.PlanMilestonesMax { + params.Milestones = append(params.Milestones, appwire.WorkstreamMilestone{Title: "Milestone"}) + } + case "waves": + params.Waves = []appwire.WorkstreamWave{} + for i := range workstream.PlanWavesMax { + params.Waves = append(params.Waves, appwire.WorkstreamWave{N: i + 1, Items: []appwire.WorkstreamPlanItem{}}) + } + case "items": + item := params.Waves[0].Items[0] + params.Waves[0].Items = []appwire.WorkstreamPlanItem{} + for range workstream.PlanItemsMax { + params.Waves[0].Items = append(params.Waves[0].Items, item) + } + } + var before appwire.WorkstreamPlanSetResult + if err := json.Unmarshal(workstreamWireEncode(t, workstreamWireResult(t, connection.call(t, "workstream.plan_set", params))), &before); err != nil { + t.Fatal(err) + } + if before.Plan.Version != 1 { + t.Fatalf("at bound: version=%d", before.Plan.Version) + } + params.BaseVersion = before.Plan.Version + switch name { + case "draft text": + params.Waves[0].Items[0].Text = workstreamPtr(*params.Waves[0].Items[0].Text + "x") + case "milestones": + params.Milestones = append(params.Milestones, appwire.WorkstreamMilestone{Title: "Extra"}) + case "waves": + params.Waves = append(params.Waves, appwire.WorkstreamWave{N: workstream.PlanWavesMax + 1, Items: []appwire.WorkstreamPlanItem{}}) + case "items": + params.Waves[0].Items = append(params.Waves[0].Items, params.Waves[0].Items[0]) + case "encoded JSON": + item := params.Waves[0].Items[0] + // JSON escapes each '<' to six bytes, while every draft stays below its text bound. + item.Text = workstreamPtr(strings.Repeat("<", workstream.PlanDraftTextMax)) + params.Waves[0].Items = []appwire.WorkstreamPlanItem{} + for range 9 { + params.Waves[0].Items = append(params.Waves[0].Items, item) + } + } + state := workstreamLimitSnapshot(t, b, w.ID) + events := len(workstreamEvents(rec)) + workstreamLimitWireRefusal(t, connection, "workstream.plan_set", params) + if name == "items" { + // The agent uses the same store authority and must receive the actual limit. + plan, err := door.Plan(ctx) + if err != nil { + t.Fatal(err) + } + agentPlan := plan + agentPlan.Waves[0].Items = append(agentPlan.Waves[0].Items, workstream.PlanItem{Kind: "draft", DraftID: "extra", Title: "Extra"}) + _, err = door.SetPlan(ctx, plan.Version, agentPlan) + if !errors.Is(err, workstream.ErrLimit) || !strings.Contains(err.Error(), "5000 items") || len(workstreamEvents(rec)) != events { + t.Fatalf("agent plan limit: %v", err) + } + } + after, err := b.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{ID: w.ID}) + if err != nil || !reflect.DeepEqual(before.Plan, after.Plan) || !reflect.DeepEqual(state, workstreamLimitSnapshot(t, b, w.ID)) || len(workstreamEvents(rec)) != events { + t.Fatalf("refused plan changed state: version=%d: %v", after.Plan.Version, err) + } + }) + } +} + +func TestStabEM4PageCountIncludesDeleted(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + // Together with Overview, these rows leave exactly one page slot. + if err := b.collections.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `WITH RECURSIVE workstream_seed(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM workstream_seed WHERE n= total { + return nil + } + _, err := tx.Exec(`WITH RECURSIVE workstream_seed(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM workstream_seed WHERE n workstream.PlanMilestonesMax { + return Errorf(CodeTooLarge, "a plan holds at most %d milestones", workstream.PlanMilestonesMax) + } + if len(p.Waves) > workstream.PlanWavesMax { + return Errorf(CodeTooLarge, "a plan holds at most %d waves", workstream.PlanWavesMax) + } + items := 0 + for _, w := range p.Waves { + items += len(w.Items) + if items > workstream.PlanItemsMax { + return Errorf(CodeTooLarge, "a plan holds at most %d items", workstream.PlanItemsMax) + } + } for _, m := range p.Milestones { if m.ID != "" { if err := workstreamID(m.ID); err != nil { @@ -308,9 +321,13 @@ func (p WorkstreamPlanSetParams) Check() error { return err } if item.Text != nil { + // Control characters stay bad_params whatever the length. if err := workstreamText(*item.Text, 0, len(*item.Text), "draft text"); err != nil { return err } + if len(*item.Text) > workstream.PlanDraftTextMax { + return Errorf(CodeTooLarge, "draft text must be at most %d bytes", workstream.PlanDraftTextMax) + } } default: return Errorf(CodeBadParams, "unknown plan item kind %q", item.Kind) diff --git a/internal/appwire/workstreams_check_test.go b/internal/appwire/workstreams_check_test.go index 2da32a2a7c..0437394f32 100644 --- a/internal/appwire/workstreams_check_test.go +++ b/internal/appwire/workstreams_check_test.go @@ -5,8 +5,74 @@ import ( "fmt" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/workstream" ) +func TestStabEM4PlanBoundsBeforeDelegation(t *testing.T) { + draftID, title := "draft", "Draft" + draft := WorkstreamPlanItem{Kind: "draft", DraftID: &draftID, Title: &title} + for _, tc := range []struct { + name string + edit func(*WorkstreamPlanSetParams) + code string + }{ + {"draft text", func(p *WorkstreamPlanSetParams) { + text := strings.Repeat("x", workstream.PlanDraftTextMax+1) + p.Waves[0].Items[0].Text = &text + }, CodeTooLarge}, + {"milestones", func(p *WorkstreamPlanSetParams) { + p.Milestones = make([]WorkstreamMilestone, workstream.PlanMilestonesMax+1) + for i := range p.Milestones { + p.Milestones[i] = WorkstreamMilestone{ID: fmt.Sprintf("m%d", i), Title: "Milestone"} + } + }, CodeTooLarge}, + {"waves", func(p *WorkstreamPlanSetParams) { + p.Waves = make([]WorkstreamWave, workstream.PlanWavesMax+1) + for i := range p.Waves { + p.Waves[i] = WorkstreamWave{N: i + 1, Items: []WorkstreamPlanItem{}} + } + }, CodeTooLarge}, + {"items", func(p *WorkstreamPlanSetParams) { + p.Waves[0].Items = make([]WorkstreamPlanItem, workstream.PlanItemsMax+1) + for i := range p.Waves[0].Items { + p.Waves[0].Items[i] = draft + } + }, CodeTooLarge}, + {"control characters", func(p *WorkstreamPlanSetParams) { + text := "before\x00after" + p.Waves[0].Items[0].Text = &text + }, CodeBadParams}, + {"control characters past the text bound", func(p *WorkstreamPlanSetParams) { + text := strings.Repeat("x", workstream.PlanDraftTextMax) + "\x00" + p.Waves[0].Items[0].Text = &text + }, CodeBadParams}, + {"bad draft id", func(p *WorkstreamPlanSetParams) { + id := " invalid " + p.Waves[0].Items[0].DraftID = &id + }, CodeBadParams}, + {"at text bound", func(p *WorkstreamPlanSetParams) { + text := strings.Repeat("x", workstream.PlanDraftTextMax) + p.Waves[0].Items[0].Text = &text + }, CodeUnknownMethod}, + } { + t.Run(tc.name, func(t *testing.T) { + params := WorkstreamPlanSetParams{ID: "w", Milestones: []WorkstreamMilestone{}, Waves: []WorkstreamWave{{N: 1, Items: []WorkstreamPlanItem{draft}}}} + tc.edit(¶ms) + raw, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + _, err = methods["workstream.plan_set"].call(fake, raw) + wire, ok := err.(*Error) + if !ok || wire.Code != tc.code { + t.Fatalf("got %v, want %s", err, tc.code) + } + }) + } +} + // B1: malformed requests are refused at the real typed dispatch door, before // the serving fake (which always answers unknown_method) can receive them. func TestWorkstreamBoundsBeforeDelegation(t *testing.T) { diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 8962ef972c..14f95fe04e 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -159,6 +159,10 @@ characters. Malformed app requests return `bad_params`. Decisions and questions can be proposed, kept, dropped and replaced. Keep can undo a drop; a replaced record cannot change again. Plans store milestones, ordered waves and job or draft items. Wave auto-start is stored without starting jobs. +A plan holds at most 500 milestones, 500 waves and 5,000 items; its stored +milestones and waves together encode to at most 4 MiB of JSON. +Plans beyond these limits return `too_large` without changing anything or +sending a change event. A go-ahead requires a member job, a planned wave or milestone, a positive cap at most $10,000 and an end in the future at most 90 days away. Expired grants @@ -183,14 +187,18 @@ 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. +Each `propose_jobs` draft's text is at most 4,000 bytes; a plan stores draft +text up to 80,000 bytes. A workstream holds at most 5,000 pages, including +deleted pages, and 5,000 records of every kind and state. `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. +attribution. New pages titled Spec or Decisions have Read by jobs on, ignoring +case and surrounding space; other new pages have it off. Updates keep the flag. +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 diff --git a/internal/workstream/limits_test.go b/internal/workstream/limits_test.go new file mode 100644 index 0000000000..05bb676d98 --- /dev/null +++ b/internal/workstream/limits_test.go @@ -0,0 +1,134 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + "time" +) + +func TestStabEM4PlanLimitsAreAtomic(t *testing.T) { + for _, name := range []string{"draft text", "milestones", "waves", "items", "encoded JSON"} { + t.Run(name, func(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Limits") + milestones := []Milestone{} + waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d", Title: "Draft"}}}} + switch name { + case "draft text": + waves[0].Items[0].Text = strings.Repeat("x", PlanDraftTextMax) + case "milestones": + for i := range PlanMilestonesMax { + milestones = append(milestones, Milestone{ID: fmt.Sprintf("m%d", i), Title: "Milestone"}) + } + case "waves": + waves = []Wave{} + for i := range PlanWavesMax { + waves = append(waves, Wave{N: i + 1, Items: []PlanItem{}}) + } + case "items": + waves[0].Items = []PlanItem{} + for i := range PlanItemsMax { + waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: fmt.Sprintf("d%d", i), Title: "Draft"}) + } + case "encoded JSON": + waves[0].Items = []PlanItem{} + for i := range 53 { + waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: fmt.Sprintf("d%d", i), Title: "Draft", Text: strings.Repeat("x", PlanDraftTextMax)}) + } + last := &waves[0].Items[52] + last.Text = "x" + m, err := json.Marshal(milestones) + if err != nil { + t.Fatal(err) + } + v, err := json.Marshal(waves) + if err != nil { + t.Fatal(err) + } + last.Text = strings.Repeat("x", 1+PlanEncodedMax-len(m)-len(v)) + if len(last.Text) > PlanDraftTextMax { + t.Fatal("encoded fixture exceeds the draft text bound") + } + } + before, c, err := s.SetPlan(ctx, w.ID, 0, milestones, waves, Attribution{}) + if err != nil || before.Version != 1 || len(c.Parts) == 0 || !reflect.DeepEqual(before.Milestones, milestones) || !reflect.DeepEqual(before.Waves, waves) { + t.Fatalf("at bound: version=%d change=%+v: %v", before.Version, c, err) + } + row, err := s.Get(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + switch name { + case "draft text": + waves[0].Items[0].Text += "x" + case "milestones": + milestones = append(milestones, Milestone{ID: "extra", Title: "Extra"}) + case "waves": + waves = append(waves, Wave{N: PlanWavesMax + 1, Items: []PlanItem{}}) + case "items": + waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: "extra", Title: "Extra"}) + case "encoded JSON": + waves[0].Items[52].Text += "x" + } + *now = now.Add(time.Minute) + _, c, err = s.SetPlan(ctx, w.ID, before.Version, milestones, waves, Attribution{}) + if !errors.Is(err, ErrLimit) || len(c.Parts) != 0 || !strings.Contains(err.Error(), "at most") { + t.Fatalf("over bound: change=%+v: %v", c, err) + } + after, err := s.Plan(ctx, w.ID) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("refused plan changed: version=%d: %v", after.Version, err) + } + afterRow, err := s.Get(ctx, w.ID) + if err != nil || !reflect.DeepEqual(row, afterRow) { + t.Fatalf("refused plan touched the workstream: %v", err) + } + }) + } +} + +func TestStabEM4DraftControlCharactersRemainInvalid(t *testing.T) { + s, _, _ := testStore(t) + w := createStream(t, s, "Control characters") + for _, text := range []string{"bad\x00text", strings.Repeat("x", PlanDraftTextMax) + "\x00"} { + _, c, err := s.SetPlan(context.Background(), w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d", Title: "Draft", Text: text}}}}, Attribution{}) + if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { + t.Fatalf("control character in %d bytes: change=%+v: %v", len(text), c, err) + } + } +} + +func TestStabEM4RestoreChecksSavedDocumentSize(t *testing.T) { + s, db, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Restore") + pages, err := s.Pages(ctx, w.ID) + if err != nil || len(pages) != 1 { + t.Fatalf("Overview: %v", err) + } + before, err := s.Page(ctx, pages[0].ID, false) + if err != nil { + t.Fatal(err) + } + if err := db.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `UPDATE workstream_page_versions SET doc=? WHERE page_id=? AND version=1`, `{"text":"`+strings.Repeat("x", DocMax)+`"}`, before.ID) + return err + }); err != nil { + t.Fatal(err) + } + _, c, err := s.RestorePage(ctx, before.ID, 1, before.Version, Attribution{}) + if !errors.Is(err, ErrTooLarge) || len(c.Parts) != 0 { + t.Fatalf("restore oversized saved document: change=%+v: %v", c, err) + } + after, err := s.Page(ctx, before.ID, false) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("refused restore changed the page: %v", err) + } +} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go index 4611a0c599..c3171dd3b7 100644 --- a/internal/workstream/pages.go +++ b/internal/workstream/pages.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "strings" "unicode/utf8" @@ -408,6 +409,13 @@ func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, err if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, p.By); err != nil { return err } + var count int + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_pages WHERE workstream_id=?", p.WorkstreamID).Scan(&count); err != nil { + return err + } + if count >= PagesMax { + return fmt.Errorf("%w: a workstream holds at most %d pages, including deleted pages", ErrLimit, PagesMax) + } id, err := s.newID() if err != nil { return err diff --git a/internal/workstream/plan.go b/internal/workstream/plan.go index d0f97be7ab..8dd7142ddd 100644 --- a/internal/workstream/plan.go +++ b/internal/workstream/plan.go @@ -68,6 +68,19 @@ func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []M if !w.DoneAt.IsZero() { return ErrConflict } + if len(milestones) > PlanMilestonesMax { + return fmt.Errorf("%w: a plan holds at most %d milestones", ErrLimit, PlanMilestonesMax) + } + if len(waves) > PlanWavesMax { + return fmt.Errorf("%w: a plan holds at most %d waves", ErrLimit, PlanWavesMax) + } + items := 0 + for _, wave := range waves { + items += len(wave.Items) + if items > PlanItemsMax { + return fmt.Errorf("%w: a plan holds at most %d items", ErrLimit, PlanItemsMax) + } + } p, err := planIn(ctx, tx, id) if err != nil { return err @@ -128,9 +141,13 @@ func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []M if err := validTitle(item.Title); err != nil { return err } + // Control characters stay invalid whatever the length. if !textWithin(item.Text, len(item.Text)) { return invalid("draft text allows only newline and tab control characters") } + if len(item.Text) > PlanDraftTextMax { + return fmt.Errorf("%w: draft text must be at most %d bytes", ErrLimit, PlanDraftTextMax) + } default: return invalid("unknown plan item kind") } @@ -144,6 +161,9 @@ func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []M if err != nil { return err } + if len(m)+len(v) > PlanEncodedMax { + return fmt.Errorf("%w: a plan's milestones and waves must encode to at most %d bytes", ErrLimit, PlanEncodedMax) + } at := s.now().UnixMilli() if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES (?,?,?,?,?,?) ON CONFLICT(workstream_id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,updated_by=excluded.updated_by,milestones=excluded.milestones,waves=excluded.waves`, id, p.Version+1, at, by, string(m), string(v)); err != nil { diff --git a/internal/workstream/records.go b/internal/workstream/records.go index a2abfdf2ee..05de613c91 100644 --- a/internal/workstream/records.go +++ b/internal/workstream/records.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "strings" "time" ) @@ -38,6 +39,18 @@ func recordText(kind, text string) error { } return nil } + +func workstreamRecordLimitIn(ctx context.Context, tx *sql.Tx, id string) error { + var count int + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_records WHERE workstream_id=?", id).Scan(&count); err != nil { + return err + } + if count >= RecordsMax { + return fmt.Errorf("%w: a workstream holds at most %d records", ErrLimit, RecordsMax) + } + return nil +} + func (s *Store) Records(ctx context.Context, id, kind, state string, includeGoAheads ...bool) ([]Record, error) { if kind != "" && kind != "decision" && kind != "question" && kind != "goahead" { return nil, invalid("unknown record kind") @@ -136,6 +149,9 @@ func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Cha } proposal = string(encoded) } + if err := workstreamRecordLimitIn(ctx, tx, p.WorkstreamID); err != nil { + return err + } id, err := s.newID() if err != nil { return err @@ -240,6 +256,9 @@ func (s *Store) SupersedeRecord(ctx context.Context, id string, p RecordReplacem if err := recordText(kind, p.Text); err != nil { return err } + if err := workstreamRecordLimitIn(ctx, tx, r.WorkstreamID); err != nil { + return err + } replacement, err := s.newID() if err != nil { return err diff --git a/internal/workstream/workstream.go b/internal/workstream/workstream.go index e6d426ee6a..af0e56b535 100644 --- a/internal/workstream/workstream.go +++ b/internal/workstream/workstream.go @@ -11,8 +11,15 @@ import ( ) const ( - DocMax = 1 << 20 - emptyDoc = `{"type":"doc","content":[]}` + DocMax = 1 << 20 + PlanDraftTextMax = 80_000 + PlanMilestonesMax = 500 + PlanWavesMax = 500 + PlanItemsMax = 5_000 + PlanEncodedMax = 4 << 20 + PagesMax = 5_000 + RecordsMax = 5_000 + emptyDoc = `{"type":"doc","content":[]}` ) var ( @@ -21,6 +28,7 @@ var ( ErrConflict = errors.New("workstream conflict") ErrCycle = errors.New("page move would form a cycle") ErrTooLarge = errors.New("encoded document exceeds 1 MiB") + ErrLimit = errors.New("workstream limit exceeded") ) type ChangedPart string From f5bf32eadcc500ac1170a61c2c4e2e178359794f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 09:39:52 -0500 Subject: [PATCH 12/20] workstreams: a job's spend counts its earlier runs Since app/v3's "A job keeps its earlier spending when it runs again", a job's Cost is only its current run and LifetimeCost holds every run. The workstream's spend summed Cost, so it fell when a member job ran again. It now takes the larger of the two, which is LifetimeCost from a current factory and Cost from an older one that does not send it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 6 +++++- cmd/codeaf/appworkstreams_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index f53e5600f3..9a344d8830 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -851,7 +851,11 @@ func (b *appBackend) workstreamFacts(jobs []workstream.Job) map[workstream.Job]w } job := read.job merged := job.PR != nil && job.PR.MergedAt != nil && !job.PR.MergedAt.IsZero() - out[j] = workstream.JobFacts{State: job.State, Merged: &merged, CostUSD: &job.Cost} + // A job's money is all of its runs: Cost is only the current run once + // it has run again, LifetimeCost keeps the earlier ones, and an older + // factory that does not send LifetimeCost leaves Cost as the whole. + cost := max(job.Cost, job.LifetimeCost) + out[j] = workstream.JobFacts{State: job.State, Merged: &merged, CostUSD: &cost} } return out } diff --git a/cmd/codeaf/appworkstreams_test.go b/cmd/codeaf/appworkstreams_test.go index 2e8baf8a64..2d79b30852 100644 --- a/cmd/codeaf/appworkstreams_test.go +++ b/cmd/codeaf/appworkstreams_test.go @@ -1309,3 +1309,30 @@ func TestAppWorkstreamDoneArchiveFailureStillAnnouncesCommit(t *testing.T) { t.Fatal("archive retry duplicated workstream event") } } + +// A job that ran again keeps its earlier runs' money in the workstream's +// spend, through the queue read and the missing-job read alike; an older +// factory that sends no lifetime cost still counts the cost it does send. +func TestAppWorkstreamSpendKeepsAJobsEarlierRuns(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{Jobs: []api.Job{ + {ID: 7, Num: 1107, State: "running", Cost: 0, LifetimeCost: 12}, + {ID: 8, Num: 1108, State: "backlog", Cost: 1.25}, + }}, nil + } + b.workstreams.job = func(_ context.Context, _ string, job int) (api.Job, error) { + return api.Job{ID: job, Num: 1109, State: "running", Cost: 0.5, LifetimeCost: 3}, nil + } + b.workstreams.spend = func(string) *float64 { return workstreamPtr(2) } + w := workstreamCreateForTest(t, b, "Reruns", workstreamJobRef("7"), workstreamJobRef("8"), workstreamJobRef("9")) + const want = 12 + 1.25 + 3 + 2 + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != want { + t.Fatalf("get spend %+v, %v; want %v", got.Workstream.SpendUSD, err, want) + } + rows, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil || len(rows.Workstreams) != 1 || rows.Workstreams[0].SpendUSD == nil || *rows.Workstreams[0].SpendUSD != want { + t.Fatalf("list spend %+v, %v; want %v", rows, err, want) + } +} From 14c3f0a10b6e35ab6bfad68284253d37b8ebedcc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 09:49:03 -0500 Subject: [PATCH 13/20] workstreams: the earlier-runs spend test names its agent spend as a float Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appworkstreams_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/codeaf/appworkstreams_test.go b/cmd/codeaf/appworkstreams_test.go index 2d79b30852..b612547048 100644 --- a/cmd/codeaf/appworkstreams_test.go +++ b/cmd/codeaf/appworkstreams_test.go @@ -1324,7 +1324,7 @@ func TestAppWorkstreamSpendKeepsAJobsEarlierRuns(t *testing.T) { b.workstreams.job = func(_ context.Context, _ string, job int) (api.Job, error) { return api.Job{ID: job, Num: 1109, State: "running", Cost: 0.5, LifetimeCost: 3}, nil } - b.workstreams.spend = func(string) *float64 { return workstreamPtr(2) } + b.workstreams.spend = func(string) *float64 { return workstreamPtr(2.0) } w := workstreamCreateForTest(t, b, "Reruns", workstreamJobRef("7"), workstreamJobRef("8"), workstreamJobRef("9")) const want = 12 + 1.25 + 3 + 2 got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) From 27a989e152a3f8224623851b67db88e9cc853908 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 11:03:36 -0500 Subject: [PATCH 14/20] workstreams: the watching agent (morning and job-event wakes, undoable small changes, wake.* methods) (#2319) * workstreams: schema 4 adds wake tables by an additive migration Co-Authored-By: Claude Opus 5.5 * workstreams: wake records, settle under autonomy, apply/undo/not now Co-Authored-By: Claude Opus 5.5 * app: wire wake.list, wake.apply, wake.undo, wake.not_now and the one-shot wake turn Co-Authored-By: Claude Opus 5.5 * app: the workstream agent wakes each morning and on member job events; wake costs count toward spend Co-Authored-By: Claude Opus 5.5 * docs: the watching agent's change entry names its pull request Co-Authored-By: Claude Opus 5.5 * workstreams: undo refuses drafts and pages changed since; morning wakes only for today's slot; wake calls carry their purpose Co-Authored-By: Claude Opus 5.5 * docs: the protocol document lists the wake methods Co-Authored-By: Claude Opus 5.5 * workstreams: wake undo keeps a later rename and tolerates sibling reorder; replay knows the wake's role Co-Authored-By: Claude Opus 5.5 * tests: wake.list fixture follows the contract's money form Co-Authored-By: Claude Opus 5.5 * workstreams: the wake call has a ledger owner; the wake queue unlocks from a defer Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf-replay/request.go | 3 + .../workstream_wake_fix2_test.go | 13 + cmd/codeaf/app.go | 1 + cmd/codeaf/appworkstreams.go | 26 + cmd/codeaf/appworkstreams_conformance_test.go | 51 +- cmd/codeaf/appworkstreams_wake.go | 369 +++++++++++++ cmd/codeaf/appworkstreams_wake_test.go | 452 ++++++++++++++++ cmd/codeaf/testdata/model_spend_owners.tsv | 1 + .../testdata/workstreams-wire/contract.json | 220 +++++++- .../testdata/workstreams-wire/wake.apply.json | 50 ++ .../testdata/workstreams-wire/wake.list.json | 67 +++ .../workstreams-wire/wake.not_now.json | 46 ++ .../testdata/workstreams-wire/wake.undo.json | 51 ++ .../workstreams-wire/workstream.changed.json | 3 +- .../2319-workstreams-watching-agent.md | 23 + docs/design/app-v3/PROTOCOL.md | 9 + internal/appwire/workstreams.go | 81 ++- internal/appwire/workstreams_check.go | 12 + internal/appwire/workstreams_test.go | 36 +- internal/manual/chat/collections.md | 37 ++ internal/session/clientdoor.go | 4 +- internal/session/workstream_wake.go | 89 ++++ internal/session/workstream_wake_test.go | 87 +++ internal/workspace/schema3_test.go | 8 +- internal/workspace/schema4_test.go | 110 ++++ internal/workspace/store.go | 34 +- internal/workspace/workstreams.go | 29 + .../workspace/workstreams_fields_v02_test.go | 1 + internal/workspace/workstreams_test.go | 11 +- internal/workstream/pages.go | 98 ++-- internal/workstream/wake_actions.go | 454 ++++++++++++++++ internal/workstream/wake_answer.go | 132 +++++ internal/workstream/wake_answer_test.go | 44 ++ internal/workstream/wakes.go | 500 ++++++++++++++++++ internal/workstream/wakes_test.go | 434 +++++++++++++++ .../workstream/workstream_wake_fix1_test.go | 320 +++++++++++ .../workstream/workstream_wake_fix2_test.go | 185 +++++++ 37 files changed, 4024 insertions(+), 67 deletions(-) create mode 100644 cmd/codeaf-replay/workstream_wake_fix2_test.go create mode 100644 cmd/codeaf/appworkstreams_wake.go create mode 100644 cmd/codeaf/appworkstreams_wake_test.go create mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.apply.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.list.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.not_now.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.undo.json create mode 100644 docs/changes/unreleased/2319-workstreams-watching-agent.md create mode 100644 internal/session/workstream_wake.go create mode 100644 internal/session/workstream_wake_test.go create mode 100644 internal/workspace/schema4_test.go create mode 100644 internal/workstream/wake_actions.go create mode 100644 internal/workstream/wake_answer.go create mode 100644 internal/workstream/wake_answer_test.go create mode 100644 internal/workstream/wakes.go create mode 100644 internal/workstream/wakes_test.go create mode 100644 internal/workstream/workstream_wake_fix1_test.go create mode 100644 internal/workstream/workstream_wake_fix2_test.go diff --git a/cmd/codeaf-replay/request.go b/cmd/codeaf-replay/request.go index 1dd449ccb1..f6dc4866b9 100644 --- a/cmd/codeaf-replay/request.go +++ b/cmd/codeaf-replay/request.go @@ -299,6 +299,9 @@ var callSiteRoles = map[string]lane.Role{ // it sets lane.RoleStanding on its own context. The older tag stays so a // log written before the change still reads. "automation-check": lane.RoleStanding, + // internal/session/workstream_wake.go: one workstream's event turn sets + // lane.RoleStanding on its context; nobody is waiting on its stream. + "workstream-wake": lane.RoleStanding, // internal/session/tools_doc.go: the model's own eyes on a document the // `read` tool cannot open as text. A TOOL CALL INSIDE A TURN, so somebody IS // waiting — the one of the three whose seconds are a person's. diff --git a/cmd/codeaf-replay/workstream_wake_fix2_test.go b/cmd/codeaf-replay/workstream_wake_fix2_test.go new file mode 100644 index 0000000000..962a818460 --- /dev/null +++ b/cmd/codeaf-replay/workstream_wake_fix2_test.go @@ -0,0 +1,13 @@ +package main + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/lane" +) + +func TestR2_M1_WorkstreamWakeResolvesToStandingRole(t *testing.T) { + if got := roleOf("workstream-wake"); got != lane.RoleStanding { + t.Fatalf("workstream-wake resolved to %q, want %q", got, lane.RoleStanding) + } +} diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 8cfc7180f2..04b1f89950 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -214,6 +214,7 @@ func runApp(args []string) error { defer keepAutomationsWindow("app")() } guard.Go("app/orders", func() { backend.watchOrders(backend.sweepStop) }) + guard.Go("app/workstream-wakes", func() { backend.watchWorkstreamWakes(backend.sweepStop) }) // And what the last quit stopped in the middle of a reply carries on. guard.Go("app/resume-after-quit", backend.resumeAfterQuit) backend.startCloseWatch() diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 9a344d8830..017a67e542 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -46,6 +46,12 @@ type appWorkstreams struct { flights map[string]chan struct{} briefMu sync.Mutex briefs map[string]session.WorkstreamBrief + wakeTurn func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) + wakeDispatch func(workstreamWakeTask) + wakeMu sync.Mutex + wakeQueue []workstreamWakeTask + wakeSignal chan struct{} + wakeCancel context.CancelFunc } type workstreamQueueRead struct { @@ -945,6 +951,26 @@ func (b *appBackend) workstreamObserved(s *workstream.Store, w workstream.Workst } func (b *appBackend) workstreamSpend(id string) *float64 { + spend := b.workstreamAgentSpend(id) + if spend == nil { + return nil + } + s, err := b.workstreamStore(false) + if err != nil { + return nil + } + if s == nil { + return spend + } + wakes, err := s.WakeSpendByAgent(context.Background(), id) + if err != nil { + return nil + } + total := *spend + wakes + return &total +} + +func (b *appBackend) workstreamAgentSpend(id string) *float64 { if id == "" { return nil } diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go index 37a63e875e..6d28c07f41 100644 --- a/cmd/codeaf/appworkstreams_conformance_test.go +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -21,6 +21,7 @@ import ( "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/workspace" "github.com/Agent-Field/codeaf/internal/workstream" ) @@ -565,6 +566,8 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir } fixture.IDs["pg_31d0"] = pages.Pages[0].ID switch name { + case "wake.list", "wake.apply", "wake.undo", "wake.not_now": + fixture.wake(t, name) case "workstream.reopen": if _, err := backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: made.Workstream.ID}); err != nil { t.Fatal(err) @@ -832,7 +835,7 @@ func TestAppWorkstreamWireConformance(t *testing.T) { "page.list", "page.get", "page.save", "page.create", "page.update", "page.move", "page.delete", "page.history", "page.restore", "record.list", "record.propose", "record.keep", "record.drop", "record.supersede", "goahead.list", "goahead.grant", - "goahead.revoke", "collections.add", "workstream.changed", + "goahead.revoke", "wake.list", "wake.apply", "wake.undo", "wake.not_now", "collections.add", "workstream.changed", } errorCases := []workstreamWireErrorCase{ {Name: "bad_params", Method: "workstream.get", Code: "bad_params"}, @@ -1428,3 +1431,49 @@ func workstreamWireHoldWriter(t *testing.T, backend *appBackend) func() { } return release } + +// WP-11 (j): every wake example uses a fake turn and the real durable action path. +func (fixture *workstreamWireFixture) wake(t *testing.T, name string) { + t.Helper() + b := fixture.Backend + s, err := b.workstreamStore(false) + if err != nil || s == nil { + t.Fatal(err) + } + if name == "wake.list" { + small := "small" + if _, _, err := s.Update(context.Background(), fixture.Stream.ID, workstream.Patch{Autonomy: &small}); err != nil { + t.Fatal(err) + } + _, _, err = s.SetPlan(context.Background(), fixture.Stream.ID, 0, nil, []workstream.Wave{{N: 1, Items: []workstream.PlanItem{{Kind: "job", Factory: "f_local", Job: 1107}}}, {N: 3, Items: []workstream.PlanItem{}}}, workstream.Attribution{}) + if err != nil { + t.Fatal(err) + } + } + b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { + text := `{"summary":"Review the rollout.","items":[{"kind":"decision","text":"Roll out after review."}]}` + if name == "wake.list" { + text = `{"summary":"Applied: moved it to wave 3; updated Where it stands. Keep the rollout decision and consider a small follow-up job.","items":[{"kind":"move","factory":"f_local","job":1107,"to_wave":3},{"kind":"decision","text":"Roll out after review.","why":"The merge is ready for review."},{"kind":"draft","title":"Check rollout","budget_usd":3,"wave":99}]}` + } + return session.WorkstreamWakeAnswer{Text: text, CostUSD: workstreamPtr(.04), Model: "fake"}, nil + } + var task workstreamWakeTask + b.workstreams.wakeDispatch = func(v workstreamWakeTask) { task = v } + id, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f_local", Job: 1107, Kind: "merged", ID: "wire-contract", At: fixture.Now, Label: "api#1102 merged"}) + if !woke { + t.Fatal("seed wake was refused") + } + b.workstreamRunWake(context.Background(), task) + fixture.IDs["wake_11"] = id + runs, err := s.Wakes(context.Background(), fixture.Stream.ID, 20) + if err != nil || len(runs) != 1 || len(runs[0].Items) == 0 { + t.Fatalf("seed wake: %+v %v", runs, err) + } + item := runs[0].Items[0] + fixture.IDs["wi_decision"] = item.ID + if name == "wake.undo" { + if _, _, err := s.ActOnWake(context.Background(), id, item.ID, "apply"); err != nil { + t.Fatal(err) + } + } +} diff --git a/cmd/codeaf/appworkstreams_wake.go b/cmd/codeaf/appworkstreams_wake.go new file mode 100644 index 0000000000..4a1fd48931 --- /dev/null +++ b/cmd/codeaf/appworkstreams_wake.go @@ -0,0 +1,369 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/automation" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +const workstreamWakeTimeout = 3 * time.Minute + +// One active call across backends also bounds tests and embedded app instances. +var workstreamWakeTurnMu sync.Mutex + +type workstreamJobEvent struct { + Factory string + Job int + Kind string + ID string + At time.Time + Label string + Detail string +} + +type workstreamWakeTask struct{ Run workstream.WakeRun } + +// WorkstreamJobEvent is the admission hook for the future job event source. +// Queue reads do not call it: each occurrence must have a stable source identity. +func (b *appBackend) WorkstreamJobEvent(ev workstreamJobEvent) (wakeID string, woke bool) { + if ev.At.IsZero() || ev.Factory == "" || ev.Job <= 0 || ev.Kind == "morning" { + return "", false + } + occurrence := ev.ID + if occurrence == "" { + occurrence = ev.At.UTC().Format(time.RFC3339) + } + key := fmt.Sprintf("job:%s:%d:%s:%s", ev.Factory, ev.Job, ev.Kind, occurrence) + event := workstream.WakeEvent{Kind: ev.Kind, Factory: ev.Factory, Job: ev.Job, At: ev.At, Label: ev.Label, Detail: ev.Detail} + return b.workstreamAdmitWake("", key, event) +} + +func (b *appBackend) workstreamAdmitWake(id, key string, event workstream.WakeEvent) (string, bool) { + s, err := b.workstreamStore(false) + if err != nil || s == nil { + return "", false + } + run, woke, err := s.BeginWake(context.Background(), id, key, event) + if err != nil { + log.Printf("workstream wake: %v", err) + return "", false + } + if !woke { + return "", false + } + b.workstreamEnqueueWake(workstreamWakeTask{Run: run}) + return run.ID, true +} + +func (b *appBackend) workstreamEnqueueWake(task workstreamWakeTask) { + state := &b.workstreams + if state.wakeDispatch != nil { + state.wakeDispatch(task) + return + } + state.wakeMu.Lock() + defer state.wakeMu.Unlock() + if state.wakeSignal == nil { + state.wakeSignal = make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) + state.wakeCancel = cancel + guard.Go("app/workstream-wake-worker", func() { b.workstreamWakeWorker(ctx) }) + } + state.wakeQueue = append(state.wakeQueue, task) + select { + case state.wakeSignal <- struct{}{}: + default: + } +} + +func (b *appBackend) workstreamWakeWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-b.workstreams.wakeSignal: + } + for { + if ctx.Err() != nil { + return + } + task, ok := b.workstreamNextWake() + if !ok { + break + } + b.workstreamRunWake(ctx, task) + } + } +} + +func (b *appBackend) watchWorkstreamWakes(stop <-chan struct{}) { + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + defer func() { + b.workstreams.wakeMu.Lock() + defer b.workstreams.wakeMu.Unlock() + if b.workstreams.wakeCancel != nil { + b.workstreams.wakeCancel() + } + }() + for { + select { + case <-stop: + return + case <-timer.C: + } + b.workstreamWakeTick(b.now()) + timer.Reset(time.Minute) + } +} + +func (b *appBackend) workstreamExpireWakes(s *workstream.Store) error { + changes, err := s.ExpireWakes(context.Background()) + if err == nil { + for _, change := range changes { + b.workstreamChanged(change) + } + } + return err +} + +// workstreamMorningSlot uses the automation clock's local-zone cron semantics. +func workstreamMorningSlot(now time.Time) (time.Time, error) { + local := now.In(time.Local) + midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local) + schedule := automation.Schedule{Every: "0 8 * * *"} + slot, err := schedule.Next(midnight.Add(-time.Minute)) + if err != nil || slot.After(now) { + return time.Time{}, err + } + return slot, nil +} +func (b *appBackend) workstreamWakeTick(now time.Time) { + s, err := b.workstreamStore(false) + if err != nil || s == nil { + return + } + if err := b.workstreamExpireWakes(s); err != nil { + log.Printf("workstream wake: %v", err) + return + } + slot, err := workstreamMorningSlot(now) + if err != nil || slot.IsZero() { + return + } + streams, err := s.List(context.Background()) + if err != nil { + return + } + for _, w := range streams { + if w.Stage == "done" || w.Autonomy == "off" || !w.Wake.Morning || slot.Before(w.CreatedAt) { + continue + } + b.workstreamAdmitWake(w.ID, "morning:"+slot.In(time.Local).Format("2006-01-02"), workstream.WakeEvent{Kind: "morning", At: slot}) + } +} + +// workstreamNextWake takes the oldest queued wake, if any. +func (b *appBackend) workstreamNextWake() (workstreamWakeTask, bool) { + b.workstreams.wakeMu.Lock() + defer b.workstreams.wakeMu.Unlock() + if len(b.workstreams.wakeQueue) == 0 { + return workstreamWakeTask{}, false + } + task := b.workstreams.wakeQueue[0] + b.workstreams.wakeQueue = b.workstreams.wakeQueue[1:] + return task, true +} + +func (b *appBackend) workstreamRunWake(parent context.Context, task workstreamWakeTask) { + workstreamWakeTurnMu.Lock() + defer workstreamWakeTurnMu.Unlock() + ctx, cancel := context.WithTimeout(parent, workstreamWakeTimeout) + defer cancel() + s, err := b.workstreamStore(false) + if err != nil || s == nil { + return + } + run, err := s.WakeRun(ctx, task.Run.ID) + if err != nil || run.State != "running" { + return + } + if b.now().Sub(run.StartedAt) > workstream.WakeAbandonedAfter { + _ = b.workstreamExpireWakes(s) + return + } + task.Run = run + w, err := s.Get(ctx, task.Run.WorkstreamID) + if err != nil { + return + } + // A queued turn must not call the model after done or off was chosen. + if w.Stage == "done" || w.Autonomy == "off" { + _, change, err := s.SettleWake(context.Background(), task.Run.ID, workstream.WakeAnswer{}, nil, "") + if err == nil { + b.workstreamChanged(change) + } + return + } + ask, err := b.workstreamWakeSnapshot(ctx, s, w, task.Run) + var answer session.WorkstreamWakeAnswer + if err == nil { + turn := b.workstreams.wakeTurn + if turn == nil { + turn = b.workstreamRealWakeTurn + } + answer, err = turn(ctx, ask) + } + parsed := workstream.WakeAnswer{} + failure := "" + if err != nil { + failure = "The agent could not finish this wake." + } else { + parsed, err = workstream.ParseWakeAnswer(answer.Text) + if err != nil { + failure = workstream.WakeAnswerError + } + } + _, change, err := s.SettleWake(context.Background(), task.Run.ID, parsed, answer.CostUSD, failure) + if err != nil { + // A refused small change rolls back the entire batch; its call cost is still owed. + _, change, err = s.SettleWake(context.Background(), task.Run.ID, workstream.WakeAnswer{}, answer.CostUSD, "The agent's changes could not be saved.") + } + if err == nil { + b.workstreamChanged(change) + } else { + log.Printf("workstream wake settle: %v", err) + } +} + +func (b *appBackend) workstreamWakeSnapshot(ctx context.Context, s *workstream.Store, w workstream.Workstream, run workstream.WakeRun) (session.WorkstreamWakeAsk, error) { + door := &workstreamAgentDoor{backend: b, agentID: w.AgentID} + snapshot := map[string]json.RawMessage{} + for _, what := range []string{"workstream", "plan", "pages"} { + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: what}) + if err != nil { + return session.WorkstreamWakeAsk{}, err + } + snapshot[what] = raw + } + records := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} + for _, state := range []string{"kept", "proposed"} { + rows, err := s.Records(ctx, w.ID, "", state, true) + if err != nil { + return session.WorkstreamWakeAsk{}, err + } + for _, r := range rows { + records.Records = append(records.Records, appWorkstreamRecord(r)) + } + } + rawRecords, err := json.Marshal(records) + if err != nil { + return session.WorkstreamWakeAsk{}, err + } + snapshot["records"] = rawRecords + grants, err := s.GoAheads(ctx, w.ID, false) + if err != nil { + return session.WorkstreamWakeAsk{}, err + } + goaheads := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} + for _, g := range grants { + goaheads.GoAheads = append(goaheads.GoAheads, appWorkstreamGoAhead(g)) + } + rawGrants, err := json.Marshal(goaheads) + if err != nil { + return session.WorkstreamWakeAsk{}, err + } + snapshot["goaheads"] = rawGrants + pages, err := s.Pages(ctx, w.ID) + if err != nil { + return session.WorkstreamWakeAsk{}, err + } + for _, p := range pages { + if p.Owner != "agent" || p.Title != session.WorkstreamWhereItStandsTitle { + continue + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: p.ID}) + if err != nil { + return session.WorkstreamWakeAsk{}, err + } + snapshot["where_it_stands"] = raw + break + } + // The existing read mappers preserve numbers, plan draft ids and page ownership. + raw, err := json.Marshal(snapshot) + return session.WorkstreamWakeAsk{ID: run.ID, AgentID: w.AgentID, Event: run.Event, Snapshot: raw}, err +} + +func (b *appBackend) workstreamRealWakeTurn(ctx context.Context, ask session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { + source, err := v3RolesSource("", b.crewProfile()) + if err != nil { + return session.WorkstreamWakeAnswer{}, err + } + proc, err := openAppEngineProcess() + if err != nil { + return session.WorkstreamWakeAnswer{}, err + } + settings := proc.Settings + return session.RunWorkstreamWake(ctx, session.TeamProposerConfig{Model: settings.Model, APIKey: settings.APIKey, BaseURL: settings.BaseURL, Sources: settings.Sources, ProfileDir: settings.ProfileDir, RolesSource: source}, ask) +} + +func appWorkstreamWake(run workstream.WakeRun) appwire.WorkstreamWakeRun { + out := appwire.WorkstreamWakeRun{ID: run.ID, WorkstreamID: run.WorkstreamID, Event: appwire.WorkstreamWakeEvent{Kind: run.Event.Kind, Factory: workstreamOptional(run.Event.Factory), Job: workstreamPositive(run.Event.Job), Label: workstreamOptional(run.Event.Label)}, State: run.State, StartedAt: run.StartedAt, SettledAt: run.SettledAt, CostUSD: run.CostUSD, Summary: workstreamOptional(run.Summary), Error: workstreamOptional(run.Error), DismissedAt: run.DismissedAt, Items: []appwire.WorkstreamWakeItem{}} + for _, item := range run.Items { + out.Items = append(out.Items, appwire.WorkstreamWakeItem{ID: item.ID, Kind: item.Kind, State: item.State, Text: item.Text, Why: workstreamOptional(item.Why), Factory: workstreamOptional(item.Factory), Job: workstreamPositive(item.Job), DraftID: workstreamOptional(item.DraftID), Wave: workstreamPositive(item.Wave), PageID: workstreamOptional(item.PageID), RecordID: workstreamOptional(item.RecordID), BudgetUSD: item.BudgetUSD, Note: workstreamOptional(item.Note), AppliedAt: item.AppliedAt, UndoneAt: item.UndoneAt}) + } + return out +} + +func (b *appBackend) WorkstreamWakeList(p appwire.WorkstreamWakeListParams) (appwire.WorkstreamWakeListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeListResult, workstream.Change, error) { + out := appwire.WorkstreamWakeListResult{Wakes: []appwire.WorkstreamWakeRun{}} + if err := b.workstreamExpireWakes(s); err != nil { + return out, workstream.Change{}, err + } + limit := 20 + if p.Limit != nil { + limit = *p.Limit + } + wakes, err := s.Wakes(context.Background(), p.WorkstreamID, limit) + for _, run := range wakes { + out.Wakes = append(out.Wakes, appWorkstreamWake(run)) + } + return out, workstream.Change{}, err + }) +} +func (b *appBackend) WorkstreamWakeApply(p appwire.WorkstreamWakeApplyParams) (appwire.WorkstreamWakeApplyResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeApplyResult, workstream.Change, error) { + item := "" + if p.ItemID != nil { + item = *p.ItemID + } + run, c, err := s.ActOnWake(context.Background(), p.ID, item, "apply") + return appwire.WorkstreamWakeApplyResult{Wake: appWorkstreamWake(run)}, c, err + }) +} +func (b *appBackend) WorkstreamWakeUndo(p appwire.WorkstreamWakeUndoParams) (appwire.WorkstreamWakeUndoResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeUndoResult, workstream.Change, error) { + item := "" + if p.ItemID != nil { + item = *p.ItemID + } + run, c, err := s.ActOnWake(context.Background(), p.ID, item, "undo") + return appwire.WorkstreamWakeUndoResult{Wake: appWorkstreamWake(run)}, c, err + }) +} +func (b *appBackend) WorkstreamWakeNotNow(p appwire.WorkstreamWakeNotNowParams) (appwire.WorkstreamWakeNotNowResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeNotNowResult, workstream.Change, error) { + run, c, err := s.ActOnWake(context.Background(), p.ID, "", "not_now") + return appwire.WorkstreamWakeNotNowResult{Wake: appWorkstreamWake(run)}, c, err + }) +} diff --git a/cmd/codeaf/appworkstreams_wake_test.go b/cmd/codeaf/appworkstreams_wake_test.go new file mode 100644 index 0000000000..d76646a831 --- /dev/null +++ b/cmd/codeaf/appworkstreams_wake_test.go @@ -0,0 +1,452 @@ +package main + +import ( + "context" + "encoding/json" + "reflect" + "slices" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func workstreamWakeBackend(t *testing.T) (*appBackend, *appRecorder, *time.Time, *[]workstreamWakeTask, *int) { + t.Helper() + b, rec, _, _ := workstreamBackend(t) + now := time.Date(2026, 11, 3, 7, 59, 0, 0, time.Local) + b.now = func() time.Time { return now } + tasks := []workstreamWakeTask{} + b.workstreams.wakeDispatch = func(task workstreamWakeTask) { tasks = append(tasks, task) } + turns := 0 + b.workstreams.wakeTurn = func(ctx context.Context, ask session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { + turns++ + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > workstreamWakeTimeout { + t.Error("wake timeout missing") + } + return session.WorkstreamWakeAnswer{Text: `{"summary":"Review next steps.","items":[{"kind":"decision","text":"Review first."},{"kind":"draft","title":"Follow up","budget_usd":3}]}`, CostUSD: workstreamPtr(.04), Model: "fake"}, nil + } + return b, rec, &now, &tasks, &turns +} +func workstreamDrainWakes(b *appBackend, tasks *[]workstreamWakeTask) { + pending := *tasks + *tasks = nil + for _, task := range pending { + b.workstreamRunWake(context.Background(), task) + } +} +func workstreamWakeListForTest(t *testing.T, b *appBackend, id string) []appwire.WorkstreamWakeRun { + t.Helper() + out, err := b.WorkstreamWakeList(appwire.WorkstreamWakeListParams{WorkstreamID: id}) + if err != nil { + t.Fatal(err) + } + return out.Wakes +} + +func TestWakeA_MorningOncePerLocalDay(t *testing.T) { + b, _, now, tasks, turns := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Morning") + if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true}}}); err != nil { + t.Fatal(err) + } + b.workstreamWakeTick(*now) + if len(*tasks) != 0 { + t.Fatal("woke before first slot") + } + *now = now.Add(time.Minute) + b.workstreamWakeTick(*now) + workstreamDrainWakes(b, tasks) + for range 60 { + *now = now.Add(time.Minute) + b.workstreamWakeTick(*now) + } + if *turns != 1 || len(*tasks) != 0 { + t.Fatal("morning repeated") + } + *now = now.AddDate(0, 0, 1) + b.workstreamWakeTick(*now) + workstreamDrainWakes(b, tasks) + if *turns != 2 || len(workstreamWakeListForTest(t, b, w.ID)) != 2 { + t.Fatal("next day did not wake") + } + // Closed days produce no rows; opening late admits just the current day's slot. + *now = now.AddDate(0, 0, 3) + b.workstreamWakeTick(*now) + workstreamDrainWakes(b, tasks) + if *turns != 3 { + t.Fatal("closed days were replayed") + } + off := "off" + if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}); err != nil { + t.Fatal(err) + } + *now = now.AddDate(0, 0, 1) + b.workstreamWakeTick(*now) + if len(*tasks) != 0 { + t.Fatal("off woke") + } +} + +func TestWakeA_DisabledAndCreatedAfterSlotDoNotWake(t *testing.T) { + b, _, now, tasks, _ := workstreamWakeBackend(t) + *now = time.Date(2026, 11, 3, 12, 0, 0, 0, time.Local) + w := workstreamCreateForTest(t, b, "Late") + b.workstreamWakeTick(*now) + if len(*tasks) != 0 { + t.Fatal("disabled morning woke") + } + b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true, RelatedIssues: true}}}) + b.workstreamWakeTick(*now) + if len(*tasks) != 0 { + t.Fatal("slot before creation woke") + } +} + +func TestWakeB_JobHookBatchesAndDeduplicates(t *testing.T) { + b, rec, now, tasks, turns := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Events", workstreamJobRef("7")) + ev := workstreamJobEvent{Factory: "f", Job: 7, Kind: "merged", ID: "merge-1", At: *now, Label: "api#7 merged", Detail: "The merge was recorded."} + id, woke := b.WorkstreamJobEvent(ev) + if !woke || id == "" || len(*tasks) != 1 { + t.Fatal(id, woke) + } + if _, woke := b.WorkstreamJobEvent(ev); woke { + t.Fatal("duplicate woke") + } + ev.Job = 8 + if _, woke := b.WorkstreamJobEvent(ev); woke { + t.Fatal("nonmember woke") + } + workstreamDrainWakes(b, tasks) + runs := workstreamWakeListForTest(t, b, w.ID) + if *turns != 1 || len(runs) != 1 || len(runs[0].Items) != 2 || runs[0].State != "settled" { + t.Fatal(runs, *turns) + } + count := 0 + for _, e := range workstreamEvents(rec) { + if slices.Contains(e.Parts, "wakes") { + count++ + } + } + if count != 1 { + t.Fatal("wanted one settle receipt", count) + } + off := "off" + b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}) + ev.Job = 7 + ev.ID = "off" + if _, woke := b.WorkstreamJobEvent(ev); woke { + t.Fatal("off job event woke") + } +} + +func TestWakeF_CostIsRecordedAndAddedToAllSpendResults(t *testing.T) { + for _, mode := range []string{"known", "unknown", "unreadable"} { + t.Run(mode, func(t *testing.T) { + b, _, now, tasks, _ := workstreamWakeBackend(t) + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + return api.Queue{Jobs: []api.Job{{ID: 7, State: "done", Cost: 2}}}, nil + } + w := workstreamCreateForTest(t, b, "Spend", workstreamJobRef("7")) + before, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || before.Workstream.SpendUSD == nil { + t.Fatal(before, err) + } + b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { + a := session.WorkstreamWakeAnswer{Text: `{"summary":"Review","items":[]}`, CostUSD: workstreamPtr(.04)} + if mode == "unknown" { + a.CostUSD = nil + } + if mode == "unreadable" { + a.Text = "not JSON" + } + return a, nil + } + if _, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}); !woke { + t.Fatal("did not wake") + } + workstreamDrainWakes(b, tasks) + runs := workstreamWakeListForTest(t, b, w.ID) + after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil { + t.Fatal(err) + } + list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil { + t.Fatal(err) + } + want := *before.Workstream.SpendUSD + if mode != "unknown" { + want += .04 + } + if after.Workstream.SpendUSD == nil || *after.Workstream.SpendUSD != want || list.Workstreams[0].SpendUSD == nil || *list.Workstreams[0].SpendUSD != want { + t.Fatal("spend drift", after, list) + } + if mode == "unknown" { + if runs[0].CostUSD != nil { + t.Fatal("fabricated cost") + } + } else if runs[0].CostUSD == nil || *runs[0].CostUSD != .04 { + t.Fatal("cost lost") + } + if mode == "unreadable" && (runs[0].State != "failed" || workstreamValue(runs[0].Error) != workstream.WakeAnswerError) { + t.Fatal(runs) + } + }) + } +} + +func TestWakeG_WireActionsSendOneReceiptPerChange(t *testing.T) { + b, rec, now, tasks, _ := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Actions", workstreamJobRef("7")) + id, _ := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "failed", At: *now}) + workstreamDrainWakes(b, tasks) + runs := workstreamWakeListForTest(t, b, w.ID) + item := runs[0].Items[0].ID + events := func() int { return len(workstreamEvents(rec)) } + before := events() + first, err := b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id, ItemID: &item}) + if err != nil || events() != before+1 { + t.Fatal(first, err) + } + again, err := b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id, ItemID: &item}) + if err != nil || events() != before+1 || !reflect.DeepEqual(first, again) { + t.Fatal("repeated apply wrote") + } + if _, err := b.WorkstreamWakeNotNow(appwire.WorkstreamWakeNotNowParams{ID: id}); err != nil || events() != before+2 { + t.Fatal(err) + } + b.WorkstreamWakeNotNow(appwire.WorkstreamWakeNotNowParams{ID: id}) + if events() != before+2 { + t.Fatal("repeated dismissal wrote") + } + if _, err := b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: &item}); err != nil || events() != before+3 { + t.Fatal(err) + } + b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: &item}) + if events() != before+3 { + t.Fatal("repeated undo wrote") + } + _, err = b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: "missing"}) + workstreamCheckError(t, err, appwire.CodeNotFound) + _, err = b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: workstreamPtr("missing")}) + workstreamCheckError(t, err, appwire.CodeNotFound) + b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) + _, err = b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeConflict) + _, err = b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id}) + workstreamCheckError(t, err, appwire.CodeConflict) +} + +func TestWakeH_DoneAndOffAreRereadAtSettlement(t *testing.T) { + for _, choice := range []string{"done", "off"} { + t.Run(choice, func(t *testing.T) { + b, _, now, tasks, turns := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Finish", workstreamJobRef("7")) + small := "small" + b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &small, Wake: &appwire.WorkstreamWake{Morning: true}}}) + b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { + *turns++ + if choice == "done" { + _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) + if err != nil { + t.Fatal(err) + } + } else { + off := "off" + _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}) + if err != nil { + t.Fatal(err) + } + } + return session.WorkstreamWakeAnswer{Text: `{"summary":"Follow up","items":[{"kind":"draft","title":"Follow up","budget_usd":3},{"kind":"page","title":"Notes","markdown":"Next"}]}`}, nil + } + b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "needs_you", At: *now}) + workstreamDrainWakes(b, tasks) + runs := workstreamWakeListForTest(t, b, w.ID) + for _, i := range runs[0].Items { + if i.State != "proposed" { + t.Fatal(i) + } + } + *now = now.AddDate(0, 0, 1) + b.workstreamWakeTick(*now) + if _, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}); woke || len(*tasks) != 0 || *turns != 1 { + t.Fatal("stopped workstream woke") + } + }) + } +} + +func TestWakeE_NoFactoryActionsAndPersonPageIsUntouched(t *testing.T) { + for _, autonomy := range []string{"propose", "small"} { + t.Run(autonomy, func(t *testing.T) { + b, _, now, tasks, _ := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Pages", workstreamJobRef("7")) + queueReads, jobReads := 0, 0 + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + queueReads++ + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil + } + b.workstreams.job = func(context.Context, string, int) (api.Job, error) { jobReads++; return api.Job{}, nil } + b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &autonomy}}) + pages, _ := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) + person, _ := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: pages.Pages[0].ID}) + b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { + raw, _ := json.Marshal(map[string]any{"summary": "Next", "items": []any{map[string]any{"kind": "page", "page_id": person.Page.ID, "markdown": "Replace"}, map[string]any{"kind": "draft", "title": "Follow up", "budget_usd": 3}}}) + return session.WorkstreamWakeAnswer{Text: string(raw)}, nil + } + id, _ := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) + workstreamDrainWakes(b, tasks) + runs := workstreamWakeListForTest(t, b, w.ID) + if len(runs[0].Items) != 1 || runs[0].Items[0].Kind != "draft" { + t.Fatal(runs) + } + b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id}) + b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id}) + after, _ := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: person.Page.ID}) + if !reflect.DeepEqual(person, after) { + t.Fatal("person page changed") + } + // These seams are read-only Factory doors. No create/start door is reachable here. + if queueReads == 0 || jobReads != 0 { + t.Fatal("unexpected Factory door use", queueReads, jobReads) + } + }) + } +} + +func TestWakeB_ConcurrentDuplicateClaimsHaveOneTurn(t *testing.T) { + b, _, now, _, _ := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Race", workstreamJobRef("7")) + var mu sync.Mutex + tasks := []workstreamWakeTask{} + b.workstreams.wakeDispatch = func(task workstreamWakeTask) { mu.Lock(); defer mu.Unlock(); tasks = append(tasks, task) } + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + guard.Go("app/workstream-wake-claim-test", func() { + defer wg.Done() + b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "merged", ID: "one", At: *now}) + }) + } + wg.Wait() + if len(tasks) != 1 { + t.Fatal("multiple claims", len(tasks)) + } + workstreamDrainWakes(b, &tasks) + if runs := workstreamWakeListForTest(t, b, w.ID); len(runs) != 1 { + t.Fatal(runs) + } +} + +func TestWakeB_EventFallbackKeyUsesUTCSecondsAndValidatesWords(t *testing.T) { + b, _, now, tasks, _ := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Keys", workstreamJobRef("7")) + ev := workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: now.Add(100 * time.Millisecond)} + if _, woke := b.WorkstreamJobEvent(ev); !woke { + t.Fatal("first did not wake") + } + ev.At = ev.At.UTC().Add(200 * time.Millisecond) + if _, woke := b.WorkstreamJobEvent(ev); woke { + t.Fatal("same UTC second was not deduplicated") + } + for _, kind := range []string{"unknown", "morning"} { + ev.Kind = kind + if _, woke := b.WorkstreamJobEvent(ev); woke { + t.Fatal("invalid kind woke") + } + } + ev.Kind = "failed" + ev.Label = string(make([]byte, 201)) + if _, woke := b.WorkstreamJobEvent(ev); woke { + t.Fatal("oversize label woke") + } + workstreamDrainWakes(b, tasks) + if len(workstreamWakeListForTest(t, b, w.ID)) != 1 { + t.Fatal("invalid event made a row") + } +} + +func TestWakeA_LatestSlotIncludesExactEight(t *testing.T) { + now := time.Date(2026, 11, 3, 8, 0, 0, 0, time.Local) + for _, tc := range []struct{ at, want time.Time }{{now, now}, {now.Add(7 * time.Hour), now}, {now.Add(-time.Minute), time.Time{}}} { + slot, err := workstreamMorningSlot(tc.at) + if err != nil || !slot.Equal(tc.want) { + t.Fatalf("%s: slot %s want %s (%v)", tc.at, slot, tc.want, err) + } + } +} + +func TestWakeA_M3_PreEightOpeningNeverCatchesUpClosedDay(t *testing.T) { + b, _, now, tasks, turns := workstreamWakeBackend(t) + *now = time.Date(2026, 11, 1, 7, 59, 0, 0, time.Local) + w := workstreamCreateForTest(t, b, "Closed day") + if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true}}}); err != nil { + t.Fatal(err) + } + // No tick happens on the intervening day while the app is closed. + *now = time.Date(2026, 11, 3, 7, 0, 0, 0, time.Local) + b.workstreamWakeTick(*now) + if len(*tasks) != 0 || *turns != 0 { + t.Fatal("opening before 08:00 queued a closed day's wake") + } + if runs := workstreamWakeListForTest(t, b, w.ID); len(runs) != 0 { + t.Fatalf("opening before 08:00 created a wake row: %+v", runs) + } + *now = time.Date(2026, 11, 3, 8, 0, 0, 0, time.Local) + b.workstreamWakeTick(*now) + if len(*tasks) != 1 { + t.Fatalf("08:00 queued %d wakes, want one", len(*tasks)) + } + workstreamDrainWakes(b, tasks) + b.workstreamWakeTick(*now) + if *turns != 1 || len(*tasks) != 0 { + t.Fatalf("today did not wake exactly once: %d turns, %d queued", *turns, len(*tasks)) + } + s, err := b.workstreamStore(false) + if err != nil || s == nil { + t.Fatalf("store: %v", err) + } + runs, err := s.Wakes(context.Background(), w.ID, 100) + if err != nil || len(runs) != 1 || runs[0].Event.Kind != "morning" || !runs[0].Event.At.Equal(*now) || !runs[0].StartedAt.Equal(*now) || runs[0].State != "settled" { + t.Fatalf("expected only today's 08:00 wake: %+v %v", runs, err) + } +} + +func TestWakeF_RecordedWakeDoesNotInventUnknownSpend(t *testing.T) { + b, _, now, tasks, _ := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Unknown spend", workstreamJobRef("7")) + b.workstreams.spend = func(string) *float64 { return nil } + b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) + workstreamDrainWakes(b, tasks) + got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || got.Workstream.SpendUSD != nil { + t.Fatal(got, err) + } + listed, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + if err != nil || listed.Workstreams[0].SpendUSD != nil { + t.Fatal(listed, err) + } + if runs := workstreamWakeListForTest(t, b, w.ID); runs[0].CostUSD == nil || *runs[0].CostUSD != .04 { + t.Fatal(runs) + } +} + +func TestWakeB_ExpiredQueuedClaimNeverCallsTheModel(t *testing.T) { + b, _, now, tasks, turns := workstreamWakeBackend(t) + w := workstreamCreateForTest(t, b, "Stopped", workstreamJobRef("7")) + b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) + *now = now.Add(workstream.WakeAbandonedAfter + time.Millisecond) + workstreamDrainWakes(b, tasks) + runs := workstreamWakeListForTest(t, b, w.ID) + if *turns != 0 || runs[0].State != "failed" || workstreamValue(runs[0].Error) != workstream.WakeStoppedError || runs[0].CostUSD != nil { + t.Fatal(runs, *turns) + } +} diff --git a/cmd/codeaf/testdata/model_spend_owners.tsv b/cmd/codeaf/testdata/model_spend_owners.tsv index 0d4c27a918..3eb9f26fdd 100644 --- a/cmd/codeaf/testdata/model_spend_owners.tsv +++ b/cmd/codeaf/testdata/model_spend_owners.tsv @@ -76,6 +76,7 @@ internal/session/tools_music.go:Agent.composeMusic:GenerateMusic exception: musi internal/session/tools_sense.go:Agent.audioSense:Transcribe exception: speech internal/session/tools_speak.go:Agent.speakTool:Speak exception: speech internal/session/tools_video.go:Agent.renderVideo:GenerateVideo exception: video +internal/session/workstream_wake.go:workstreamWakeCall:CompleteWithMessages owner: Workstream wake (workstreamWakeCall -> session.RecordUsage; its cost_usd counts in the workstream spend) internal/shaped/prose.go:Prose:CompleteWithMessages exception: untraced shaped helpers internal/shaped/shaped.go:Answer:CompleteWithMessages exception: untraced shaped helpers internal/shaped/shaped.go:repair:CompleteWithMessages exception: untraced shaped helpers diff --git a/cmd/codeaf/testdata/workstreams-wire/contract.json b/cmd/codeaf/testdata/workstreams-wire/contract.json index b3959d0c9c..313f576d9d 100644 --- a/cmd/codeaf/testdata/workstreams-wire/contract.json +++ b/cmd/codeaf/testdata/workstreams-wire/contract.json @@ -24,7 +24,11 @@ "record.supersede", "goahead.list", "goahead.grant", - "goahead.revoke" + "goahead.revoke", + "wake.list", + "wake.apply", + "wake.undo", + "wake.not_now" ], "events": [ { @@ -155,7 +159,33 @@ "pages", "records", "goaheads", - "jobs" + "jobs", + "wakes" + ], + "WorkstreamWakeKind": [ + "morning", + "finished", + "merged", + "failed", + "needs_you" + ], + "WorkstreamWakeRunState": [ + "running", + "settled", + "failed" + ], + "WorkstreamWakeItemKind": [ + "move", + "draft", + "page", + "decision", + "question" + ], + "WorkstreamWakeItemState": [ + "applied", + "undone", + "proposed", + "dismissed" ] }, "types": { @@ -769,6 +799,132 @@ "required": false } }, + "WorkstreamWakeEvent": { + "kind": { + "type": "WorkstreamWakeKind", + "required": true + }, + "factory": { + "type": "string", + "required": false + }, + "job": { + "type": "integer", + "required": false + }, + "label": { + "type": "string", + "required": false + } + }, + "WorkstreamWakeItem": { + "id": { + "type": "string", + "required": true + }, + "kind": { + "type": "WorkstreamWakeItemKind", + "required": true + }, + "state": { + "type": "WorkstreamWakeItemState", + "required": true + }, + "text": { + "type": "string", + "required": true + }, + "why": { + "type": "string", + "required": false + }, + "factory": { + "type": "string", + "required": false + }, + "job": { + "type": "integer", + "required": false + }, + "draft_id": { + "type": "string", + "required": false + }, + "wave": { + "type": "integer", + "required": false + }, + "page_id": { + "type": "string", + "required": false + }, + "record_id": { + "type": "string", + "required": false + }, + "budget_usd": { + "type": "number (USD)", + "required": false + }, + "note": { + "type": "string", + "required": false + }, + "applied_at": { + "type": "string (RFC 3339)", + "required": false + }, + "undone_at": { + "type": "string (RFC 3339)", + "required": false + } + }, + "WorkstreamWakeRun": { + "id": { + "type": "string", + "required": true + }, + "workstream_id": { + "type": "string", + "required": true + }, + "event": { + "type": "WorkstreamWakeEvent", + "required": true + }, + "state": { + "type": "WorkstreamWakeRunState", + "required": true + }, + "started_at": { + "type": "string (RFC 3339)", + "required": true + }, + "settled_at": { + "type": "string (RFC 3339)", + "required": false + }, + "cost_usd": { + "type": "number (USD)", + "required": false + }, + "summary": { + "type": "string", + "required": false + }, + "error": { + "type": "string", + "required": false + }, + "dismissed_at": { + "type": "string (RFC 3339)", + "required": false + }, + "items": { + "type": "WorkstreamWakeItem[]", + "required": true + } + }, "WorkstreamListParams": {}, "WorkstreamListResult": { "workstreams": { @@ -1275,6 +1431,66 @@ "type": "WorkstreamGoAhead", "required": true } + }, + "WorkstreamWakeListParams": { + "workstream_id": { + "type": "string", + "required": true + }, + "limit": { + "type": "integer", + "required": false + } + }, + "WorkstreamWakeListResult": { + "wakes": { + "type": "WorkstreamWakeRun[]", + "required": true + } + }, + "WorkstreamWakeApplyParams": { + "id": { + "type": "string", + "required": true + }, + "item_id": { + "type": "string", + "required": false + } + }, + "WorkstreamWakeApplyResult": { + "wake": { + "type": "WorkstreamWakeRun", + "required": true + } + }, + "WorkstreamWakeUndoParams": { + "id": { + "type": "string", + "required": true + }, + "item_id": { + "type": "string", + "required": false + } + }, + "WorkstreamWakeUndoResult": { + "wake": { + "type": "WorkstreamWakeRun", + "required": true + } + }, + "WorkstreamWakeNotNowParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamWakeNotNowResult": { + "wake": { + "type": "WorkstreamWakeRun", + "required": true + } } } } diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.apply.json b/cmd/codeaf/testdata/workstreams-wire/wake.apply.json new file mode 100644 index 0000000000..5c1b061376 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/wake.apply.json @@ -0,0 +1,50 @@ +{ + "method": "wake.apply", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "too_large", + "unknown_method" + ], + "params_type": "WorkstreamWakeApplyParams", + "result_type": "WorkstreamWakeApplyResult", + "examples": { + "params": [ + { + "id": "wake_11", + "item_id": "wi_decision" + } + ], + "result": [ + { + "wake": { + "id": "wake_11", + "workstream_id": "ws_8f2c1a", + "event": { + "kind": "merged", + "factory": "f_local", + "job": 1102, + "label": "api#1102 merged" + }, + "state": "settled", + "started_at": "2026-11-03T14:00:00Z", + "settled_at": "2026-11-03T14:00:02Z", + "cost_usd": 0.04, + "summary": "Review the rollout.", + "items": [ + { + "id": "wi_decision", + "kind": "decision", + "state": "applied", + "text": "Kept decision: Roll out after review.", + "record_id": "rec_wake", + "applied_at": "2026-11-03T14:05:00Z" + } + ] + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.list.json b/cmd/codeaf/testdata/workstreams-wire/wake.list.json new file mode 100644 index 0000000000..bea5259804 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/wake.list.json @@ -0,0 +1,67 @@ +{ + "method": "wake.list", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamWakeListParams", + "result_type": "WorkstreamWakeListResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a", + "limit": 20 + } + ], + "result": [ + { + "wakes": [ + { + "id": "wake_11", + "workstream_id": "ws_8f2c1a", + "event": { + "kind": "merged", + "factory": "f_local", + "job": 1102, + "label": "api#1102 merged" + }, + "state": "settled", + "started_at": "2026-11-03T14:00:00Z", + "settled_at": "2026-11-03T14:00:02Z", + "cost_usd": 0.04, + "summary": "Applied: moved it to wave 3; updated Where it stands. Keep the rollout decision and consider a small follow-up job.", + "items": [ + { + "id": "wi_move", + "kind": "move", + "state": "applied", + "text": "Moved f_local#1102 to wave 3.", + "factory": "f_local", + "job": 1102, + "wave": 3, + "applied_at": "2026-11-03T14:00:02Z" + }, + { + "id": "wi_decision", + "kind": "decision", + "state": "proposed", + "text": "Keep decision: Roll out after review.", + "why": "The merge is ready for review." + }, + { + "id": "wi_draft", + "kind": "draft", + "state": "proposed", + "text": "Add draft: Check rollout.", + "wave": 3, + "budget_usd": 3.0 + } + ] + } + ] + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json b/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json new file mode 100644 index 0000000000..dbea46ce44 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json @@ -0,0 +1,46 @@ +{ + "method": "wake.not_now", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamWakeNotNowParams", + "result_type": "WorkstreamWakeNotNowResult", + "examples": { + "params": [ + { + "id": "wake_11" + } + ], + "result": [ + { + "wake": { + "id": "wake_11", + "workstream_id": "ws_8f2c1a", + "event": { + "kind": "merged", + "factory": "f_local", + "job": 1102, + "label": "api#1102 merged" + }, + "state": "settled", + "started_at": "2026-11-03T14:00:00Z", + "settled_at": "2026-11-03T14:00:02Z", + "cost_usd": 0.04, + "summary": "Review the rollout.", + "dismissed_at": "2026-11-03T14:07:00Z", + "items": [ + { + "id": "wi_decision", + "kind": "decision", + "state": "dismissed", + "text": "Keep decision: Roll out after review." + } + ] + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.undo.json b/cmd/codeaf/testdata/workstreams-wire/wake.undo.json new file mode 100644 index 0000000000..399751245e --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/wake.undo.json @@ -0,0 +1,51 @@ +{ + "method": "wake.undo", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "too_large", + "unknown_method" + ], + "params_type": "WorkstreamWakeUndoParams", + "result_type": "WorkstreamWakeUndoResult", + "examples": { + "params": [ + { + "id": "wake_11", + "item_id": "wi_decision" + } + ], + "result": [ + { + "wake": { + "id": "wake_11", + "workstream_id": "ws_8f2c1a", + "event": { + "kind": "merged", + "factory": "f_local", + "job": 1102, + "label": "api#1102 merged" + }, + "state": "settled", + "started_at": "2026-11-03T14:00:00Z", + "settled_at": "2026-11-03T14:00:02Z", + "cost_usd": 0.04, + "summary": "Review the rollout.", + "items": [ + { + "id": "wi_decision", + "kind": "decision", + "state": "undone", + "text": "Keep decision: Roll out after review.", + "record_id": "rec_wake", + "applied_at": "2026-11-03T14:05:00Z", + "undone_at": "2026-11-03T14:06:00Z" + } + ] + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json b/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json index 663445fa90..2c7a8f387f 100644 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json @@ -11,7 +11,8 @@ "pages", "records", "goaheads", - "jobs" + "jobs", + "wakes" ] }, { diff --git a/docs/changes/unreleased/2319-workstreams-watching-agent.md b/docs/changes/unreleased/2319-workstreams-watching-agent.md new file mode 100644 index 0000000000..137499e3c5 --- /dev/null +++ b/docs/changes/unreleased/2319-workstreams-watching-agent.md @@ -0,0 +1,23 @@ +--- +kind: added +title: Workstream agents wake for morning slots and member job events with undoable changes +pr: 2319 +surface: [app, engine] +invalidates: [] +--- + +Workstreams can produce one costed, ranked batch per morning slot or delivered +member-job event. Propose only keeps suggestions; Small changes can edit the +plan and agent pages, with Apply, Undo applied and Not now for each card. +Wakes never start Factory jobs or write the person's pages. Recorded wake costs +count toward workstream spend. Daily caps bound background calls. + +The additive wire contract is v0.3, with four wake methods and a wakes change +part. Storage migrates additively from schema 3 to 4 on the first write. +Related-issue wakes are not built. The job event hook awaits the Factory event +source; queue reads do not trigger it. + +Morning wakes consider only today's local 08:00 slot; opening before it never +catches up yesterday. Undo preserves later draft edits and page metadata +changes, and removes a wake-created wave only while it is unchanged and empty. +A wake keeps only the first page suggestion per existing page or new-page title. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 21cc6d259e..f80464efd0 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -1284,6 +1284,15 @@ role pins and checked as the other pins are. | `tools.output` | `in.toolCallParams` | `LinesNextLineTotalResult` | | `tools.unfollow` | `in.toolCallParams` | `Empty` | +## wake methods + +| method | params | result | +|---|---|---| +| `wake.apply` | `in.WorkstreamWakeApplyParams` | `WorkstreamWakeApplyResult` | +| `wake.list` | `in.WorkstreamWakeListParams` | `WorkstreamWakeListResult` | +| `wake.not_now` | `in.WorkstreamWakeNotNowParams` | `WorkstreamWakeNotNowResult` | +| `wake.undo` | `in.WorkstreamWakeUndoParams` | `WorkstreamWakeUndoResult` | + ## work methods | method | params | result | diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go index cddf6df198..95aa4b56a5 100644 --- a/internal/appwire/workstreams.go +++ b/internal/appwire/workstreams.go @@ -45,6 +45,10 @@ var WorkstreamMethods = []string{ "goahead.list", "goahead.grant", "goahead.revoke", + "wake.list", + "wake.apply", + "wake.undo", + "wake.not_now", } // Optional numbers and booleans use pointers so known zero and false survive. @@ -225,7 +229,7 @@ type WorkstreamGoAhead struct { type WorkstreamChanged struct { ID string `json:"id"` - Parts []string `json:"parts,omitempty" enum:"workstream,plan,pages,records,goaheads,jobs"` + Parts []string `json:"parts,omitempty" enum:"workstream,plan,pages,records,goaheads,jobs,wakes"` } type WorkstreamTaken struct { @@ -501,6 +505,73 @@ type WorkstreamGoAheadRevokeResult struct { GoAhead WorkstreamGoAhead `json:"goahead"` } +type WorkstreamWakeEvent struct { + Kind string `json:"kind" enum:"morning,finished,merged,failed,needs_you"` + Factory *string `json:"factory,omitempty"` + Job *int `json:"job,omitempty"` + Label *string `json:"label,omitempty"` +} + +type WorkstreamWakeItem struct { + ID string `json:"id"` + Kind string `json:"kind" enum:"move,draft,page,decision,question"` + State string `json:"state" enum:"applied,undone,proposed,dismissed"` + Text string `json:"text"` + Why *string `json:"why,omitempty"` + Factory *string `json:"factory,omitempty"` + Job *int `json:"job,omitempty"` + DraftID *string `json:"draft_id,omitempty"` + Wave *int `json:"wave,omitempty"` + PageID *string `json:"page_id,omitempty"` + RecordID *string `json:"record_id,omitempty"` + BudgetUSD *float64 `json:"budget_usd,omitempty"` + Note *string `json:"note,omitempty"` + AppliedAt time.Time `json:"applied_at,omitzero"` + UndoneAt time.Time `json:"undone_at,omitzero"` +} + +type WorkstreamWakeRun struct { + ID string `json:"id"` + WorkstreamID string `json:"workstream_id"` + Event WorkstreamWakeEvent `json:"event"` + State string `json:"state" enum:"running,settled,failed"` + StartedAt time.Time `json:"started_at"` + SettledAt time.Time `json:"settled_at,omitzero"` + CostUSD *float64 `json:"cost_usd,omitempty"` + Summary *string `json:"summary,omitempty"` + Error *string `json:"error,omitempty"` + DismissedAt time.Time `json:"dismissed_at,omitzero"` + Items []WorkstreamWakeItem `json:"items"` +} + +type WorkstreamWakeListParams struct { + WorkstreamID string `json:"workstream_id"` + Limit *int `json:"limit,omitempty"` +} +type WorkstreamWakeListResult struct { + Wakes []WorkstreamWakeRun `json:"wakes"` +} +type WorkstreamWakeApplyParams struct { + ID string `json:"id"` + ItemID *string `json:"item_id,omitempty"` +} +type WorkstreamWakeApplyResult struct { + Wake WorkstreamWakeRun `json:"wake"` +} +type WorkstreamWakeUndoParams struct { + ID string `json:"id"` + ItemID *string `json:"item_id,omitempty"` +} +type WorkstreamWakeUndoResult struct { + Wake WorkstreamWakeRun `json:"wake"` +} +type WorkstreamWakeNotNowParams struct { + ID string `json:"id"` +} +type WorkstreamWakeNotNowResult struct { + Wake WorkstreamWakeRun `json:"wake"` +} + // Workstreamer is the optional engine half of the workstreams contract. // A stub implements the shapes but returns ErrWorkstreamsNotYet and serves false. type Workstreamer interface { @@ -530,6 +601,10 @@ type Workstreamer interface { WorkstreamGoAheadList(WorkstreamGoAheadListParams) (WorkstreamGoAheadListResult, error) WorkstreamGoAheadGrant(WorkstreamGoAheadGrantParams) (WorkstreamGoAheadGrantResult, error) WorkstreamGoAheadRevoke(WorkstreamGoAheadRevokeParams) (WorkstreamGoAheadRevokeResult, error) + WorkstreamWakeList(WorkstreamWakeListParams) (WorkstreamWakeListResult, error) + WorkstreamWakeApply(WorkstreamWakeApplyParams) (WorkstreamWakeApplyResult, error) + WorkstreamWakeUndo(WorkstreamWakeUndoParams) (WorkstreamWakeUndoResult, error) + WorkstreamWakeNotNow(WorkstreamWakeNotNowParams) (WorkstreamWakeNotNowResult, error) } // workstreamCall preserves the params/result types used by the schema generator. @@ -587,6 +662,10 @@ func init() { methods["goahead.list"] = workstreamCall(Workstreamer.WorkstreamGoAheadList) methods["goahead.grant"] = workstreamCall(Workstreamer.WorkstreamGoAheadGrant) methods["goahead.revoke"] = workstreamCall(Workstreamer.WorkstreamGoAheadRevoke) + methods["wake.list"] = workstreamCall(Workstreamer.WorkstreamWakeList) + methods["wake.apply"] = workstreamCall(Workstreamer.WorkstreamWakeApply) + methods["wake.undo"] = workstreamCall(Workstreamer.WorkstreamWakeUndo) + methods["wake.not_now"] = workstreamCall(Workstreamer.WorkstreamWakeNotNow) eventShapes[EventWorkstreamChanged] = reflect.TypeFor[WorkstreamChanged]() } diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go index 556e8eab12..28efde3f8a 100644 --- a/internal/appwire/workstreams_check.go +++ b/internal/appwire/workstreams_check.go @@ -423,3 +423,15 @@ func workstreamDraftFields(item WorkstreamPlanItem) error { } return workstreamChecks(workstreamOptionalEnum(item.Size, "S", "M", "L"), workstreamSource(item.Source)) } + +func (p WorkstreamWakeListParams) Check() error { + _, err := bounded("limit", p.Limit, 1, 100, 20) + return workstreamChecks(workstreamID(p.WorkstreamID), err) +} +func (p WorkstreamWakeApplyParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ItemID)) +} +func (p WorkstreamWakeUndoParams) Check() error { + return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ItemID)) +} +func (p WorkstreamWakeNotNowParams) Check() error { return workstreamID(p.ID) } diff --git a/internal/appwire/workstreams_test.go b/internal/appwire/workstreams_test.go index c3fe76e3d2..9d1e9a4c3a 100644 --- a/internal/appwire/workstreams_test.go +++ b/internal/appwire/workstreams_test.go @@ -127,10 +127,10 @@ func workstreamHelloHasFeature(hello map[string]any) bool { return false } -// V1: the contract index and dispatch table name exactly the same 25 methods. +// V1: the contract index and dispatch table name exactly the same 29 methods. func TestEveryWorkstreamMethodIsRegistered(t *testing.T) { - if len(WorkstreamMethods) != 25 { - t.Fatalf("contract has %d methods, want 25", len(WorkstreamMethods)) + if len(WorkstreamMethods) != 29 { + t.Fatalf("contract has %d methods, want 29", len(WorkstreamMethods)) } known := make(map[string]bool, len(WorkstreamMethods)) served := Methods() @@ -144,7 +144,7 @@ func TestEveryWorkstreamMethodIsRegistered(t *testing.T) { } } for _, name := range served { - for _, prefix := range []string{"workstream.", "page.", "record.", "goahead."} { + for _, prefix := range []string{"workstream.", "page.", "record.", "goahead.", "wake."} { if strings.HasPrefix(name, prefix) && !known[name] { t.Errorf("registered method not in contract: %s", name) } @@ -244,3 +244,31 @@ func TestWorkstreamSchemaNamesEveryMethod(t *testing.T) { } } } + +func (f *fakeWorkstreamer) WorkstreamWakeList(WorkstreamWakeListParams) (WorkstreamWakeListResult, error) { + return WorkstreamWakeListResult{}, ErrWorkstreamsNotYet +} +func (f *fakeWorkstreamer) WorkstreamWakeApply(WorkstreamWakeApplyParams) (WorkstreamWakeApplyResult, error) { + return WorkstreamWakeApplyResult{}, ErrWorkstreamsNotYet +} +func (f *fakeWorkstreamer) WorkstreamWakeUndo(WorkstreamWakeUndoParams) (WorkstreamWakeUndoResult, error) { + return WorkstreamWakeUndoResult{}, ErrWorkstreamsNotYet +} +func (f *fakeWorkstreamer) WorkstreamWakeNotNow(WorkstreamWakeNotNowParams) (WorkstreamWakeNotNowResult, error) { + return WorkstreamWakeNotNowResult{}, ErrWorkstreamsNotYet +} + +func TestWakeJ_ParameterBounds(t *testing.T) { + for _, p := range []interface{ Check() error }{WorkstreamWakeListParams{}, WorkstreamWakeApplyParams{}, WorkstreamWakeUndoParams{}, WorkstreamWakeNotNowParams{}, WorkstreamWakeListParams{WorkstreamID: "w", Limit: workstreamWireInt(101)}, WorkstreamWakeApplyParams{ID: "w", ItemID: workstreamWireString("")}} { + if p.Check() == nil { + t.Errorf("accepted %+v", p) + } + } + for _, p := range []interface{ Check() error }{WorkstreamWakeListParams{WorkstreamID: "w"}, WorkstreamWakeApplyParams{ID: "w"}, WorkstreamWakeUndoParams{ID: "w"}, WorkstreamWakeNotNowParams{ID: "w"}} { + if err := p.Check(); err != nil { + t.Error(err) + } + } +} +func workstreamWireInt(n int) *int { return &n } +func workstreamWireString(s string) *string { return &s } diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 14f95fe04e..2ff81605cb 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -238,3 +238,40 @@ Expiry, revocation and marking done leave a kept record kept while ending its gr `record.list` returns decisions and questions by default. Use `include_goaheads: true` for every kind, or `kind: goahead` for only go-ahead records. The agent's record reads include go-ahead proposals and their grant IDs. + +## Watching workstreams: morning wakes, costs, Apply, Undo and Not now + +With morning wakes on, a workstream can wake once for today's 08:00 slot in +this machine's zone, while the app is open, once that slot has passed and if it +is at or after creation. Before 08:00 no morning slot is eligible; closed days +are never caught up. +The app first checks after 30 seconds, then every minute; opening late wakes +once, without replaying a backlog of missed days. Related-issue wakes are +stored as a preference but are not available yet. Job-event handling is ready +for finished, merged, failed and needs-you events; automatic delivery awaits +the Factory event source. Nothing else wakes the agent. Done or autonomy Off +stops new wakes. + +Each wake makes one model call, bounded to three minutes, and returns one card +with a short summary and up to ten ranked applied changes and proposals. Propose only applies +nothing. Small changes can move plan items to manual waves without a wave +go-ahead, add follow-up drafts with a budget below $5 to a manual wave, and +write the agent's pages. A draft stays a draft. Decisions and questions remain +proposals. The person's pages are never written by a wake. + +## Applying and undoing wake proposals: Apply, Undo applied and Not now + +Apply chooses one or all proposals, regardless of autonomy; a decision or +question becomes kept. Undo applied restores changes only while their targets +are unchanged. Updated pages restore a previous version as a new agent version; +new pages are restorably deleted. A changed target refuses a single Undo; +Undo applied leaves that item with “Changed since; not undone.” Not now +dismisses the remaining proposals. Repeating an action changes nothing. +Done workstreams refuse Apply and Undo. + +Every recorded wake cost counts toward workstream spend. Unknown costs stay +absent. At most 24 wakes per workstream and 200 per home can run per local day, +with one turn at a time. A running wake left over 15 minutes becomes failed on +the next check or list: “Stopped before it finished.” Its cost is unknown. +An unreadable answer says “The agent's answer could not be read.” Any recorded +cost still counts toward spend. diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 08b56dcc05..2279266e72 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -693,7 +693,7 @@ const ( // role, the call site writes `callPurpose(roles.RoleX)` and no constant is // declared here — restating a role's string would be the same word in two // places, and the point of a small closed block is that what IS in it stands - // out. The two below are not roles at all. + // out. The purposes below are not roles at all. // // purposeAutomationCheck is one automation watch's judgment: does what its // look saw meet the condition? It is the ONE DELIBERATE DIVERGENCE: the call @@ -704,6 +704,8 @@ const ( // tag (issue #1010, whose spine is #928). The standing check this replaced // was tagged `standing-check`, which cmd/codeaf-replay still reads. purposeAutomationCheck callPurpose = "automation-check" + // purposeWorkstreamWake names one workstream's event turn on the sentinel ladder. + purposeWorkstreamWake callPurpose = "workstream-wake" // purposeDocument is a rung of the document reader — the model's own eyes on // a PDF the `read` tool cannot open as text. It is not a role because the // reader resolves its model from the document settings and never from the diff --git a/internal/session/workstream_wake.go b/internal/session/workstream_wake.go new file mode 100644 index 0000000000..057e9a2971 --- /dev/null +++ b/internal/session/workstream_wake.go @@ -0,0 +1,89 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/roles" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// WorkstreamWakeAsk is a bounded snapshot for one unattended turn, with no tools. +type WorkstreamWakeAsk struct { + ID string + AgentID string + Event workstream.WakeEvent + Snapshot json.RawMessage +} + +type WorkstreamWakeAnswer struct { + Text string + CostUSD *float64 + Model string +} + +const workstreamWakeRules = `You are the workstream's agent. You propose; the person keeps. You write only your own pages; the person's pages are theirs. Keep your agent page "` + WorkstreamWhereItStandsTitle + `" current, saying what it was updated from. Never invent numbers: counts, costs, states and dates come from the snapshot; absent numbers are unknown. On a page use a live block such as [live: jobs] instead of writing a number. You start nothing; the person launches jobs. +For this wake, answer one JSON object. Summarise in at most two sentences, rank the most useful items first, at most 10 items. The engine decides what applies under autonomy; decisions and questions are always proposed. You cannot start jobs or write the person's pages. There are no tools.` + +const workstreamWakeFormat = `Answer format: +{"summary":"...","items":[{"kind":"move","factory":"f1","job":1102,"to_wave":3,"why":"..."},{"kind":"move","draft_id":"d_x","to_wave":2,"why":"..."},{"kind":"draft","title":"...","repo":"api","text":"...","size":"S","budget_usd":3,"wave":3,"why":"..."},{"kind":"page","page_id":"existing agent page id; omit for new","title":"...","markdown":"...","why":"..."},{"kind":"decision","text":"...","why":"..."},{"kind":"question","text":"...","why":"..."}]}` + +func workstreamWakeQuestion(ask WorkstreamWakeAsk) string { + event, _ := json.Marshal(ask.Event) + snapshot := "Numbers absent below are unknown; say so and never estimate them.\n" + string(ask.Snapshot) + if len(snapshot) > workstreamReadMax { + const tail = "\nThe workstream read was cut." + snapshot = workstreamCut(snapshot, workstreamReadMax-len(tail)) + tail + } + return "Event:\n" + string(event) + "\nWorkstream snapshot:\n" + snapshot + "\n" + workstreamWakeFormat +} + +// RunWorkstreamWake resolves the credentialed sentinel ladder down to the person's model. +// It constructs call state only: no agent, transcript, tool belt or turn loop. +func RunWorkstreamWake(ctx context.Context, settings TeamProposerConfig, ask WorkstreamWakeAsk) (WorkstreamWakeAnswer, error) { + cfg := Config{Model: settings.Model, APIKey: settings.APIKey, BaseURL: settings.BaseURL, ProfileDir: settings.ProfileDir, Sources: settings.Sources, RolesSource: settings.RolesSource} + models, err := automationJudgeModels(cfg) + if err != nil { + return WorkstreamWakeAnswer{}, err + } + model := models[0] + client, err := newProviderClient(cfg, model) + if err != nil { + return WorkstreamWakeAnswer{}, err + } + return workstreamWakeCall(ctx, client, model, ask, func(line UsageLine) { RecordUsage(UsageLedgerPath(), line) }) +} + +func workstreamWakeCall(ctx context.Context, client Completer, model string, ask WorkstreamWakeAsk, record func(UsageLine)) (WorkstreamWakeAnswer, error) { + ctx = provider.WithRole(provider.WithRoutingIntent(provider.WithoutStream(withPurpose(ctx, purposeWorkstreamWake)), provider.IntentBackground), lane.RoleStanding) + response, err := client.CompleteWithMessages(ctx, []ai.Message{textMessage("system", workstreamWakeRules), textMessage("user", workstreamWakeQuestion(ask))}) + answer := WorkstreamWakeAnswer{Model: model} + line := UsageLine{Model: model, Role: string(roles.RoleSentinel), Calls: 1, Session: ask.AgentID, Run: ask.ID, Unbilled: true} + if response == nil { + record(line) + return answer, workstreamWakeResponseError(err) + } + answer.Text = response.Text() + if response.Usage != nil { + line.Input, line.Output = response.Usage.PromptTokens, response.Usage.CompletionTokens + answer.CostUSD = response.Usage.Cost + if answer.CostUSD != nil { + line.USD = *answer.CostUSD + line.Price = usagePrice(line.USD, usagePriceProvider) + line.Unbilled = false + } + } + record(line) + return answer, err +} +func workstreamWakeResponseError(err error) error { + if err != nil { + return err + } + return errors.New("the model answered nothing") +} diff --git a/internal/session/workstream_wake_test.go b/internal/session/workstream_wake_test.go new file mode 100644 index 0000000000..0bc04cb14e --- /dev/null +++ b/internal/session/workstream_wake_test.go @@ -0,0 +1,87 @@ +package session + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func TestWakeB_OneModelCallWithCostAndNoTools(t *testing.T) { + for _, known := range []bool{true, false} { + t.Run(map[bool]string{true: "known", false: "unknown"}[known], func(t *testing.T) { + usd := .04 + client := &scriptedCompleter{steps: []step{func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + if len(messages) != 2 || provider.RoleFrom(ctx) != lane.RoleStanding || provider.RoutingIntentFrom(ctx) != provider.IntentBackground { + t.Fatalf("wake call: %+v", messages) + } + if !strings.Contains(messages[0].Content[0].Text, "person's pages") || !strings.Contains(messages[1].Content[0].Text, "Answer format:") { + t.Fatal("wake rules absent") + } + r := textResponse(`{"summary":"Next steps","items":[]}`) + if known { + r.Usage = &ai.Usage{Cost: &usd, PromptTokens: 10, CompletionTokens: 5} + } else { + r.Usage = nil + } + return r, nil + }}} + var lines []UsageLine + answer, err := workstreamWakeCall(context.Background(), client, "cheap/model", WorkstreamWakeAsk{ID: "wake", AgentID: "agent", Event: workstream.WakeEvent{Kind: "morning"}, Snapshot: json.RawMessage(`{}`)}, func(line UsageLine) { lines = append(lines, line) }) + if err != nil || client.requests() != 1 || len(lines) != 1 || answer.Model != "cheap/model" { + t.Fatal(answer, lines, err) + } + if known && (answer.CostUSD == nil || *answer.CostUSD != .04 || lines[0].USD != .04) { + t.Fatal("cost lost") + } + if !known && (answer.CostUSD != nil || !lines[0].Unbilled) { + t.Fatal("unknown cost fabricated") + } + }) + } +} + +func TestWakeB_m2_ModelCallCarriesWakePurpose(t *testing.T) { + checks := 0 + adapter, err := provider.NewClient(provider.Config{ + BaseURL: "http://provider.test", Model: "test/model", APIKey: "test-key", + HTTPClient: fakeHTTP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`)) + })), + RouteGate: func(ctx context.Context, model, tag string) error { + checks++ + if tag != "workstream-wake" { + t.Fatalf("wake purpose: %q", tag) + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + client := &scriptedCompleter{steps: []step{func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + return adapter.CompleteWithMessages(ctx, messages) + }}} + lines := 0 + _, err = workstreamWakeCall(context.Background(), client, "test/model", WorkstreamWakeAsk{ID: "wake", AgentID: "agent", Event: workstream.WakeEvent{Kind: "morning"}, Snapshot: json.RawMessage(`{}`)}, func(UsageLine) { lines++ }) + if err != nil || client.requests() != 1 || checks != 1 || lines != 1 { + t.Fatalf("wake call: requests %d, purpose checks %d, usage lines %d (%v)", client.requests(), checks, lines, err) + } +} + +func TestWakeB_SnapshotUsesReadCutRule(t *testing.T) { + text := workstreamWakeQuestion(WorkstreamWakeAsk{Snapshot: json.RawMessage(`{"words":"` + strings.Repeat("é", 40000) + `"}`)}) + start := strings.Index(text, "Numbers absent below") + end := strings.Index(text, "\nAnswer format:") + if start < 0 || end < start || end-start > 60000 || !utf8.ValidString(text) || !strings.HasSuffix(text[start:end], "\nThe workstream read was cut.") { + t.Fatal("snapshot cut drifted") + } +} diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go index c5d63d8900..8ddc386987 100644 --- a/internal/workspace/schema3_test.go +++ b/internal/workspace/schema3_test.go @@ -74,8 +74,8 @@ func TestPartASchemaTwoReadsWithoutWritingAndMigratesAllRows(t *testing.T) { if err := s.Rename(ctx, "aaaa", "Products"); err != nil { t.Fatal(err) } - if userVersion(t, s) != 3 { - t.Fatal("first write did not migrate to 3") + if userVersion(t, s) != 4 { + t.Fatal("first write did not migrate to 4") } gotC, gotM := legacyRows(t, s.db) wantC[0] = strings.Replace(wantC[0], "Product|", "Products|", 1) @@ -221,6 +221,7 @@ func TestWorkstreamSchemaThreeRequiresDeletionColumns(t *testing.T) { path := filepath.Join(t.TempDir(), "obsolete.db") s := openTestStore(t, path) createTestCollection(t, s, "Schema three") + workstreamSchemaThreeFixture(t, s) if userVersion(t, s) != 3 { t.Fatal("fixture is not schema 3") } @@ -308,7 +309,7 @@ func TestStabEB1AnOlderWriterCannotDeleteAWorkstream(t *testing.T) { older := workstreamSchemaTwoWriter(t, path) s := openTestStore(t, path) seedWorkstream(t, s, "protected", "Protected") - if userVersion(t, s) != 3 { + if userVersion(t, s) != 4 { t.Fatal("workstream creation did not migrate schema 2") } // The domain store imports workspace, so this fixture uses its shared SQL @@ -413,6 +414,7 @@ func TestStabEB1AnEarlierSchemaThreeFileGainsTheGuardOnItsNextWrite(t *testing.T path := filepath.Join(t.TempDir(), "earlier.db") fixture := openTestStore(t, path) plain := createTestCollection(t, fixture, "Earlier") + workstreamSchemaThreeFixture(t, fixture) if _, err := fixture.db.Exec("DROP TRIGGER IF EXISTS workstream_collection_keep"); err != nil { t.Fatal(err) } diff --git a/internal/workspace/schema4_test.go b/internal/workspace/schema4_test.go new file mode 100644 index 0000000000..c3fe9f8e52 --- /dev/null +++ b/internal/workspace/schema4_test.go @@ -0,0 +1,110 @@ +package workspace + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func workstreamSchemaThreeFixture(t *testing.T, s *Store) { + t.Helper() + if _, err := s.db.Exec("DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3"); err != nil { + t.Fatal(err) + } +} + +func TestWakeI_SchemaThreeMigratesAdditively(t *testing.T) { + path := filepath.Join(t.TempDir(), "schema.db") + s := openTestStore(t, path) + seedWorkstream(t, s, "w", "Watching") + if _, err := s.db.Exec(` + INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('p','w','Notes','agent',0,1,100,'agent','{}'); + INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('p',1,100,'agent','Notes','{}',2); + INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) VALUES ('r','w','decision','Keep it','kept','agent',100); + INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('w',1,100,'you','[]','[]');`); err != nil { + t.Fatal(err) + } + workstreamSchemaThreeFixture(t, s) + queries := []string{"SELECT * FROM workstreams", "SELECT * FROM workstream_pages", "SELECT * FROM workstream_page_versions", "SELECT * FROM workstream_records", "SELECT * FROM workstream_plans"} + snapshot := func() [][]string { + all := [][]string{} + for _, q := range queries { + rows, err := s.db.Query(q) + if err != nil { + t.Fatal(err) + } + columns, _ := rows.Columns() + for rows.Next() { + cells := make([]sql.NullString, len(columns)) + args := make([]any, len(cells)) + for i := range args { + args[i] = &cells[i] + } + if err := rows.Scan(args...); err != nil { + t.Fatal(err) + } + values := []string{} + for _, cell := range cells { + values = append(values, cell.String) + } + all = append(all, values) + } + rows.Close() + } + return all + } + before := snapshot() + if err := s.Write(context.Background(), func(*sql.Tx) error { return nil }); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 4 || !reflect.DeepEqual(snapshot(), before) { + t.Fatal("migration lost existing rows") + } + var count int + if err := s.db.QueryRow("SELECT count(*) FROM workstream_wakes").Scan(&count); err != nil || count != 0 { + t.Fatalf("wakes: %d %v", count, err) + } +} + +func TestWakeI_NewerSchemaIsRefusedWithoutWriting(t *testing.T) { + path := filepath.Join(t.TempDir(), "future.db") + s := openTestStore(t, path) + createTestCollection(t, s, "Future") + if _, err := s.db.Exec("PRAGMA user_version=5"); err != nil { + t.Fatal(err) + } + s.Close() + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + opened, err := Open(path) + if opened != nil { + opened.Close() + } + if err == nil || !strings.Contains(err.Error(), "version 5") { + t.Fatalf("newer schema: %v", err) + } + after, err := os.ReadFile(path) + if err != nil || string(before) != string(after) { + t.Fatal("newer file changed") + } +} + +func TestWakeI_FailedFirstWriteRollsBackTheMigration(t *testing.T) { + s := openTestStore(t, filepath.Join(t.TempDir(), "rollback.db")) + seedWorkstream(t, s, "w", "Watching") + workstreamSchemaThreeFixture(t, s) + err := s.Write(context.Background(), func(*sql.Tx) error { return ErrInvalid }) + if err != ErrInvalid || userVersion(t, s) != 3 { + t.Fatal("failed write migrated", err) + } + var n int + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE name IN ('workstream_wakes','workstream_wake_items')").Scan(&n); err != nil || n != 0 { + t.Fatal("partial migration", n, err) + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index e6d7f8f1d9..e84defbce0 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -20,10 +20,10 @@ import ( sqlite3 "modernc.org/sqlite/lib" ) -// schemaVersion is the schema this build writes. Schemas 1 and 2 remain -// readable without migration; the first successful write upgrades them to 3. +// schemaVersion is the schema this build writes. Schemas 1, 2 and 3 remain +// readable without migration; the first successful write upgrades them to 4. const ( - schemaVersion = 3 + schemaVersion = WorkstreamWakeSchemaVersion legacySchemaVersion = 1 ) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. @@ -41,6 +41,7 @@ const ( schemaReady schemaLegacy schemaTwo + schemaThree schemaRefused ) @@ -51,6 +52,8 @@ func (s schemaState) version() int { return schemaVersion case schemaTwo: return 2 + case schemaThree: + return 3 case schemaLegacy: return legacySchemaVersion } @@ -174,14 +177,17 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := q.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return schemaBlank, err } - if app == applicationID && (version == schemaVersion || version == 2 || version == legacySchemaVersion) { + if app == applicationID && (version == schemaVersion || version == 3 || version == 2 || version == legacySchemaVersion) { state := schemaReady collections := "SELECT seq,id,name,created_at FROM collections LIMIT 0" memberships := "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at FROM memberships LIMIT 0" if version == 2 { state = schemaTwo } - if version == schemaVersion { + if version == 3 { + state = schemaThree + } + if version >= 3 { memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships LIMIT 0" } if version == legacySchemaVersion { @@ -197,13 +203,21 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if _, err := q.ExecContext(ctx, memberships); err != nil { return state, err } - if version == schemaVersion { + if version >= 3 { for _, query := range workstreamSchemaReads { if _, err := q.ExecContext(ctx, query); err != nil { return state, err } } } + if version >= WorkstreamWakeSchemaVersion { + if _, err := q.ExecContext(ctx, "SELECT id,workstream_id,event_key,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at FROM workstream_wakes LIMIT 0"); err != nil { + return state, err + } + if _, err := q.ExecContext(ctx, "SELECT id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at FROM workstream_wake_items LIMIT 0"); err != nil { + return state, err + } + } return state, nil } if app != 0 || version != 0 { @@ -312,13 +326,21 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { switch state { case schemaReady: _, err = tx.ExecContext(ctx, workstreamCollectionKeep) + case schemaThree: + err = migrateWorkstreamWakes(ctx, tx) case schemaLegacy, schemaTwo: err = migrateLegacy(ctx, tx, state.version()) + if err == nil { + err = migrateWorkstreamWakes(ctx, tx) + } case schemaBlank: if !create { err = ErrNotFound } else { _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes+workstreamSchema) + if err == nil { + err = migrateWorkstreamWakes(ctx, tx) + } } } } diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go index 07ad6cbf36..9aec4f95d4 100644 --- a/internal/workspace/workstreams.go +++ b/internal/workspace/workstreams.go @@ -241,3 +241,32 @@ var workstreamSchemaReads = []string{ "SELECT id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,end_reason,granted_at,ended_at FROM workstream_goaheads LIMIT 0", "SELECT workstream_id,version,updated_at,updated_by,milestones,waves FROM workstream_plans LIMIT 0", } + +// WorkstreamWakeSchemaVersion keeps admission reads tied to this additive step. +const WorkstreamWakeSchemaVersion = 4 + +// Schema 4 is additive; its tables can be renumbered without changing schema 3. +const workstreamWakeSchema = ` +CREATE TABLE workstream_wakes ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + event_key TEXT NOT NULL, event_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('running','settled','failed')), + summary TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', cost_usd REAL, + started_at INTEGER NOT NULL, settled_at INTEGER, dismissed_at INTEGER, + UNIQUE(workstream_id,event_key) +); +CREATE INDEX workstream_wake_list ON workstream_wakes(workstream_id,started_at); +CREATE TABLE workstream_wake_items ( + id TEXT PRIMARY KEY, wake_id TEXT NOT NULL REFERENCES workstream_wakes(id) ON DELETE CASCADE, + position INTEGER NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('move','draft','page','decision','question')), + state TEXT NOT NULL CHECK(state IN ('applied','undone','proposed','dismissed')), + why TEXT NOT NULL DEFAULT '', item_json TEXT NOT NULL, undo_json TEXT, + note TEXT NOT NULL DEFAULT '', applied_at INTEGER, undone_at INTEGER +); +CREATE INDEX workstream_wake_item_list ON workstream_wake_items(wake_id,position); +` + +func migrateWorkstreamWakes(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, workstreamWakeSchema+workstreamCollectionKeep) + return err +} diff --git a/internal/workspace/workstreams_fields_v02_test.go b/internal/workspace/workstreams_fields_v02_test.go index 3311c87639..6bf939c62a 100644 --- a/internal/workspace/workstreams_fields_v02_test.go +++ b/internal/workspace/workstreams_fields_v02_test.go @@ -14,6 +14,7 @@ func TestWorkstreamSchemaThreeRequiresGoAheadRecordColumns(t *testing.T) { path := filepath.Join(t.TempDir(), "obsolete.db") s := openTestStore(t, path) createTestCollection(t, s, "Schema three") + workstreamSchemaThreeFixture(t, s) if userVersion(t, s) != 3 { t.Fatal("fixture is not schema 3") } diff --git a/internal/workspace/workstreams_test.go b/internal/workspace/workstreams_test.go index 685c262755..3b1feda36e 100644 --- a/internal/workspace/workstreams_test.go +++ b/internal/workspace/workstreams_test.go @@ -88,19 +88,18 @@ func TestPartAWorkstreamMembershipOwnershipIsEnforcedInSQLite(t *testing.T) { } } -// Part A (a): schema 1 upgrades straight to 3, preserving legacy timestamps as -// unknown; schema 3 checks the new ref's factory on direct SQL writes too. -func TestPartASchemaOneMigratesStraightToThree(t *testing.T) { +// Part A (a): schema 1 upgrades to the current schema, preserving unknown times. +func TestPartASchemaOneMigratesStraightToCurrent(t *testing.T) { s := openTestStore(t, writeLegacyStore(t)) ctx := context.Background() if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "11", Factory: "f"}); err != nil { t.Fatal(err) } - if userVersion(t, s) != 3 { - t.Fatal("did not migrate directly to schema 3") + if userVersion(t, s) != 4 { + t.Fatal("did not migrate directly to schema 4") } var count int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 7 { + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 9 { t.Fatalf("workstream tables: %d %v", count, err) } var created, added int64 diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go index c3171dd3b7..920787dba1 100644 --- a/internal/workstream/pages.go +++ b/internal/workstream/pages.go @@ -259,14 +259,7 @@ func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawM if err := checkDoc(doc); err != nil { return err } - at := s.now().UnixMilli() - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,doc=?,version=version+1,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, string(doc), at, by, source, id); err != nil { - return err - } - if err := saveVersion(ctx, tx, id); err != nil { - return err - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + if err := workstreamSavePageIn(ctx, tx, p, title, doc, by, source, s.now().UnixMilli()); err != nil { return err } out, _, err = pageIn(ctx, tx, id) @@ -279,6 +272,23 @@ func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawM return out, c, nil } +// workstreamSavePageIn shares history and attribution with atomic wake writes. +func workstreamSavePageIn(ctx context.Context, tx *sql.Tx, p Page, title string, doc json.RawMessage, by string, source any, at int64) error { + if err := workstreamPageWriteAllowed(ctx, tx, p, by); err != nil { + return err + } + if err := checkDoc(doc); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,doc=?,version=version+1,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, string(doc), at, by, source, p.ID); err != nil { + return err + } + if err := saveVersion(ctx, tx, p.ID); err != nil { + return err + } + return touch(ctx, tx, p.WorkstreamID, at) +} + // placePage validates every target before changing sibling positions. It packs // positions after a move so a later insert never relies on gaps or old offsets. func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, last bool, at int64, by string, source any) (bool, error) { @@ -406,40 +416,7 @@ func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, err var out Page var c Change err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, p.By); err != nil { - return err - } - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_pages WHERE workstream_id=?", p.WorkstreamID).Scan(&count); err != nil { - return err - } - if count >= PagesMax { - return fmt.Errorf("%w: a workstream holds at most %d pages, including deleted pages", ErrLimit, PagesMax) - } - id, err := s.newID() - if err != nil { - return err - } - at := s.now().UnixMilli() - // A temporary root position distinct from every packed position makes the - // common placement helper handle creation and movement identically. - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc) VALUES (?,?,?,?,?,2147483647,1,?,?,?,?)`, id, p.WorkstreamID, p.Title, 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, p.By, source); err != nil { - return err - } - if err := saveVersion(ctx, tx, id); err != nil { - return err - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - out, _, err = pageIn(ctx, tx, id) + out, err = s.workstreamCreatePageIn(ctx, tx, p, source) c = changed(p.WorkstreamID, PartPages) return err }) @@ -448,6 +425,43 @@ func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, err } return out, c, nil } + +func (s *Store) workstreamCreatePageIn(ctx context.Context, tx *sql.Tx, p PageCreate, source any) (Page, error) { + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, p.By); err != nil { + return Page{}, err + } + var count int + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_pages WHERE workstream_id=?", p.WorkstreamID).Scan(&count); err != nil { + return Page{}, err + } + if count >= PagesMax { + return Page{}, fmt.Errorf("%w: a workstream holds at most %d pages, including deleted pages", ErrLimit, PagesMax) + } + id, err := s.newID() + if err != nil { + return Page{}, err + } + at := s.now().UnixMilli() + // A temporary root position lets the common placement path pack every sibling. + 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 Page{}, err + } + page, _, err := livePageIn(ctx, tx, id) + if err != nil { + return Page{}, err + } + if _, err := placePage(ctx, tx, page, p.ParentID, p.AfterID, p.AfterID == "", at, p.By, source); err != nil { + return Page{}, err + } + if err := saveVersion(ctx, tx, id); err != nil { + return Page{}, err + } + if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { + return Page{}, err + } + out, _, err := pageIn(ctx, tx, id) + return out, err +} func (s *Store) UpdatePage(ctx context.Context, id string, p PagePatch) (PageNode, Change, error) { if p.Title != nil { if err := validTitle(*p.Title); err != nil { diff --git a/internal/workstream/wake_actions.go b/internal/workstream/wake_actions.go new file mode 100644 index 0000000000..4d674e2413 --- /dev/null +++ b/internal/workstream/wake_actions.go @@ -0,0 +1,454 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "reflect" + "strconv" + + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +func workstreamWakeWave(p Plan, n int) int { + if n == 0 && len(p.Waves) > 0 { + return len(p.Waves) - 1 + } + for i, w := range p.Waves { + if w.N == n { + return i + } + } + return -1 +} +func workstreamWakeTarget(p Plan, item WakeItem) (int, int) { + for wi, w := range p.Waves { + for pos, v := range w.Items { + if item.Factory != "" && v.Kind == "job" && v.Factory == item.Factory && v.Job == item.Job { + return wi, pos + } + if item.DraftID != "" && v.Kind == "draft" && v.DraftID == item.DraftID { + return wi, pos + } + } + } + return -1, -1 +} +func (s *Store) workstreamWakeSmallIn(ctx context.Context, tx *sql.Tx, w Workstream, item WakeItem) (bool, error) { + switch item.Kind { + case "page": + return true, nil + case "draft", "move": + p, err := planIn(ctx, tx, w.ID) + if err != nil { + return false, err + } + target := workstreamWakeWave(p, item.Wave) + if item.Kind == "draft" { + budget := item.BudgetUSD + if budget == nil || *budget <= 0 || *budget >= 5 { + return false, nil + } + if len(p.Waves) == 0 { + return item.Wave == 0 || item.Wave == 1, nil + } + return target >= 0 && !p.Waves[target].AutoStart, nil + } + from, _ := workstreamWakeTarget(p, item) + if target < 0 || from < 0 || target == from || p.Waves[target].AutoStart { + return false, nil + } + var granted bool + err = tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_goaheads WHERE workstream_id=? AND scope='wave' AND wave=? AND state='active' AND ends_at>?)", w.ID, item.Wave, s.now().UnixMilli()).Scan(&granted) + return !granted, err + } + return false, nil +} + +func workstreamWakeSource(w Workstream, event WakeEvent) *Source { + for _, job := range w.Jobs() { + if event.Kind != "morning" && job.Factory == event.Factory && job.Number == event.Job { + return &Source{Kind: "factory_job", Factory: job.Factory, ID: strconv.Itoa(job.Number), Label: event.Label} + } + } + return &Source{Kind: "chat", ID: w.AgentID, Label: event.Label} +} + +func (s *Store) workstreamWakeApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) (ChangedPart, error) { + var part ChangedPart + var err error + switch item.Kind { + case "move", "draft": + part = PartPlan + err = s.workstreamWakePlanApplyIn(ctx, tx, w, event, item) + case "page": + part = PartPages + err = s.workstreamWakePageApplyIn(ctx, tx, w, event, item) + case "decision", "question": + part = PartRecords + err = s.workstreamWakeRecordApplyIn(ctx, tx, w, event, item) + default: + return "", ErrConflict + } + if err != nil { + return "", err + } + item.State = "applied" + item.Note = "" + item.AppliedAt = instant(s.now().UnixMilli()) + item.Text = workstreamWakeItemText(*item) + return part, nil +} + +func (s *Store) workstreamWakeSavePlanIn(ctx context.Context, tx *sql.Tx, p Plan) error { + if len(p.Waves) > PlanWavesMax { + return ErrLimit + } + count := 0 + for _, w := range p.Waves { + count += len(w.Items) + } + if count > PlanItemsMax { + return ErrLimit + } + milestones, err := json.Marshal(p.Milestones) + if err != nil { + return err + } + waves, err := json.Marshal(p.Waves) + if err != nil { + return err + } + if len(milestones)+len(waves) > PlanEncodedMax { + return ErrLimit + } + _, err = tx.ExecContext(ctx, `INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES (?,?,?,'agent',?,?) + ON CONFLICT(workstream_id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,updated_by='agent',milestones=excluded.milestones,waves=excluded.waves`, p.WorkstreamID, p.Version+1, s.now().UnixMilli(), string(milestones), string(waves)) + return err +} + +func (s *Store) workstreamWakePlanApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { + p, err := planIn(ctx, tx, w.ID) + if err != nil { + return err + } + target := workstreamWakeWave(p, item.Wave) + if item.Kind == "move" { + from, pos := workstreamWakeTarget(p, *item) + if target < 0 || from < 0 || from == target { + return ErrConflict + } + moved := p.Waves[from].Items[pos] + item.Undo = workstreamWakeUndo{Wave: p.Waves[from].N, Position: pos, PlanItem: moved} + p.Waves[from].Items = append(p.Waves[from].Items[:pos], p.Waves[from].Items[pos+1:]...) + p.Waves[target].Items = append(p.Waves[target].Items, moved) + } else { + if len(p.Waves) == 0 && (item.Wave == 0 || item.Wave == 1) { + p.Waves = append(p.Waves, Wave{N: 1, Items: []PlanItem{}}) + target = 0 + item.Undo.CreatedWave = true + wave := p.Waves[target] + item.Undo.CreatedWaveImage = &wave + } + if target < 0 { + return ErrConflict + } + id, err := s.newID() + if err != nil { + return err + } + item.DraftID = "draft_" + id + item.Wave = p.Waves[target].N + v := PlanItem{Kind: "draft", DraftID: item.DraftID, Title: item.Proposal.Title, Repo: item.Proposal.Repo, Text: item.Proposal.Text, Size: item.Proposal.Size, Source: workstreamWakeSource(w, event)} + if item.BudgetUSD != nil { + v.BudgetUSD = *item.BudgetUSD + } + item.Undo.PlanItem = v + p.Waves[target].Items = append(p.Waves[target].Items, v) + } + return s.workstreamWakeSavePlanIn(ctx, tx, p) +} + +func (s *Store) workstreamWakePageApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { + source := workstreamWakeSource(w, event) + if source.Label == "" { + source.Label = "Workstream wake" + } + who := Attribution{By: "agent", From: source} + doc := pagedoc.FromMarkdown(item.Proposal.Markdown) + if item.PageID == "" { + ask, raw, err := workstreamPreparePageCreate(PageCreate{WorkstreamID: w.ID, Title: item.Proposal.Title, Owner: "agent", ReadByJobs: ReadByJobsDefault(item.Proposal.Title), Doc: doc, Attribution: who}) + if err != nil { + return err + } + page, err := s.workstreamCreatePageIn(ctx, tx, ask, raw) + if err != nil { + return err + } + item.PageID = page.ID + item.Undo = workstreamWakeUndo{Created: true, Version: page.Version, Page: &page.PageNode} + return nil + } + page, _, err := livePageIn(ctx, tx, item.PageID) + if errors.Is(err, ErrNotFound) { + return ErrConflict + } + if err != nil { + return err + } + if page.WorkstreamID != w.ID || page.Owner != "agent" { + return ErrConflict + } + previousVersion := page.Version + raw, err := sourceJSON(source) + if err != nil { + return err + } + if err := workstreamSavePageIn(ctx, tx, page, page.Title, doc, "agent", raw, s.now().UnixMilli()); err != nil { + return err + } + item.Proposal.Title = page.Title + page, _, err = livePageIn(ctx, tx, page.ID) + if err != nil { + return err + } + item.Undo = workstreamWakeUndo{Version: page.Version, PreviousVersion: previousVersion, Page: &page.PageNode} + return nil +} + +func (s *Store) workstreamWakeRecordApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { + if err := workstreamRecordLimitIn(ctx, tx, w.ID); err != nil { + return err + } + source, err := sourceJSON(workstreamWakeSource(w, event)) + if err != nil { + return err + } + id, err := s.newID() + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at) VALUES (?,?,?,?,'kept','agent',?,?,?)", id, w.ID, item.Kind, item.Proposal.Text, source, s.now().UnixMilli(), s.now().UnixMilli()) + if err == nil { + item.RecordID = id + } + return err +} + +func (s *Store) workstreamWakeUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) (ChangedPart, error) { + var part ChangedPart + var err error + switch item.Kind { + case "move", "draft": + part = PartPlan + err = s.workstreamWakePlanUndoIn(ctx, tx, w, item) + case "page": + part = PartPages + err = s.workstreamWakePageUndoIn(ctx, tx, w, event, item) + case "decision", "question": + part = PartRecords + r, readErr := recordIn(ctx, tx, item.RecordID) + if errors.Is(readErr, ErrNotFound) { + return "", ErrConflict + } + if readErr != nil { + return "", readErr + } + if r.WorkstreamID != w.ID || r.State != "kept" || r.SupersededBy != "" { + return "", ErrConflict + } + _, err = tx.ExecContext(ctx, "UPDATE workstream_records SET state='dropped',decided_at=? WHERE id=?", s.now().UnixMilli(), r.ID) + default: + return "", ErrConflict + } + if err != nil { + return "", err + } + item.State = "undone" + item.Note = "" + item.UndoneAt = instant(s.now().UnixMilli()) + item.Text = workstreamWakeItemText(*item) + return part, nil +} +func (s *Store) workstreamWakePlanUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, item *WakeItem) error { + p, err := planIn(ctx, tx, w.ID) + if err != nil { + return err + } + from, pos := workstreamWakeTarget(p, *item) + if from < 0 { + return ErrConflict + } + if p.Waves[from].N != item.Wave { + return ErrConflict + } + if item.Kind == "draft" && !reflect.DeepEqual(p.Waves[from].Items[pos], item.Undo.PlanItem) { + return ErrConflict + } + target := -1 + if item.Kind == "move" { + target = workstreamWakeWave(p, item.Undo.Wave) + if target < 0 { + return ErrConflict + } + } + v := p.Waves[from].Items[pos] + p.Waves[from].Items = append(p.Waves[from].Items[:pos], p.Waves[from].Items[pos+1:]...) + if item.Kind == "move" { + items := p.Waves[target].Items + at := min(item.Undo.Position, len(items)) + items = append(items, PlanItem{}) + copy(items[at+1:], items[at:]) + items[at] = v + p.Waves[target].Items = items + } else if item.Undo.CreatedWave && item.Undo.CreatedWaveImage != nil && len(p.Waves[from].Items) == 0 && reflect.DeepEqual(p.Waves[from], *item.Undo.CreatedWaveImage) { + p.Waves = append(p.Waves[:from], p.Waves[from+1:]...) + } + return s.workstreamWakeSavePlanIn(ctx, tx, p) +} +func (s *Store) workstreamWakePageUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { + p, _, err := livePageIn(ctx, tx, item.PageID) + if errors.Is(err, ErrNotFound) { + return ErrConflict + } + if err != nil { + return err + } + if p.WorkstreamID != w.ID || p.Owner != "agent" || p.Version != item.Undo.Version || item.Undo.Page == nil { + return ErrConflict + } + // Sibling compaction or reordering alone must not block undo. + postImage := *item.Undo.Page + postImage.Position = p.Position + if !reflect.DeepEqual(p.PageNode, postImage) { + return ErrConflict + } + if item.Undo.Created { + // A newly attached child is a later change; never delete another person's page. + var children bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_pages WHERE parent_id=? AND deleted_at IS NULL)", p.ID).Scan(&children); err != nil { + return err + } + if children { + return ErrConflict + } + if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET deleted_at=?,deleted_root=? WHERE id=?", s.now().UnixMilli(), p.ID, p.ID); err != nil { + return err + } + _, err = tx.ExecContext(ctx, "UPDATE workstream_pages SET position=position-1 WHERE workstream_id=? AND parent_id IS ? AND position>? AND deleted_at IS NULL", w.ID, nullString(p.ParentID), p.Position) + return err + } + // Wake updates never change titles, so undo restores only the document. + var doc string + if err := tx.QueryRowContext(ctx, "SELECT doc FROM workstream_page_versions WHERE page_id=? AND version=?", p.ID, item.Undo.PreviousVersion).Scan(&doc); err != nil { + return found(err) + } + source := workstreamWakeSource(w, event) + if source.Label == "" { + source.Label = "Workstream wake" + } + raw, err := sourceJSON(source) + if err != nil { + return err + } + return workstreamSavePageIn(ctx, tx, p, p.Title, json.RawMessage(doc), "agent", raw, s.now().UnixMilli()) +} + +// ActOnWake applies one item atomically or all eligible items best effort. +// Undo walks backwards so dependent changes on the same target unwind in order. +func (s *Store) ActOnWake(ctx context.Context, id, itemID, action string) (WakeRun, Change, error) { + var out WakeRun + var change Change + if action != "apply" && action != "undo" && action != "not_now" { + return out, change, invalid("unknown wake action") + } + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + run, err := workstreamWakeIn(ctx, tx, id) + if err != nil { + return err + } + w, err := getIn(ctx, tx, run.WorkstreamID) + if err != nil { + return err + } + if itemID != "" { + found := false + for _, item := range run.Items { + found = found || item.ID == itemID + } + if !found { + return ErrNotFound + } + } + if action != "not_now" && w.Stage == "done" { + return ErrConflict + } + parts := map[ChangedPart]bool{} + changed := false + for step := range run.Items { + pos := step + if action == "undo" { + pos = len(run.Items) - 1 - step + } + item := run.Items[pos] + if itemID != "" && item.ID != itemID { + continue + } + source := "proposed" + if action == "undo" { + source = "applied" + } + if item.State != source { + continue + } + before := item + if action == "not_now" { + item.State = "dismissed" + run.DismissedAt = instant(s.now().UnixMilli()) + } else { + var part ChangedPart + if action == "apply" { + part, err = s.workstreamWakeApplyIn(ctx, tx, w, run.Event, &item) + } else { + part, err = s.workstreamWakeUndoIn(ctx, tx, w, run.Event, &item) + } + if errors.Is(err, ErrConflict) && itemID == "" { + item = before + item.Note = "Its target is gone; not applied." + if action == "undo" { + item.Note = "Changed since; not undone." + } + err = nil + } else if err != nil { + return err + } else { + parts[part] = true + } + } + if item == before { + continue + } + if err := workstreamSaveWakeItemIn(ctx, tx, id, pos, item); err != nil { + return err + } + run.Items[pos] = item + changed = true + } + if changed { + if _, err := tx.ExecContext(ctx, "UPDATE workstream_wakes SET dismissed_at=? WHERE id=?", workstreamWakeTime(run.DismissedAt), id); err != nil { + return err + } + if err := touch(ctx, tx, w.ID, s.now().UnixMilli()); err != nil { + return err + } + change = workstreamWakeChange(w.ID, parts) + } + out = run + return nil + }) + if err != nil { + return WakeRun{}, Change{}, err + } + return out, change, nil +} diff --git a/internal/workstream/wake_answer.go b/internal/workstream/wake_answer.go new file mode 100644 index 0000000000..7b74bcdf46 --- /dev/null +++ b/internal/workstream/wake_answer.go @@ -0,0 +1,132 @@ +package workstream + +import ( + "encoding/json" + "errors" + "math" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +const WakeItemsMax = 10 + +// WakeProposal contains only model suggestions; result references are engine owned. +type WakeProposal struct { + Kind string `json:"kind"` + Factory string `json:"factory,omitempty"` + Job int `json:"job,omitempty"` + DraftID string `json:"draft_id,omitempty"` + ToWave int `json:"to_wave,omitempty"` + Wave int `json:"wave,omitempty"` + Title string `json:"title,omitempty"` + Repo string `json:"repo,omitempty"` + Text string `json:"text,omitempty"` + Size string `json:"size,omitempty"` + BudgetUSD *float64 `json:"budget_usd,omitempty"` + PageID string `json:"page_id,omitempty"` + Markdown string `json:"markdown,omitempty"` + Why string `json:"why,omitempty"` +} + +type WakeAnswer struct { + Summary string + Items []WakeProposal +} + +// ParseWakeAnswer reads the first object, including an object inside a JSON fence. +// Invalid items are dropped independently so one bad suggestion cannot hide good ones. +func ParseWakeAnswer(text string) (WakeAnswer, error) { + start := strings.IndexByte(text, '{') + if start < 0 { + return WakeAnswer{}, errors.New("no answer object") + } + var raw struct { + Summary *string `json:"summary"` + Items []json.RawMessage `json:"items"` + } + if err := json.NewDecoder(strings.NewReader(text[start:])).Decode(&raw); err != nil { + return WakeAnswer{}, err + } + if raw.Summary == nil || raw.Items == nil { + return WakeAnswer{}, errors.New("answer needs summary and items") + } + out := WakeAnswer{Summary: workstreamWakeClip(*raw.Summary, 2000), Items: []WakeProposal{}} + for _, item := range raw.Items { + var p WakeProposal + if json.Unmarshal(item, &p) != nil { + continue + } + p = workstreamWakeProposal(p) + if !workstreamWakeValid(p) { + continue + } + p.Why = workstreamWakeClip(strings.Join(strings.Fields(p.Why), " "), 200) + out.Items = append(out.Items, p) + if len(out.Items) == WakeItemsMax { + break + } + } + return out, nil +} + +func workstreamWakeClip(text string, limit int) string { + if !utf8.ValidString(text) { + text = strings.ToValidUTF8(text, "") + } + if len(text) <= limit { + return text + } + for limit > 0 && !utf8.RuneStart(text[limit]) { + limit-- + } + return text[:limit] +} + +func workstreamWakeValid(p WakeProposal) bool { + switch p.Kind { + case "move": + job := p.Factory != "" && p.Job > 0 && p.DraftID == "" && lineWithin(p.Factory, 4096) + draft := p.DraftID != "" && p.Factory == "" && p.Job == 0 && lineWithin(p.DraftID, 4096) + return p.ToWave > 0 && (job || draft) + case "draft": + return workstreamWakeDraftValid(p) + case "page": + return (p.PageID != "" || validTitle(p.Title) == nil) && (p.Title == "" || validTitle(p.Title) == nil) && lineWithin(p.PageID, 4096) && textWithin(p.Markdown, DocMax) && len(pagedoc.FromMarkdown(p.Markdown)) <= DocMax + case "decision", "question": + return recordText(p.Kind, p.Text) == nil + } + return false +} + +// Fields belonging to another kind are ignored, never result identities or undo targets. +func workstreamWakeProposal(p WakeProposal) WakeProposal { + base := WakeProposal{Kind: p.Kind, Why: p.Why} + switch p.Kind { + case "move": + base.Factory, base.Job, base.DraftID, base.ToWave = p.Factory, p.Job, p.DraftID, p.ToWave + case "draft": + base.Title, base.Repo, base.Text, base.Size, base.BudgetUSD, base.Wave = p.Title, p.Repo, p.Text, p.Size, p.BudgetUSD, p.Wave + case "page": + base.PageID, base.Title, base.Markdown = p.PageID, p.Title, p.Markdown + case "decision", "question": + base.Text = p.Text + } + return base +} + +func workstreamWakeDraftValid(p WakeProposal) bool { + if validTitle(p.Title) != nil || !lineWithin(p.Repo, 200) || !textWithin(p.Text, PlanDraftTextMax) || p.Wave < 0 { + return false + } + if p.Size != "" && p.Size != "S" && p.Size != "M" && p.Size != "L" { + return false + } + if p.BudgetUSD == nil { + return true + } + b := *p.BudgetUSD + return !math.IsNaN(b) && !math.IsInf(b, 0) && b > 0 && b <= workspace.WorkstreamGoAheadCapMax +} diff --git a/internal/workstream/wake_answer_test.go b/internal/workstream/wake_answer_test.go new file mode 100644 index 0000000000..77e0618e9b --- /dev/null +++ b/internal/workstream/wake_answer_test.go @@ -0,0 +1,44 @@ +package workstream + +import ( + "strings" + "testing" +) + +func TestWakeC_ParseAnswer(t *testing.T) { + answer, err := ParseWakeAnswer("Words ```json\n" + `{"summary":"Next steps","ignored":true,"items":[{"kind":"move","factory":"f","job":1,"to_wave":3,"why":"one\nline"},{"kind":"draft","title":"Follow up","budget_usd":3},{"kind":"draft","title":"Invalid","budget_usd":0},{"kind":"page","page_id":"p","markdown":"Updated"},{"kind":"decision","text":"Keep this"},{"kind":"unknown"}]}` + "\n```") + if err != nil || len(answer.Items) != 4 || answer.Items[0].Why != "one line" { + t.Fatalf("answer=%+v err=%v", answer, err) + } + if _, err := ParseWakeAnswer("no answer"); err == nil { + t.Fatal("unreadable answer accepted") + } + if _, err := ParseWakeAnswer(`{"summary":"oops"`); err == nil { + t.Fatal("partial answer accepted") + } +} + +func TestWakeC_ParseBoundsAndOrder(t *testing.T) { + text := `{"summary":"` + strings.Repeat("é", 1100) + `","items":[` + for i := 0; i < 12; i++ { + if i > 0 { + text += "," + } + text += `{"kind":"question","text":"What next?","why":"` + strings.Repeat("é", 110) + `"}` + } + answer, err := ParseWakeAnswer(text + `],"unknown":123}`) + if err != nil || len(answer.Items) != 10 || len(answer.Summary) > 2000 || len(answer.Items[0].Why) > 200 { + t.Fatalf("bounds: %+v %v", answer, err) + } +} + +func TestWakeE_AnswerFieldsCannotChooseAnotherKindsUndoTarget(t *testing.T) { + answer, err := ParseWakeAnswer(`{"summary":"Next","items":[{"kind":"draft","title":"Follow up","factory":"f","job":7,"draft_id":"existing","page_id":"person","budget_usd":3},{"kind":"decision","text":"Review","page_id":"person"}]}`) + if err != nil || len(answer.Items) != 2 { + t.Fatal(answer, err) + } + p := answer.Items[0] + if p.Factory != "" || p.Job != 0 || p.DraftID != "" || p.PageID != "" || answer.Items[1].PageID != "" { + t.Fatal("foreign kind fields survived", answer) + } +} diff --git a/internal/workstream/wakes.go b/internal/workstream/wakes.go new file mode 100644 index 0000000000..0f1fb6644f --- /dev/null +++ b/internal/workstream/wakes.go @@ -0,0 +1,500 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const ( + PartWakes ChangedPart = "wakes" + WakeWorkstreamDayMax = 24 + WakeHomeDayMax = 200 + WakeAbandonedAfter = 15 * time.Minute + WakeStoppedError = "Stopped before it finished." + WakeAnswerError = "The agent's answer could not be read." +) + +var ErrWakeCap = errors.New("workstream wake daily cap reached") + +type WakeEvent struct { + Kind string `json:"kind"` + Factory string `json:"factory,omitempty"` + Job int `json:"job,omitempty"` + Label string `json:"label,omitempty"` + Detail string `json:"detail,omitempty"` + At time.Time `json:"at"` +} + +type WakeItem struct { + ID string + Kind string + State string + Text string + Why string + Factory string + Job int + DraftID string + Wave int + PageID string + RecordID string + BudgetUSD *float64 + Note string + AppliedAt time.Time + UndoneAt time.Time + Proposal WakeProposal + Undo workstreamWakeUndo +} + +type WakeRun struct { + ID string + WorkstreamID string + Event WakeEvent + State string + StartedAt time.Time + SettledAt time.Time + CostUSD *float64 + Summary string + Error string + DismissedAt time.Time + Items []WakeItem +} + +type workstreamWakeUndo struct { + Wave int + Position int + Version int + PreviousVersion int + Created bool + CreatedWave bool + CreatedWaveImage *Wave + PlanItem PlanItem + Page *PageNode +} + +// BeginWake claims the event before any model call, under the stage and cost guards. +// A losing claimant returns false, including another process using the same home. +func (s *Store) BeginWake(ctx context.Context, id, key string, event WakeEvent) (WakeRun, bool, error) { + var out WakeRun + if key == "" || !lineWithin(event.Label, 200) || !textWithin(event.Detail, 4000) { + return out, false, invalid("invalid wake event") + } + switch event.Kind { + case "morning", "finished", "merged", "failed", "needs_you": + default: + return out, false, invalid("unknown wake kind") + } + woke := false + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + if event.Kind != "morning" { + if event.Factory == "" || event.Job <= 0 { + return invalid("job event needs factory and positive job") + } + var owner string + err := tx.QueryRowContext(ctx, "SELECT workstream_id FROM workstream_jobs WHERE factory=? AND job=?", event.Factory, strconv.Itoa(event.Job)).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if id != "" && id != owner { + return nil + } + id = owner + } + w, err := getIn(ctx, tx, id) + if errors.Is(err, ErrNotFound) { + return nil + } + if err != nil { + return err + } + if w.Stage == "done" || w.Autonomy == "off" { + return nil + } + if event.Kind == "morning" && (!w.Wake.Morning || event.At.Before(w.CreatedAt)) { + return nil + } + var exists bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_wakes WHERE workstream_id=? AND event_key=?)", id, key).Scan(&exists); err != nil { + return err + } + if exists { + return nil + } + now := s.now() + local := now.In(time.Local) + day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local) + var home, stream int + if err := tx.QueryRowContext(ctx, "SELECT count(*),coalesce(sum(workstream_id=?),0) FROM workstream_wakes WHERE started_at>=? AND started_at= WakeHomeDayMax { + return fmt.Errorf("%w: at most %d per home per local day", ErrWakeCap, WakeHomeDayMax) + } + if stream >= WakeWorkstreamDayMax { + return fmt.Errorf("%w: at most %d per local day for %s", ErrWakeCap, WakeWorkstreamDayMax, id) + } + wakeID, err := s.newID() + if err != nil { + return err + } + raw, err := json.Marshal(event) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,started_at) VALUES (?,?,?,?,'running',?)", wakeID, id, key, string(raw), now.UnixMilli()); err != nil { + return err + } + out = WakeRun{ID: wakeID, WorkstreamID: id, Event: event, State: "running", StartedAt: instant(now.UnixMilli()), Items: []WakeItem{}} + woke = true + return nil + }) + return out, woke, err +} + +func workstreamWakeIn(ctx context.Context, tx *sql.Tx, id string) (WakeRun, error) { + out := WakeRun{Items: []WakeItem{}} + var raw string + var started int64 + var settled, dismissed sql.NullInt64 + var cost sql.NullFloat64 + err := tx.QueryRowContext(ctx, "SELECT id,workstream_id,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at FROM workstream_wakes WHERE id=?", id).Scan(&out.ID, &out.WorkstreamID, &raw, &out.State, &out.Summary, &out.Error, &cost, &started, &settled, &dismissed) + if err != nil { + return out, found(err) + } + if err := json.Unmarshal([]byte(raw), &out.Event); err != nil { + return out, err + } + out.StartedAt, out.SettledAt, out.DismissedAt = instant(started), optionalInstant(settled), optionalInstant(dismissed) + if cost.Valid { + out.CostUSD = &cost.Float64 + } + rows, err := tx.QueryContext(ctx, "SELECT item_json FROM workstream_wake_items WHERE wake_id=? ORDER BY position", id) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return out, err + } + var item WakeItem + if err := json.Unmarshal([]byte(raw), &item); err != nil { + return out, err + } + out.Items = append(out.Items, item) + } + return out, rows.Err() +} + +func workstreamSaveWakeItemIn(ctx context.Context, tx *sql.Tx, wake string, pos int, item WakeItem) error { + raw, err := json.Marshal(item) + if err != nil { + return err + } + undo, err := json.Marshal(item.Undo) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET position=excluded.position,state=excluded.state,item_json=excluded.item_json,undo_json=excluded.undo_json,note=excluded.note,applied_at=excluded.applied_at,undone_at=excluded.undone_at`, item.ID, wake, pos, item.Kind, item.State, item.Why, string(raw), string(undo), item.Note, workstreamWakeTime(item.AppliedAt), workstreamWakeTime(item.UndoneAt)) + return err +} +func workstreamWakeTime(at time.Time) any { + if at.IsZero() { + return nil + } + return at.UnixMilli() +} + +// WakeRun lets the worker recheck a durable claim after waiting in its queue. +func (s *Store) WakeRun(ctx context.Context, id string) (WakeRun, error) { + var out WakeRun + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < workspace.WorkstreamWakeSchemaVersion { + return ErrNotFound + } + var err error + out, err = workstreamWakeIn(ctx, tx, id) + return err + }) + return out, err +} + +func (s *Store) Wakes(ctx context.Context, id string, limit int) ([]WakeRun, error) { + if limit == 0 { + limit = 20 + } + if limit < 1 || limit > 100 { + return nil, invalid("wake limit must be 1–100") + } + out := []WakeRun{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + if v < workspace.WorkstreamWakeSchemaVersion { + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT id FROM workstream_wakes WHERE workstream_id=? ORDER BY started_at DESC,rowid DESC LIMIT ?", id, limit) + if err != nil { + return err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + for _, id := range ids { + w, err := workstreamWakeIn(ctx, tx, id) + if err != nil { + return err + } + out = append(out, w) + } + return nil + }) + return out, err +} + +// WakeSpendByAgent preserves unknown conversation spend at the adapter boundary. +func (s *Store) WakeSpendByAgent(ctx context.Context, agent string) (float64, error) { + var cost float64 + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < workspace.WorkstreamWakeSchemaVersion { + return nil + } + return tx.QueryRowContext(ctx, "SELECT coalesce(sum(r.cost_usd),0) FROM workstream_wakes r JOIN workstreams w ON w.id=r.workstream_id WHERE w.agent_id=?", agent).Scan(&cost) + }) + return cost, err +} + +// ExpireWakes does not take a writer or migrate a schema-3 file unless a stale row exists. +func (s *Store) ExpireWakes(ctx context.Context) ([]Change, error) { + ids := []string{} + cutoff := s.now().Add(-WakeAbandonedAfter).UnixMilli() + err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { + if v < workspace.WorkstreamWakeSchemaVersion { + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT DISTINCT workstream_id FROM workstream_wakes WHERE state='running' AND started_at 0 { + if err := touch(ctx, tx, id, s.now().UnixMilli()); err != nil { + return err + } + changes = append(changes, changed(id, PartWakes)) + } + } + return nil + }) + return changes, err +} + +func workstreamWakeChange(id string, parts map[ChangedPart]bool) Change { + c := Change{ID: id, Parts: []ChangedPart{PartWakes}} + for _, p := range []ChangedPart{PartWorkstream, PartPlan, PartPages, PartRecords} { + if parts[p] { + c.Parts = append(c.Parts, p) + } + } + return c +} + +func workstreamWakeCost(cost *float64) *float64 { + if cost == nil || math.IsNaN(*cost) || math.IsInf(*cost, 0) || *cost < 0 { + return nil + } + return cost +} + +func (s *Store) SettleWake(ctx context.Context, id string, answer WakeAnswer, cost *float64, failure string) (WakeRun, Change, error) { + var out WakeRun + var change Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + run, err := workstreamWakeIn(ctx, tx, id) + if err != nil { + return err + } + out = run + if run.State != "running" { + return nil + } + w, err := getIn(ctx, tx, run.WorkstreamID) + if err != nil { + return err + } + parts := map[ChangedPart]bool{PartWorkstream: true} + if failure == "" { + pageTargets := map[string]bool{} + for _, p := range answer.Items { + p = workstreamWakeProposal(p) + if len(run.Items) == WakeItemsMax { + break + } + if !workstreamWakeValid(p) { + continue + } + if p.Kind == "page" && p.PageID != "" { + page, _, err := livePageIn(ctx, tx, p.PageID) + if errors.Is(err, ErrNotFound) { + continue + } + if err != nil { + return err + } + if page.WorkstreamID != w.ID || page.Owner != "agent" { + continue + } + p.Title = page.Title + } + if p.Kind == "page" { + target := "title:" + p.Title + if p.PageID != "" { + target = "id:" + p.PageID + } + if pageTargets[target] { + continue + } + pageTargets[target] = true + } + itemID, err := s.newID() + if err != nil { + return err + } + item := WakeItem{ID: itemID, Kind: p.Kind, State: "proposed", Why: p.Why, Factory: p.Factory, Job: p.Job, DraftID: p.DraftID, Wave: p.Wave, PageID: p.PageID, BudgetUSD: p.BudgetUSD, Proposal: p} + if p.Kind == "move" { + item.Wave = p.ToWave + } + if w.Autonomy == "small" && w.Stage != "done" { + ok, err := s.workstreamWakeSmallIn(ctx, tx, w, item) + if err != nil { + return err + } + if ok { + part, err := s.workstreamWakeApplyIn(ctx, tx, w, run.Event, &item) + if err != nil { + return err + } + parts[part] = true + } + } + item.Text = workstreamWakeItemText(item) + run.Items = append(run.Items, item) + } + sort.SliceStable(run.Items, func(i, j int) bool { return run.Items[i].State == "applied" && run.Items[j].State != "applied" }) + for i, item := range run.Items { + if err := workstreamSaveWakeItemIn(ctx, tx, id, i, item); err != nil { + return err + } + } + } + run.State = "settled" + if failure != "" { + run.State = "failed" + } + run.Summary = workstreamWakeClip(answer.Summary, 2000) + run.Error = failure + run.CostUSD = workstreamWakeCost(cost) + run.SettledAt = instant(s.now().UnixMilli()) + if _, err := tx.ExecContext(ctx, "UPDATE workstream_wakes SET state=?,summary=?,error=?,cost_usd=?,settled_at=? WHERE id=?", run.State, run.Summary, run.Error, run.CostUSD, run.SettledAt.UnixMilli(), id); err != nil { + return err + } + if err := touch(ctx, tx, w.ID, s.now().UnixMilli()); err != nil { + return err + } + out = run + change = workstreamWakeChange(w.ID, parts) + return nil + }) + if err != nil { + return WakeRun{}, Change{}, err + } + return out, change, nil +} + +func workstreamWakeItemText(item WakeItem) string { + applied := item.State == "applied" + switch item.Kind { + case "move": + if !applied { + return "Move the plan item to the proposed wave." + } + target := item.DraftID + if item.Factory != "" { + target = fmt.Sprintf("%s#%d", item.Factory, item.Job) + } + verb := "Move" + if applied { + verb = "Moved" + } + return fmt.Sprintf("%s %s to wave %d.", verb, target, item.Wave) + case "draft": + verb := "Add draft: " + if applied { + verb = "Added draft: " + } + return verb + item.Proposal.Title + "." + case "page": + verb := "Update agent page: " + if applied { + verb = "Updated agent page: " + } + return verb + item.Proposal.Title + "." + default: + verb := "Keep " + if applied { + verb = "Kept " + } + return verb + item.Kind + ": " + item.Proposal.Text + } +} diff --git a/internal/workstream/wakes_test.go b/internal/workstream/wakes_test.go new file mode 100644 index 0000000000..159ef773b1 --- /dev/null +++ b/internal/workstream/wakes_test.go @@ -0,0 +1,434 @@ +package workstream + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +func workstreamWakeForTest(t *testing.T, s *Store, w Workstream, key string, items ...WakeProposal) WakeRun { + t.Helper() + ctx := context.Background() + run, woke, err := s.BeginWake(ctx, w.ID, key, WakeEvent{Kind: "merged", Factory: "f", Job: 7, At: s.now(), Label: "api#7 merged"}) + if err != nil || !woke { + t.Fatalf("begin: %+v %v %v", run, woke, err) + } + run, c, err := s.SettleWake(ctx, run.ID, WakeAnswer{Summary: "Review next steps.", Items: items}, nil, "") + if err != nil || c.ID != w.ID { + t.Fatalf("settle: %+v %+v %v", run, c, err) + } + return run +} +func workstreamWakeMember() workspace.Ref { + return workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"} +} +func workstreamWakeBudget(n float64) *float64 { return &n } + +func TestWakeC_ProposeChangesNothing(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Proposals", workstreamWakeMember()) + before, _ := s.Plan(ctx, w.ID) + pages, _ := s.Pages(ctx, w.ID) + run := workstreamWakeForTest(t, s, w, "c", WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}, WakeProposal{Kind: "draft", Title: "Follow up", BudgetUSD: workstreamWakeBudget(3)}, WakeProposal{Kind: "page", Title: "Where it stands", Markdown: "Review"}, WakeProposal{Kind: "decision", Text: "Review first"}) + if len(run.Items) != 4 { + t.Fatal(run) + } + for _, i := range run.Items { + if i.State != "proposed" { + t.Fatal(i) + } + } + after, _ := s.Plan(ctx, w.ID) + afterPages, _ := s.Pages(ctx, w.ID) + records, _ := s.Records(ctx, w.ID, "", "") + if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(pages, afterPages) || len(records) != 0 { + t.Fatal("propose changed workstream content") + } +} + +func TestWakeD_SmallClassificationAndUndo(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Small", workstreamWakeMember()) + small := "small" + s.Update(ctx, w.ID, Patch{Autonomy: &small}) + page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Where it stands", Owner: "agent"}) + if err != nil { + t.Fatal(err) + } + waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d_before", Title: "Before"}, {Kind: "job", Factory: "f", Job: 7}, {Kind: "draft", DraftID: "d_after", Title: "After"}}}, {N: 2, Items: []PlanItem{}}, {N: 3, AutoStart: true, Items: []PlanItem{}}, {N: 4, Items: []PlanItem{}}} + before, _, err := s.SetPlan(ctx, w.ID, 0, nil, waves, Attribution{}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: w.ID, Scope: "wave", Wave: 4, CapUSD: 20, EndsAt: s.now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + run := workstreamWakeForTest(t, s, w, "d", + WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}, + WakeProposal{Kind: "draft", Title: "Small follow-up", BudgetUSD: workstreamWakeBudget(3), Wave: 2}, + WakeProposal{Kind: "page", PageID: page.ID, Markdown: "Updated"}, + WakeProposal{Kind: "page", Title: "Agent notes", Markdown: "New"}, + WakeProposal{Kind: "draft", Title: "Large follow-up", BudgetUSD: workstreamWakeBudget(7), Wave: 2}, + WakeProposal{Kind: "draft", Title: "Unknown budget", Wave: 2}, + WakeProposal{Kind: "move", DraftID: "d_before", ToWave: 3}, + WakeProposal{Kind: "move", DraftID: "d_after", ToWave: 4}, + WakeProposal{Kind: "decision", Text: "Keep the scope"}, + WakeProposal{Kind: "question", Text: "Ready?"}) + for i, item := range run.Items { + want := "proposed" + if i < 4 { + want = "applied" + } + if item.State != want { + t.Fatalf("%d: %+v", i, item) + } + } + after, _ := s.Plan(ctx, w.ID) + if after.Waves[1].Items[1].Kind != "draft" || after.Waves[1].Items[1].Source.Kind != "factory_job" { + t.Fatal(after) + } + undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil || c.ID != w.ID { + t.Fatalf("undo: %+v %v", c, err) + } + restored, _ := s.Plan(ctx, w.ID) + restoredPage, _ := s.Page(ctx, page.ID, false) + pages, _ := s.Pages(ctx, w.ID) + if !reflect.DeepEqual(restored.Waves, before.Waves) || string(restoredPage.Doc) != emptyDoc || restoredPage.Version != 3 || len(pages) != 2 { + t.Fatalf("restore: %+v %+v %+v", restored, restoredPage, pages) + } + for i := 0; i < 4; i++ { + if undone.Items[i].State != "undone" { + t.Fatal(undone.Items) + } + } + if _, c, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil || c.ID != "" { + t.Fatalf("repeat undo: %+v %v", c, err) + } +} + +func TestWakeD_LaterPageAndPlanChangesRefuseUndo(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Stale", workstreamWakeMember()) + small := "small" + s.Update(ctx, w.ID, Patch{Autonomy: &small}) + p, _, _ := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent"}) + s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 7}}}, {N: 2, Items: []PlanItem{}}, {N: 3, Items: []PlanItem{}}}, Attribution{}) + run := workstreamWakeForTest(t, s, w, "stale", WakeProposal{Kind: "page", PageID: p.ID, Markdown: "Wake"}, WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}) + s.SavePage(ctx, p.ID, 2, []byte(emptyDoc), Attribution{}) + plan, _ := s.Plan(ctx, w.ID) + plan.Waves[2].Items = plan.Waves[1].Items + plan.Waves[1].Items = []PlanItem{} + s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}) + for _, item := range run.Items { + if _, c, err := s.ActOnWake(ctx, run.ID, item.ID, "undo"); !errors.Is(err, ErrConflict) || c.ID != "" { + t.Fatalf("single stale: %+v %v", c, err) + } + } + noted, _, err := s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil { + t.Fatal(err) + } + for _, item := range noted.Items { + if item.State != "applied" || item.Note != "Changed since; not undone." { + t.Fatal(item) + } + } + _, c, err := s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil || c.ID != "" { + t.Fatalf("same notes wrote again: %+v %v", c, err) + } +} + +func TestWakeE_PersonAndDeletedPagesAreDropped(t *testing.T) { + for _, autonomy := range []string{"propose", "small"} { + t.Run(autonomy, func(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Private", workstreamWakeMember()) + s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}) + pages, _ := s.Pages(ctx, w.ID) + person, _ := s.Page(ctx, pages[0].ID, false) + deleted, _, _ := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Deleted", Owner: "agent"}) + s.DeletePage(ctx, deleted.ID) + run := workstreamWakeForTest(t, s, w, "e", WakeProposal{Kind: "page", PageID: person.ID, Markdown: "Changed"}, WakeProposal{Kind: "page", PageID: deleted.ID, Markdown: "Changed"}) + after, _ := s.Page(ctx, person.ID, false) + if len(run.Items) != 0 || !reflect.DeepEqual(person, after) { + t.Fatal("page boundary violated") + } + if _, c, err := s.ActOnWake(ctx, run.ID, "", "apply"); err != nil || c.ID != "" { + t.Fatalf("empty apply: %+v %v", c, err) + } + }) + } +} + +func TestWakeG_ActionsAreAtomicAndIdempotent(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Actions", workstreamWakeMember()) + run := workstreamWakeForTest(t, s, w, "g", WakeProposal{Kind: "decision", Text: "Keep it"}, WakeProposal{Kind: "question", Text: "Ready?"}, WakeProposal{Kind: "move", DraftID: "gone", ToWave: 9}) + applied, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply") + if err != nil || c.ID != w.ID || applied.Items[0].RecordID == "" { + t.Fatalf("apply: %+v %v", applied, err) + } + row, _ := s.Get(ctx, w.ID) + again, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply") + after, _ := s.Get(ctx, w.ID) + if err != nil || c.ID != "" || !reflect.DeepEqual(again, applied) || !row.UpdatedAt.Equal(after.UpdatedAt) { + t.Fatal("repeated apply changed state") + } + all, _, err := s.ActOnWake(ctx, run.ID, "", "apply") + if err != nil || all.Items[1].State != "applied" || all.Items[2].Note != "Its target is gone; not applied." { + t.Fatalf("all: %+v %v", all, err) + } + dismissed, _, err := s.ActOnWake(ctx, run.ID, "", "not_now") + if err != nil || dismissed.DismissedAt.IsZero() || dismissed.Items[2].State != "dismissed" { + t.Fatal(dismissed, err) + } + if _, c, err := s.ActOnWake(ctx, run.ID, "", "not_now"); err != nil || c.ID != "" { + t.Fatal("repeat dismissal changed state") + } + if _, _, err := s.ActOnWake(ctx, "unknown", "", "apply"); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, _, err := s.ActOnWake(ctx, run.ID, "unknown", "apply"); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { + t.Fatal(err) + } + records, _ := s.Records(ctx, w.ID, "", "") + for _, r := range records { + if r.State != "dropped" { + t.Fatal(r) + } + } + s.MarkDone(ctx, w.ID, "") + for _, action := range []string{"apply", "undo"} { + if _, _, err := s.ActOnWake(ctx, run.ID, "", action); !errors.Is(err, ErrConflict) { + t.Fatal(err) + } + } +} + +func TestWakeB_DeduplicationCapsAndAbandonment(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Events", workstreamWakeMember()) + event := WakeEvent{Kind: "finished", Factory: "f", Job: 7, At: *now} + first, woke, err := s.BeginWake(ctx, "", "once", event) + if err != nil || !woke { + t.Fatal(err) + } + if _, woke, err := s.BeginWake(ctx, "", "once", event); err != nil || woke { + t.Fatal("duplicate woke", err) + } + for i := 1; i < WakeWorkstreamDayMax; i++ { + if _, woke, err := s.BeginWake(ctx, w.ID, fmt.Sprint(i), event); err != nil || !woke { + t.Fatal(i, err) + } + } + if _, woke, err := s.BeginWake(ctx, w.ID, "capped", event); !errors.Is(err, ErrWakeCap) || woke { + t.Fatal("cap not enforced", err) + } + *now = now.Add(WakeAbandonedAfter + time.Millisecond) + changes, err := s.ExpireWakes(ctx) + if err != nil || len(changes) != 1 { + t.Fatal(changes, err) + } + runs, _ := s.Wakes(ctx, w.ID, 100) + if len(runs) != WakeWorkstreamDayMax { + t.Fatal(len(runs)) + } + for _, r := range runs { + if r.State != "failed" || r.Error != WakeStoppedError || r.CostUSD != nil { + t.Fatal(r) + } + } + if run, c, err := s.SettleWake(ctx, first.ID, WakeAnswer{}, workstreamWakeBudget(.04), ""); err != nil || c.ID != "" || run.CostUSD != nil { + t.Fatal("late worker overwrote expired wake") + } +} + +func TestWakeI_SchemaThreeWakeListIsReadOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "three.db") + ws, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + s := New(ws, Options{}) + w := createStream(t, s, "Earlier") + if err := ws.Write(context.Background(), func(tx *sql.Tx) error { + _, err := tx.Exec("DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3") + return err + }); err != nil { + t.Fatal(err) + } + ws.Close() + before, _ := os.ReadFile(path) + ws, err = workspace.Open(path) + if err != nil { + t.Fatal(err) + } + s = New(ws, Options{}) + wakes, err := s.Wakes(context.Background(), w.ID, 20) + if err != nil || len(wakes) != 0 { + t.Fatal(wakes, err) + } + if changes, err := s.ExpireWakes(context.Background()); err != nil || len(changes) != 0 { + t.Fatal(changes, err) + } + ws.Close() + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("wake read migrated schema 3") + } +} + +func TestWakeB_HomeCapAndLocalDayReset(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + event := WakeEvent{Kind: "morning", At: *now} + var last Workstream + for i := 0; i < 9; i++ { + last = createStream(t, s, fmt.Sprintf("Stream %d", i)) + s.Update(ctx, last.ID, Patch{Wake: &Wake{Morning: true}}) + n := WakeWorkstreamDayMax + if i == 8 { + n = 8 + } + for j := 0; j < n; j++ { + if _, woke, err := s.BeginWake(ctx, last.ID, fmt.Sprintf("%d/%d", i, j), event); err != nil || !woke { + t.Fatal(i, j, err) + } + } + } + if _, woke, err := s.BeginWake(ctx, last.ID, "home-capped", event); !errors.Is(err, ErrWakeCap) || woke { + t.Fatal("home cap ignored", err) + } + *now = now.In(time.Local).AddDate(0, 0, 1) + event.At = *now + if _, woke, err := s.BeginWake(ctx, last.ID, "next-day", event); err != nil || !woke { + t.Fatal("cap did not reset", err) + } +} + +func TestWakeB_DuplicateAcrossStoreHandles(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "claims.db") + a, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + defer a.Close() + first := New(a, Options{}) + w := createStream(t, first, "Shared", workstreamWakeMember()) + b, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + defer b.Close() + second := New(b, Options{}) + event := WakeEvent{Kind: "failed", Factory: "f", Job: 7, At: time.Now()} + if _, woke, err := first.BeginWake(ctx, w.ID, "same", event); err != nil || !woke { + t.Fatal(err) + } + if _, woke, err := second.BeginWake(ctx, w.ID, "same", event); err != nil || woke { + t.Fatal("second handle won", err) + } + runs, err := second.Wakes(ctx, w.ID, 20) + if err != nil || len(runs) != 1 { + t.Fatal(runs, err) + } +} + +func TestWakeD_ExplicitApplyKeepsRecordsAndAgentSources(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Explicit", workstreamWakeMember()) + run := workstreamWakeForTest(t, s, w, "explicit", WakeProposal{Kind: "draft", Title: "Costly follow-up", BudgetUSD: workstreamWakeBudget(7)}, WakeProposal{Kind: "page", Title: "Spec", Markdown: "Keep these words"}, WakeProposal{Kind: "decision", Text: "The rollout is ready"}) + off := "off" + s.Update(ctx, w.ID, Patch{Autonomy: &off}) + applied, _, err := s.ActOnWake(ctx, run.ID, "", "apply") + if err != nil { + t.Fatal(err) + } + for _, item := range applied.Items { + if item.State != "applied" { + t.Fatal(item) + } + } + plan, _ := s.Plan(ctx, w.ID) + if len(plan.Waves) != 1 || plan.Waves[0].AutoStart || plan.Waves[0].Items[0].Source.Kind != "factory_job" { + t.Fatal(plan) + } + page, _ := s.Page(ctx, applied.Items[1].PageID, false) + if page.Owner != "agent" || page.UpdatedBy != "agent" || page.UpdatedFrom.Factory != "f" || !page.ReadByJobs { + t.Fatal(page) + } + r, err := s.Records(ctx, w.ID, "decision", "kept") + if err != nil || len(r) != 1 || r[0].By != "agent" || r[0].Source.Kind != "factory_job" { + t.Fatal(r, err) + } + if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { + t.Fatal(err) + } + restored, _ := s.Plan(ctx, w.ID) + if len(restored.Waves) != 0 { + t.Fatal("created wave remained", restored) + } + deleted, err := s.Page(ctx, page.ID, false) + if err != nil || deleted.DeletedAt.IsZero() { + t.Fatal("new page was not restorably deleted", deleted, err) + } +} + +func TestWakeG_NamedApplyConflictRollsBackAndAllKeepsNote(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Gone", workstreamWakeMember()) + run := workstreamWakeForTest(t, s, w, "gone", WakeProposal{Kind: "move", DraftID: "missing", ToWave: 7}) + if _, _, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply"); !errors.Is(err, ErrConflict) { + t.Fatal(err) + } + runs, _ := s.Wakes(ctx, w.ID, 20) + if !reflect.DeepEqual(runs[0], run) { + t.Fatal("conflict wrote") + } + noted, _, err := s.ActOnWake(ctx, run.ID, "", "apply") + if err != nil || noted.Items[0].State != "proposed" || noted.Items[0].Note != "Its target is gone; not applied." { + t.Fatal(noted, err) + } +} + +func TestWakeE_DraftUndoCannotTargetAMemberJob(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Identities", workstreamWakeMember()) + if _, _, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}, Attribution{}); err != nil { + t.Fatal(err) + } + run := workstreamWakeForTest(t, s, w, "identities", WakeProposal{Kind: "draft", Title: "Follow up", Factory: "f", Job: 7, DraftID: "untrusted", BudgetUSD: workstreamWakeBudget(3)}) + if _, _, err := s.ActOnWake(ctx, run.ID, "", "apply"); err != nil { + t.Fatal(err) + } + if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { + t.Fatal(err) + } + plan, _ := s.Plan(ctx, w.ID) + if len(plan.Waves[0].Items) != 1 || plan.Waves[0].Items[0].Kind != "job" || plan.Waves[0].Items[0].Job != 7 { + t.Fatal("undo touched member job", plan) + } +} diff --git a/internal/workstream/workstream_wake_fix1_test.go b/internal/workstream/workstream_wake_fix1_test.go new file mode 100644 index 0000000000..32b95961ff --- /dev/null +++ b/internal/workstream/workstream_wake_fix1_test.go @@ -0,0 +1,320 @@ +package workstream + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +func workstreamWakeUndoConflictForTest(t *testing.T, s *Store, run WakeRun) { + t.Helper() + ctx := context.Background() + if len(run.Items) != 1 || run.Items[0].State != "applied" { + t.Fatalf("expected one applied item: %+v", run) + } + if _, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "undo"); !errors.Is(err, ErrConflict) || c.ID != "" { + t.Fatalf("named undo: %+v %v", c, err) + } + stored, err := s.WakeRun(ctx, run.ID) + if err != nil || !reflect.DeepEqual(stored, run) { + t.Fatalf("named conflict changed the wake: %+v %v", stored, err) + } + noted, c, err := s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil || c.ID != run.WorkstreamID || len(noted.Items) != 1 || noted.Items[0].State != "applied" || noted.Items[0].Note != "Changed since; not undone." { + t.Fatalf("undo all: %+v %+v %v", noted, c, err) + } + again, c, err := s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil || c.ID != "" || !reflect.DeepEqual(again, noted) { + t.Fatalf("repeated undo all changed the wake: %+v %+v %v", again, c, err) + } +} + +func TestWakeD_M1_ChangedDraftRefusesUndo(t *testing.T) { + for _, change := range []string{"title", "repo", "text", "size", "budget", "source", "wave"} { + t.Run(change, func(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Draft edits", workstreamWakeMember()) + small := "small" + if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { + t.Fatal(err) + } + run := workstreamWakeForTest(t, s, w, "draft-edits", WakeProposal{Kind: "draft", Title: "Follow up", Repo: "api", Text: "Original", Size: "S", BudgetUSD: workstreamWakeBudget(3)}) + plan, err := s.Plan(ctx, w.ID) + if err != nil || len(plan.Waves) != 1 || len(plan.Waves[0].Items) != 1 { + t.Fatalf("wake plan: %+v %v", plan, err) + } + draft := &plan.Waves[0].Items[0] + switch change { + case "title": + draft.Title = "Edited follow-up" + case "repo": + draft.Repo = "web" + case "text": + draft.Text = "Edited contents" + case "size": + draft.Size = "M" + case "budget": + draft.BudgetUSD = 4 + case "source": + draft.Source = &Source{Kind: "chat", ID: w.AgentID, Label: "Person's edit"} + case "wave": + plan.Waves = append(plan.Waves, Wave{N: 2, Title: "Person's wave", Items: plan.Waves[0].Items}) + plan.Waves[0].Items = []PlanItem{} + } + before, _, err := s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}) + if err != nil { + t.Fatal(err) + } + workstreamWakeUndoConflictForTest(t, s, run) + after, err := s.Plan(ctx, w.ID) + if err != nil || !reflect.DeepEqual(after, before) { + t.Fatalf("undo changed the edited draft or its waves: %+v %v", after, err) + } + }) + } +} + +func TestWakeD_M1_OnlyUnchangedWakeCreatedWaveIsRemoved(t *testing.T) { + for _, change := range []string{"unchanged", "renamed", "auto_start", "milestone", "another_draft", "person_created"} { + t.Run(change, func(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Wave cleanup", workstreamWakeMember()) + small := "small" + if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { + t.Fatal(err) + } + if change == "person_created" { + if _, _, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { + t.Fatal(err) + } + } + run := workstreamWakeForTest(t, s, w, "wave-cleanup", WakeProposal{Kind: "draft", Title: "Follow up", BudgetUSD: workstreamWakeBudget(3)}) + plan, err := s.Plan(ctx, w.ID) + if err != nil || len(run.Items) != 1 || run.Items[0].State != "applied" || len(plan.Waves) != 1 || len(plan.Waves[0].Items) != 1 { + t.Fatalf("wake plan: %+v %+v %v", run, plan, err) + } + switch change { + case "renamed": + plan.Waves[0].Title = "Person's title" + case "auto_start": + plan.Waves[0].AutoStart = true + case "milestone": + plan.Milestones = []Milestone{{ID: "m", Title: "Release"}} + plan.Waves[0].MilestoneID = "m" + case "another_draft": + plan.Waves[0].Items = append(plan.Waves[0].Items, PlanItem{Kind: "draft", DraftID: "person-draft", Title: "Person's draft"}) + } + if change != "unchanged" && change != "person_created" { + if _, _, err := s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}); err != nil { + t.Fatal(err) + } + } + want := []Wave{} + if change != "unchanged" { + want = plan.Waves + want[0].Items = want[0].Items[1:] + } + undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil || c.ID != w.ID || len(undone.Items) != 1 || undone.Items[0].State != "undone" { + t.Fatalf("undo: %+v %+v %v", undone, c, err) + } + after, err := s.Plan(ctx, w.ID) + if err != nil || !reflect.DeepEqual(after.Waves, want) || !reflect.DeepEqual(after.Milestones, plan.Milestones) { + t.Fatalf("wave cleanup: %+v, want %+v (%v)", after, want, err) + } + }) + } +} + +func TestWakeDE_M2_PageMetadataChangesRefuseUndo(t *testing.T) { + for _, operation := range []string{"wake_update", "wake_creation"} { + t.Run(operation, func(t *testing.T) { + for _, change := range []string{"page.update_title", "page.update_owner", "page.update_read_by_jobs", "page.move_parent", "page.move_position", "page.delete"} { + t.Run(change, func(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Page metadata", workstreamWakeMember()) + small := "small" + if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { + t.Fatal(err) + } + parent, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person's folder", Owner: "you"}) + if err != nil { + t.Fatal(err) + } + proposal := WakeProposal{Kind: "page", Title: "Agent notes", Markdown: "Wake contents"} + if operation == "wake_update" { + page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: proposal.Title, Owner: "agent", Doc: pagedoc.FromMarkdown("Original")}) + if err != nil { + t.Fatal(err) + } + proposal.PageID = page.ID + } + run := workstreamWakeForTest(t, s, w, "page-metadata", proposal) + if len(run.Items) != 1 || run.Items[0].State != "applied" { + t.Fatal(run) + } + id := run.Items[0].PageID + switch change { + case "page.update_title": + title := "Person's title" + _, _, err = s.UpdatePage(ctx, id, PagePatch{Title: &title}) + case "page.update_owner": + owner := "you" + _, _, err = s.UpdatePage(ctx, id, PagePatch{Owner: &owner}) + case "page.update_read_by_jobs": + read := true + _, _, err = s.UpdatePage(ctx, id, PagePatch{ReadByJobs: &read}) + case "page.move_parent": + _, _, err = s.MovePage(ctx, id, parent.ID, "") + case "page.move_position": + _, _, err = s.MovePage(ctx, id, "", "") + case "page.delete": + _, _, err = s.DeletePage(ctx, id) + } + if err != nil { + t.Fatal(err) + } + before, err := s.Page(ctx, id, false) + if err != nil || before.Version != run.Items[0].Undo.Version { + t.Fatalf("metadata change advanced the content version: %+v %v", before, err) + } + history, historyErr := s.PageHistory(ctx, id, 100) + if change == "page.delete" { + if !errors.Is(historyErr, ErrNotFound) { + t.Fatal(historyErr) + } + } else if historyErr != nil { + t.Fatal(historyErr) + } + personBefore, err := s.Page(ctx, parent.ID, false) + if err != nil { + t.Fatal(err) + } + workstreamWakeUndoConflictForTest(t, s, run) + after, err := s.Page(ctx, id, false) + if err != nil || !reflect.DeepEqual(after, before) { + t.Fatalf("undo changed the later page edit: %+v %v", after, err) + } + afterHistory, err := s.PageHistory(ctx, id, 100) + if !errors.Is(err, historyErr) || !reflect.DeepEqual(afterHistory, history) { + t.Fatalf("undo wrote page history: %+v %v", afterHistory, err) + } + person, err := s.Page(ctx, parent.ID, false) + if err != nil || !reflect.DeepEqual(person, personBefore) { + t.Fatalf("undo changed the person's page: %+v %v", person, err) + } + }) + } + }) + } +} + +func TestWakeD_m1_DuplicatePageItemsKeepFirstAndUndoOriginal(t *testing.T) { + for _, autonomy := range []string{"small", "propose"} { + t.Run(autonomy, func(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Duplicate pages", workstreamWakeMember()) + if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}); err != nil { + t.Fatal(err) + } + page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent", Doc: pagedoc.FromMarkdown("Original")}) + if err != nil { + t.Fatal(err) + } + answer, err := ParseWakeAnswer(`{"summary":"Next","items":[` + + `{"kind":"page","page_id":"` + page.ID + `","markdown":"First update"},` + + `{"kind":"page","page_id":"` + page.ID + `","markdown":"Later update"}]}`) + if err != nil { + t.Fatal(err) + } + run := workstreamWakeForTest(t, s, w, "duplicate-pages", answer.Items...) + stored, err := s.WakeRun(ctx, run.ID) + if err != nil || len(stored.Items) != 1 || stored.Items[0].PageID != page.ID || stored.Items[0].Proposal.Markdown != "First update" { + t.Fatalf("page items were not deduplicated: %+v %v", stored, err) + } + if autonomy == "propose" { + for _, item := range stored.Items { + if item.State != "proposed" { + t.Fatal(item) + } + } + stored, _, err = s.ActOnWake(ctx, run.ID, "", "apply") + if err != nil { + t.Fatal(err) + } + } + for _, item := range stored.Items { + if item.State != "applied" { + t.Fatal(item) + } + } + updated, err := s.Page(ctx, page.ID, false) + if err != nil || updated.Version != page.Version+1 || string(updated.Doc) != string(pagedoc.FromMarkdown("First update")) { + t.Fatalf("first update was not the only write: %+v %v", updated, err) + } + undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil || c.ID != w.ID { + t.Fatalf("undo: %+v %v", c, err) + } + for _, item := range undone.Items { + if item.State != "undone" || item.Note != "" { + t.Fatal(item) + } + } + restored, err := s.Page(ctx, page.ID, false) + if err != nil || restored.Version != page.Version+2 || string(restored.Doc) != string(page.Doc) || restored.Title != page.Title { + t.Fatalf("undo did not restore the original document: %+v %v", restored, err) + } + }) + } +} + +func TestWakeD_m1_DuplicateNewPageTitlesKeepFirst(t *testing.T) { + for _, autonomy := range []string{"small", "propose"} { + t.Run(autonomy, func(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Duplicate new pages", workstreamWakeMember()) + if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}); err != nil { + t.Fatal(err) + } + run := workstreamWakeForTest(t, s, w, "duplicate-new-pages", WakeProposal{Kind: "page", Title: "New notes", Markdown: "First creation"}, WakeProposal{Kind: "page", Title: "New notes", Markdown: "Later creation"}) + stored, err := s.WakeRun(ctx, run.ID) + if err != nil || len(stored.Items) != 1 || stored.Items[0].Proposal.Markdown != "First creation" { + t.Fatalf("new-page items were not deduplicated: %+v %v", stored, err) + } + if autonomy == "propose" { + if stored.Items[0].State != "proposed" { + t.Fatal(stored) + } + stored, _, err = s.ActOnWake(ctx, run.ID, "", "apply") + if err != nil { + t.Fatal(err) + } + } + pages, err := s.Pages(ctx, w.ID) + if err != nil || len(pages) != 2 || stored.Items[0].State != "applied" { + t.Fatalf("expected one new agent page: %+v %+v %v", pages, stored, err) + } + page, err := s.Page(ctx, stored.Items[0].PageID, false) + if err != nil || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown("First creation")) { + t.Fatalf("first new-page suggestion was not kept: %+v %v", page, err) + } + undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil || c.ID != w.ID || undone.Items[0].State != "undone" { + t.Fatalf("undo: %+v %+v %v", undone, c, err) + } + deleted, err := s.Page(ctx, page.ID, false) + if err != nil || deleted.DeletedAt.IsZero() { + t.Fatalf("new page was not restorably deleted: %+v %v", deleted, err) + } + }) + } +} diff --git a/internal/workstream/workstream_wake_fix2_test.go b/internal/workstream/workstream_wake_fix2_test.go new file mode 100644 index 0000000000..f29c87c71b --- /dev/null +++ b/internal/workstream/workstream_wake_fix2_test.go @@ -0,0 +1,185 @@ +package workstream + +import ( + "context" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +func TestWakeD_R2_M2_UndoKeepsRenameBetweenClaimAndSettlement(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Rename during wake", workstreamWakeMember()) + small := "small" + if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { + t.Fatal(err) + } + page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Old title", Owner: "agent", Doc: pagedoc.FromMarkdown("Original document")}) + if err != nil { + t.Fatal(err) + } + run, woke, err := s.BeginWake(ctx, w.ID, "rename-during-wake", WakeEvent{Kind: "merged", Factory: "f", Job: 7, At: s.now()}) + if err != nil || !woke { + t.Fatalf("claim: %+v %v %v", run, woke, err) + } + title := "Person's title" + renamed, c, err := s.UpdatePage(ctx, page.ID, PagePatch{Title: &title}) + if err != nil || c.ID != w.ID || renamed.Title != title || renamed.Version != page.Version { + t.Fatalf("rename between claim and settlement: %+v %+v %v", renamed, c, err) + } + history, err := s.PageHistory(ctx, page.ID, 100) + if err != nil || len(history) != 1 || history[0].Title != page.Title { + t.Fatalf("rename changed the original content history: %+v %v", history, err) + } + run, c, err = s.SettleWake(ctx, run.ID, WakeAnswer{Items: []WakeProposal{{Kind: "page", PageID: page.ID, Markdown: "Wake document"}}}, nil, "") + if err != nil || c.ID != w.ID || len(run.Items) != 1 || run.Items[0].State != "applied" { + t.Fatalf("settle: %+v %+v %v", run, c, err) + } + applied, err := s.Page(ctx, page.ID, false) + if err != nil || applied.Title != title || applied.Version != page.Version+1 || string(applied.Doc) != string(pagedoc.FromMarkdown("Wake document")) { + t.Fatalf("wake did not preserve the rename: %+v %v", applied, err) + } + undone, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "undo") + if err != nil || c.ID != w.ID || len(undone.Items) != 1 || undone.Items[0].State != "undone" || undone.Items[0].Note != "" { + t.Fatalf("undo: %+v %+v %v", undone, c, err) + } + restored, err := s.Page(ctx, page.ID, false) + if err != nil || restored.Title != title || restored.Version != page.Version+2 || restored.UpdatedBy != "agent" || string(restored.Doc) != string(page.Doc) { + t.Fatalf("undo did not restore the original document and keep the rename: %+v %v", restored, err) + } + history, err = s.PageHistory(ctx, page.ID, 100) + if err != nil || len(history) != 3 || history[0].Version != restored.Version || history[0].Title != title || history[0].SavedBy != "agent" || history[2].Title != page.Title { + t.Fatalf("undo history lost the rename or rewrote the original version: %+v %v", history, err) + } + stored, err := s.WakeRun(ctx, run.ID) + if err != nil || !reflect.DeepEqual(stored, undone) { + t.Fatalf("undo was not persisted: %+v %v", stored, err) + } +} + +func TestWakeD_R2_m1_CreatedSiblingPagesUndoInAnyOrder(t *testing.T) { + for _, tc := range []struct { + name string + autonomy string + reverseApply bool + reorderSibling bool + }{ + {name: "named_undo_after_sibling_compaction", autonomy: "small"}, + {name: "undo_all_after_reverse_individual_apply", autonomy: "propose", reverseApply: true}, + {name: "named_undo_after_person_reorders_siblings", autonomy: "small", reorderSibling: true}, + } { + t.Run(tc.name, func(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Sibling pages", workstreamWakeMember()) + if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &tc.autonomy}); err != nil { + t.Fatal(err) + } + before, err := s.Pages(ctx, w.ID) + if err != nil || len(before) != 1 { + t.Fatalf("initial pages: %+v %v", before, err) + } + run := workstreamWakeForTest(t, s, w, "sibling-pages", + WakeProposal{Kind: "page", Title: "A", Markdown: "Document A"}, + WakeProposal{Kind: "page", Title: "B", Markdown: "Document B"}) + if len(run.Items) != 2 || run.Items[0].Proposal.Title != "A" || run.Items[1].Proposal.Title != "B" { + t.Fatalf("expected two pages in card order A, B: %+v", run) + } + if tc.reverseApply { + for _, item := range run.Items { + if item.State != "proposed" { + t.Fatalf("page was applied before individual Apply: %+v", item) + } + } + for i := len(run.Items) - 1; i >= 0; i-- { + var c Change + run, c, err = s.ActOnWake(ctx, run.ID, run.Items[i].ID, "apply") + if err != nil || c.ID != w.ID || run.Items[i].State != "applied" { + t.Fatalf("individual Apply in reverse card order: %+v %+v %v", run, c, err) + } + } + } + for i, item := range run.Items { + position := i + 1 + if tc.reverseApply { + position = len(run.Items) - i + } + page, err := s.Page(ctx, item.PageID, false) + if err != nil || item.State != "applied" || page.ParentID != "" || page.Position != position || page.Owner != "agent" || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown(item.Proposal.Markdown)) { + t.Fatalf("applied root page: %+v %+v %v", item, page, err) + } + } + var sibling PageNode + if tc.reorderSibling { + page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person's page", Owner: "you"}) + if err != nil { + t.Fatal(err) + } + var c Change + sibling, c, err = s.MovePage(ctx, page.ID, "", before[0].ID) + if err != nil || c.ID != w.ID || sibling.Position != 1 { + t.Fatalf("reorder sibling: %+v %+v %v", sibling, c, err) + } + for i, item := range run.Items { + page, err := s.Page(ctx, item.PageID, false) + if err != nil || page.Position != i+2 || item.Undo.Page == nil { + t.Fatalf("sibling reorder did not shift the wake page: %+v %v", page, err) + } + postImage := *item.Undo.Page + postImage.Position = page.Position + if !reflect.DeepEqual(page.PageNode, postImage) { + t.Fatalf("sibling reorder changed more than position: %+v", page) + } + } + } + if tc.reverseApply { + var c Change + run, c, err = s.ActOnWake(ctx, run.ID, "", "undo") + if err != nil || c.ID != w.ID { + t.Fatalf("Undo all: %+v %+v %v", run, c, err) + } + } else { + b, err := s.Page(ctx, run.Items[1].PageID, false) + if err != nil { + t.Fatal(err) + } + for i := range run.Items { + var c Change + run, c, err = s.ActOnWake(ctx, run.ID, run.Items[i].ID, "undo") + if err != nil || c.ID != w.ID || run.Items[i].State != "undone" || run.Items[i].Note != "" { + t.Fatalf("named Undo %d: %+v %+v %v", i, run, c, err) + } + if i == 0 { + compacted, err := s.Page(ctx, b.ID, false) + if err != nil || compacted.Position != b.Position-1 || !compacted.DeletedAt.IsZero() || run.Items[1].State != "applied" { + t.Fatalf("Undo A did not compact the still-applied B: %+v %+v %v", compacted, run, err) + } + } + } + } + for _, item := range run.Items { + page, err := s.Page(ctx, item.PageID, false) + if err != nil || item.State != "undone" || item.Note != "" || page.DeletedAt.IsZero() || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown(item.Proposal.Markdown)) { + t.Fatalf("wake page was not restorably deleted: %+v %+v %v", item, page, err) + } + } + want := append([]PageNode(nil), before...) + if tc.reorderSibling { + want = append(want, sibling) + } + after, err := s.Pages(ctx, w.ID) + if err != nil || !reflect.DeepEqual(after, want) { + t.Fatalf("Undo changed the person's pages: %+v %v", after, err) + } + stored, err := s.WakeRun(ctx, run.ID) + if err != nil || !reflect.DeepEqual(stored, run) { + t.Fatalf("Undo was not persisted: %+v %v", stored, err) + } + if again, c, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil || c.ID != "" || !reflect.DeepEqual(again, run) { + t.Fatalf("repeated Undo changed the wake: %+v %+v %v", again, c, err) + } + }) + } +} From d33dfffb84cdbca3f01763703ea9ccaaa0682d17 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 11:51:01 -0500 Subject: [PATCH 15/20] workstreams: sources (file, repo, issue, link, kept), wire v0.4, schema 5 (#2320) * workstreams: schema 4 adds workstream_sources by an additive migration Schema 3 stays readable without writing; the first write creates the table and moves to 4 in one transaction, keeping every existing row. Schemas 1 and 2 migrate straight to 4. Co-Authored-By: Claude Opus 5.5 * workstreams: sources store (file, repo, issue, link, kept) with strict path and URL checks Co-Authored-By: Claude Opus 5.5 * app: workstreams wire v0.3: source.list/add/update/remove, create takes sources Co-Authored-By: Claude Opus 5.5 * app: the workstream agent reads sources and proposes new ones for the person to keep Co-Authored-By: Claude Opus 5.5 * docs: change entry for workstream sources names its pull request Co-Authored-By: Claude Opus 5.5 * docs: the app protocol lists the source methods Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 14 +- cmd/codeaf/appworkstreams_agent.go | 4 + cmd/codeaf/appworkstreams_agent_sources.go | 188 ++++++ .../appworkstreams_agent_sources_test.go | 311 ++++++++++ cmd/codeaf/appworkstreams_conformance_test.go | 41 +- .../appworkstreams_source_open_other.go | 30 + cmd/codeaf/appworkstreams_source_open_unix.go | 21 + .../appworkstreams_source_open_unix_test.go | 52 ++ cmd/codeaf/appworkstreams_sources.go | 51 ++ cmd/codeaf/appworkstreams_sources_test.go | 514 ++++++++++++++++ .../testdata/workstreams-wire/contract.json | 236 +++++++- .../testdata/workstreams-wire/source.add.json | 59 ++ .../workstreams-wire/source.list.json | 73 +++ .../workstreams-wire/source.remove.json | 34 ++ .../workstreams-wire/source.update.json | 37 ++ .../workstreams-wire/workstream.changed.json | 3 +- .../workstreams-wire/workstream.create.json | 14 +- .../unreleased/2320-app-workstream-sources.md | 28 + docs/design/app-v3/PROTOCOL.md | 14 + internal/appwire/workstreams.go | 38 +- internal/appwire/workstreams_check.go | 3 + internal/appwire/workstreams_sources.go | 146 +++++ internal/appwire/workstreams_sources_test.go | 63 ++ internal/appwire/workstreams_test.go | 25 +- internal/manual/chat/collections.md | 58 +- internal/session/actioncategory.go | 2 +- internal/session/workstream_contract.go | 10 +- internal/session/workstream_schema.go | 6 +- internal/session/workstream_sources.go | 53 ++ internal/session/workstream_sources_test.go | 105 ++++ internal/session/workstream_tools.go | 11 +- internal/session/workstream_tools_test.go | 45 +- internal/workspace/schema3_test.go | 8 +- internal/workspace/schema4_test.go | 9 +- internal/workspace/store.go | 33 +- internal/workspace/workstream_sources.go | 36 ++ internal/workspace/workstream_sources_test.go | 155 +++++ internal/workspace/workstreams_test.go | 11 +- internal/workstream/sources.go | 391 ++++++++++++ internal/workstream/sources_migration_test.go | 209 +++++++ internal/workstream/sources_test.go | 556 ++++++++++++++++++ internal/workstream/sources_validation.go | 297 ++++++++++ internal/workstream/store.go | 24 +- internal/workstream/workstream.go | 2 + 44 files changed, 3947 insertions(+), 73 deletions(-) create mode 100644 cmd/codeaf/appworkstreams_agent_sources.go create mode 100644 cmd/codeaf/appworkstreams_agent_sources_test.go create mode 100644 cmd/codeaf/appworkstreams_source_open_other.go create mode 100644 cmd/codeaf/appworkstreams_source_open_unix.go create mode 100644 cmd/codeaf/appworkstreams_source_open_unix_test.go create mode 100644 cmd/codeaf/appworkstreams_sources.go create mode 100644 cmd/codeaf/appworkstreams_sources_test.go create mode 100644 cmd/codeaf/testdata/workstreams-wire/source.add.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/source.list.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/source.remove.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/source.update.json create mode 100644 docs/changes/unreleased/2320-app-workstream-sources.md create mode 100644 internal/appwire/workstreams_sources.go create mode 100644 internal/appwire/workstreams_sources_test.go create mode 100644 internal/session/workstream_sources.go create mode 100644 internal/session/workstream_sources_test.go create mode 100644 internal/workspace/workstream_sources.go create mode 100644 internal/workspace/workstream_sources_test.go create mode 100644 internal/workstream/sources.go create mode 100644 internal/workstream/sources_migration_test.go create mode 100644 internal/workstream/sources_test.go create mode 100644 internal/workstream/sources_validation.go diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 017a67e542..14cb7b658c 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -331,6 +331,14 @@ func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire } func (b *appBackend) workstreamCreatePreflight(p appwire.WorkstreamCreateParams) error { + // Source paths can disappear after creation, so check them only for a new client. + sources := make([]workstream.SourceAdd, 0, len(p.Sources)) + for _, source := range p.Sources { + sources = append(sources, source.SourceAdd()) + } + if err := workstream.ValidateCreateSources(sources); err != nil { + return appWorkstreamError(err) + } if p.OriginChatID != nil { if _, err := b.find(*p.OriginChatID); err != nil { return err @@ -361,7 +369,11 @@ func (b *appBackend) workstreamCreateWithAgent(p appwire.WorkstreamCreateParams, 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)}) + sources := make([]workstream.SourceAdd, 0, len(p.Sources)) + for _, source := range p.Sources { + sources = append(sources, source.SourceAdd()) + } + 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), Sources: sources}) } if err != nil || w.AgentID != agent.ID { if cleanup := b.workstreamCloseUnusedAgent(agent.ID, err); cleanup != nil { diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go index 7ddd4ba7bb..adaff29491 100644 --- a/cmd/codeaf/appworkstreams_agent.go +++ b/cmd/codeaf/appworkstreams_agent.go @@ -116,6 +116,10 @@ func (d *workstreamAgentDoor) Read(ctx context.Context, ask session.WorkstreamRe return json.Marshal(appwire.WorkstreamPlanGetResult{Plan: appWorkstreamPlan(p)}) case "goaheads": return workstreamAgentReadGoAheads(ctx, s, w.ID) + case "sources": + return workstreamAgentReadSources(ctx, s, w.ID) + case "source": + return workstreamAgentReadSource(ctx, s, w.ID, ask.SourceID) } return nil, errors.New("what must name a workstream part") } diff --git a/cmd/codeaf/appworkstreams_agent_sources.go b/cmd/codeaf/appworkstreams_agent_sources.go new file mode 100644 index 0000000000..a3d715deff --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent_sources.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf8" + + "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" +) + +var errWorkstreamSourceLink = errors.New("source is a link") + +func (d *workstreamAgentDoor) ProposeSource(ctx context.Context, ask session.WorkstreamSourceProposal) (session.WorkstreamSourceProposed, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return session.WorkstreamSourceProposed{}, err + } + item, already, change, err := s.ProposeSource(ctx, w.ID, ask.SourceAdd(), ask.Why) + if err != nil { + return session.WorkstreamSourceProposed{}, workstreamAgentError(err) + } + d.backend.workstreamChanged(change) + return session.WorkstreamSourceProposed{ID: item.ID, Already: already}, nil +} + +func workstreamAgentReadSources(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { + items, err := s.Sources(ctx, id) + if err != nil { + return nil, err + } + out := appwire.WorkstreamSourceListResult{Sources: []appwire.WorkstreamSourceItem{}} + for _, item := range items { + out.Sources = append(out.Sources, appWorkstreamSourceItem(item)) + } + return json.Marshal(out) +} + +func workstreamAgentReadSource(ctx context.Context, s *workstream.Store, id, sourceID string) (json.RawMessage, error) { + item, err := s.GetSource(ctx, sourceID) + if err != nil { + return nil, err + } + if item.WorkstreamID != id { + return nil, workstream.ErrNotFound + } + content, err := workstreamAgentSourceContent(ctx, s, item) + if err != nil { + return nil, err + } + return workstreamAgentSourceJSON(item, content) +} + +func workstreamAgentSourceContent(ctx context.Context, s *workstream.Store, item workstream.SourceItem) (string, error) { + switch item.Kind { + case workstream.SourceFile: + return workstreamAgentReadPath(item.Path, false), nil + case workstream.SourceRepo: + if filepath.IsAbs(item.Repo) { + return workstreamAgentReadPath(item.Repo, true), nil + } + case workstream.SourceKept: + return workstreamAgentReadKept(ctx, s, item) + } + return "This source is a pointer; its content is not read here.", nil +} + +func workstreamAgentReadKept(ctx context.Context, s *workstream.Store, item workstream.SourceItem) (string, error) { + switch item.Ref.Kind { + case "page": + page, err := workstreamAgentPage(ctx, s, item.WorkstreamID, item.Ref.ID, false) + if err != nil { + return "", err + } + if !page.DeletedAt.IsZero() { + return "The kept page is deleted.", nil + } + return pagedoc.Markdown(page.Doc) + case "record": + records, err := s.Records(ctx, item.WorkstreamID, "", "", true) + if err != nil { + return "", err + } + for _, record := range records { + if record.ID == item.Ref.ID { + return record.Text, nil + } + } + return "", workstream.ErrNotFound + } + return "This kept message or job is a pointer; its content is not read here.", nil +} + +func workstreamAgentReadPath(path string, folderOnly bool) string { + file, err := workstreamAgentOpenSourcePath(path) + if errors.Is(err, errWorkstreamSourceLink) { + return "The source is a link and was not read." + } + if err != nil { + return "The source is unreadable." + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return "The source is unreadable." + } + if folderOnly && !info.IsDir() { + return "The repo folder is unreadable." + } + if info.IsDir() { + return workstreamAgentReadFolder(file) + } + if !info.Mode().IsRegular() { + return "The source is not a regular text file." + } + data, err := io.ReadAll(io.LimitReader(file, session.WorkstreamSourceReadMax+1)) + if err != nil { + return "The file is unreadable." + } + cut := len(data) > session.WorkstreamSourceReadMax + if cut { + data = data[:session.WorkstreamSourceReadMax] + } + if cut { + data = workstreamAgentUTF8Tail(data) + } + if !utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0 { + return "The file is binary, not UTF-8 text." + } + if cut { + return string(data) + "\nThe file was cut." + } + return string(data) +} + +func workstreamAgentUTF8Tail(data []byte) []byte { + end := len(data) + for start := end - 1; start >= 0 && start >= end-utf8.UTFMax; start-- { + if !utf8.RuneStart(data[start]) { + continue + } + if !utf8.FullRune(data[start:]) { + return data[:start] + } + break + } + return data +} + +func workstreamAgentReadFolder(folder *os.File) string { + names, err := folder.Readdirnames(200) + if err != nil && err != io.EOF { + return "The folder is unreadable." + } + sort.Strings(names) + return strings.Join(names, "\n") +} + +func workstreamAgentSourceJSON(item workstream.SourceItem, content string) (json.RawMessage, error) { + out := struct { + Source appwire.WorkstreamSourceItem `json:"source"` + Content string `json:"content"` + }{Source: appWorkstreamSourceItem(item), Content: content} + // Bound the encoded answer too: escapes and metadata count toward the read cap. + for { + raw, err := json.Marshal(out) + if err != nil || len(raw) <= session.WorkstreamSourceReadMax { + return raw, err + } + end := len(out.Content) / 2 + for end > 0 && !utf8.RuneStart(out.Content[end]) { + end-- + } + tail := "\nThe source read was cut." + if item.Kind == workstream.SourceFile { + tail = "\nThe file was cut." + } + out.Content = out.Content[:end] + tail + } +} diff --git a/cmd/codeaf/appworkstreams_agent_sources_test.go b/cmd/codeaf/appworkstreams_agent_sources_test.go new file mode 100644 index 0000000000..00f1ca07e3 --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent_sources_test.go @@ -0,0 +1,311 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func sessionWorkstreamSourceProposalForTest() session.WorkstreamSourceProposal { + return session.WorkstreamSourceProposal{Kind: "link", URL: workstreamPtr("https://example.com/guide"), Why: "Read the guide."} +} + +// WP-12 (g): the agent adds only a proposal, lists its exact stored fields and +// reads a text source; duplicate and invalid proposals add nothing else. +func TestWorkstreamAgentSourceProposalsAndReads(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + path := workstreamSourcesFile(t) + ask := session.WorkstreamSourceProposal{Kind: "file", Path: &path, Why: "Read recorded usage."} + before := workstreamLimitSnapshot(t, b, w.ID) + events := len(workstreamEvents(rec)) + proposal, err := door.ProposeSource(ctx, ask) + if err != nil || proposal.ID == "" || proposal.Already { + t.Fatalf("proposal: %+v %v", proposal, err) + } + again, err := door.ProposeSource(ctx, ask) + if err != nil || !again.Already || again.ID != proposal.ID || len(workstreamEvents(rec)) != events+1 { + t.Fatalf("duplicate: %+v %v", again, err) + } + listed, err := door.Read(ctx, session.WorkstreamReadAsk{What: "sources"}) + if err != nil { + t.Fatal(err) + } + var list appwire.WorkstreamSourceListResult + if err := json.Unmarshal(listed, &list); err != nil { + t.Fatal(err) + } + if len(list.Sources) != 1 { + t.Fatalf("list: %s", listed) + } + item := list.Sources[0] + if item.ID != proposal.ID || item.Kind != "file" || item.Path == nil || *item.Path != path || !item.Proposed || item.ReadByJobs || item.AddedBy != "agent" || item.Why == nil || *item.Why != ask.Why { + t.Fatalf("proposal row: %+v", item) + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: proposal.ID}) + if err != nil { + t.Fatal(err) + } + var read struct { + Source appwire.WorkstreamSourceItem `json:"source"` + Content string `json:"content"` + } + if err := json.Unmarshal(raw, &read); err != nil { + t.Fatal(err) + } + if read.Content != "Recorded usage.\n" || read.Source.ID != proposal.ID { + t.Fatalf("source read: %s", raw) + } + for _, bad := range []session.WorkstreamSourceProposal{{Kind: "file", Path: workstreamPtr("relative"), Why: "Reason"}, {Kind: "link", URL: workstreamPtr("file:///etc/passwd"), Why: "Reason"}, {Kind: "link", URL: workstreamPtr("https://example.com/other"), Why: ""}} { + if _, err := door.ProposeSource(ctx, bad); err == nil { + t.Fatal("invalid proposal succeeded") + } + } + after := workstreamLimitSnapshot(t, b, w.ID) + if before.Pages != after.Pages || before.Records != after.Records || before.Versions != after.Versions || !reflect.DeepEqual(before.Row.Members, after.Row.Members) || len(workstreamEvents(rec)) != events+1 { + t.Fatal("source proposals changed unrelated state") + } + if _, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: "missing"}); err == nil { + t.Fatal("unknown source read succeeded") + } + other := workstreamCreateForTest(t, b, "Other source owner") + foreign, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: other.ID, Kind: "file", Path: &path}) + if err != nil { + t.Fatal(err) + } + if _, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: foreign.Source.ID}); err == nil { + t.Fatal("foreign source read succeeded") + } +} + +// WP-12 (g): reads use only the stored file/folder path, bound content and +// entry names, and explain binary or missing files in one line. +func TestWorkstreamAgentSourceFileReadBounds(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + path := workstreamSourcesFile(t) + file, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + data string + want string + }{ + {strings.Repeat("é", session.WorkstreamSourceReadMax), "The file was cut."}, + {strings.Repeat("\"", session.WorkstreamSourceReadMax), "The file was cut."}, + {"binary\x00bytes", "The file is binary, not UTF-8 text."}, + {string([]byte{255, 254}), "The file is binary, not UTF-8 text."}, + } { + if err := os.WriteFile(path, []byte(tc.data), 0600); err != nil { + t.Fatal(err) + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: file.Source.ID}) + var answer struct { + Content string `json:"content"` + } + if err != nil || len(raw) > session.WorkstreamSourceReadMax || !utf8.Valid(raw) { + t.Fatalf("bounded source: %d %v", len(raw), err) + } + if err := json.Unmarshal(raw, &answer); err != nil || !strings.Contains(answer.Content, tc.want) { + t.Fatalf("content: %q %v", answer.Content, err) + } + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: file.Source.ID}) + if err != nil || !strings.Contains(string(raw), "unreadable") { + t.Fatalf("missing file: %s %v", raw, err) + } + folder := t.TempDir() + for i := 0; i < 205; i++ { + if err := os.WriteFile(filepath.Join(folder, fmt.Sprintf("entry-%03d", i)), []byte("child text must not be read"), 0600); err != nil { + t.Fatal(err) + } + } + for _, kind := range []string{"file", "repo"} { + ask := appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: kind} + if kind == "file" { + ask.Path = &folder + } else { + ask.Repo = &folder + } + made, err := b.WorkstreamSourceAdd(ask) + if err != nil { + t.Fatal(err) + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: made.Source.ID}) + var answer struct { + Content string `json:"content"` + } + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &answer); err != nil || len(strings.Split(answer.Content, "\n")) != 200 || strings.Contains(answer.Content, "child text") { + t.Fatalf("folder names: %s %v", raw, err) + } + } +} + +// WP-12 Fix 1 Major 1 (g): final-component links never expose another file, +// whether present at admission or planted after an ordinary file was added. +func TestWorkstreamAgentMajor1ReadConfinementG(t *testing.T) { + for _, replacement := range []bool{false, true} { + t.Run(fmt.Sprintf("replacement=%t", replacement), func(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + path := workstreamSourcesFile(t) + target := filepath.Join(t.TempDir(), "other.txt") + const otherText = "Bytes from another readable file must never appear." + if err := os.WriteFile(target, []byte(otherText), 0600); err != nil { + t.Fatal(err) + } + plantLink := func() { + t.Helper() + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + } + if !replacement { + plantLink() + } + item, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) + if err != nil { + t.Fatal(err) + } + if replacement { + plantLink() + } + raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) + if err != nil { + t.Fatal(err) + } + var answer struct { + Content string `json:"content"` + } + if err := json.Unmarshal(raw, &answer); err != nil { + t.Fatal(err) + } + if answer.Content != "The source is a link and was not read." || strings.Contains(string(raw), otherText) { + t.Fatalf("link read: %s", raw) + } + }) + } +} + +// WP-12 Fix 1 Major 1 (g): intermediate directory links remain usable, and +// folder reads return entry names without following file or directory links. +func TestWorkstreamAgentMajor1ReadConfinementGIntermediateAndEntries(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + folder := t.TempDir() + path := filepath.Join(folder, "text.txt") + if err := os.WriteFile(path, []byte("Stored file text."), 0600); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "folder") + if err := os.Symlink(folder, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + for name, target := range map[string]string{"file-link": path, "folder-link": t.TempDir(), "missing-link": filepath.Join(folder, "missing")} { + if err := os.Symlink(target, filepath.Join(folder, name)); err != nil { + t.Fatal(err) + } + } + for _, tc := range []struct { + kind, path, want string + }{ + {"file", filepath.Join(link, "text.txt"), "Stored file text."}, + {"file", folder, "file-link\nfolder-link\nmissing-link\ntext.txt"}, + {"repo", folder, "file-link\nfolder-link\nmissing-link\ntext.txt"}, + } { + ask := appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: tc.kind} + if tc.kind == "repo" { + ask.Repo = &tc.path + } else { + ask.Path = &tc.path + } + item, err := b.WorkstreamSourceAdd(ask) + if err != nil { + t.Fatal(err) + } + raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) + var answer struct { + Content string `json:"content"` + } + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &answer); err != nil || answer.Content != tc.want { + t.Fatalf("%s read: %s %v", tc.kind, raw, err) + } + } +} + +// WP-12 (g): kept pages render markdown, records return text, and network, +// message and job sources stay pointers with no fetch or lookup. +func TestWorkstreamAgentKeptSourcesAndPointers(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Guide", Doc: json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Kept page words."}]}]}`)}) + if err != nil { + t.Fatal(err) + } + record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Kept record text."}) + if err != nil { + t.Fatal(err) + } + cases := []struct { + ask appwire.WorkstreamSourceAddParams + want string + }{ + {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "page", ID: page.Page.ID}}, "Kept page words."}, + {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: record.Record.ID}}, "Kept record text."}, + {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "message", ID: "unlooked-up", ChatID: workstreamPtr("chat")}}, "pointer"}, + {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "factory_job", ID: "7", Factory: workstreamPtr("f")}}, "pointer"}, + {appwire.WorkstreamSourceAddParams{Kind: "repo", Repo: workstreamPtr("owner/name")}, "pointer"}, + {appwire.WorkstreamSourceAddParams{Kind: "issue", Repo: workstreamPtr("owner/name"), Number: workstreamPtr(412)}, "pointer"}, + {appwire.WorkstreamSourceAddParams{Kind: "link", URL: workstreamPtr("https://example.com")}, "pointer"}, + } + for _, tc := range cases { + tc.ask.WorkstreamID = w.ID + made, err := b.WorkstreamSourceAdd(tc.ask) + if err != nil { + t.Fatal(err) + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: made.Source.ID}) + if err != nil || !strings.Contains(string(raw), tc.want) { + t.Fatalf("kept read: %s %v", raw, err) + } + } +} + +// WP-12 (g), (h): a done state committed after resolving the agent door is +// still enforced by the store, with an empty change receipt. +func TestWorkstreamSourceProposalDoneAfterResolution(t *testing.T) { + _, _, _, door := workstreamAgentFixture(t) + ctx := context.Background() + s, w, err := door.(*workstreamAgentDoor).workstreamOwner(ctx, true) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { + t.Fatal(err) + } + _, _, change, err := s.ProposeSource(ctx, w.ID, workstream.SourceAdd{Kind: "link", URL: workstreamPtr("https://example.com")}, "Read it.") + if workstreamAgentError(err) != session.ErrWorkstreamDone || len(change.Parts) != 0 { + t.Fatalf("late done: %+v %v", change, err) + } +} diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go index 6d28c07f41..bd331b4394 100644 --- a/cmd/codeaf/appworkstreams_conformance_test.go +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -456,9 +456,14 @@ func (connection *workstreamWireConnection) read(t *testing.T) map[string]any { } func (connection *workstreamWireConnection) call(t *testing.T, method string, params any) map[string]any { + t.Helper() + raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": method, "params": params}) + return connection.callRaw(t, raw) +} + +func (connection *workstreamWireConnection) callRaw(t *testing.T, raw []byte) map[string]any { t.Helper() connection.Next++ - raw := workstreamWireEncode(t, map[string]any{"id": connection.Next, "method": method, "params": params}) writeErr := make(chan error, 1) connection.Writes.Add(1) go func() { @@ -520,6 +525,7 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir Backend: backend, Recorder: recorder, Now: now, IDs: map[string]string{"conv_5b9e": origin.ID, "ms_usage": "conformance-milestone", "draft_418c": "conformance-draft"}, } + fixture.IDs["/Users/ada/billing/usage-pricing.md"] = workstreamSourcesFile(t) if name == "workstream.create" { // An unobserved member job keeps all optional numbers unknown on create. return fixture @@ -633,6 +639,9 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir fixture.grant(t) } } + if name == "source.list" || name == "source.update" || name == "source.remove" { + fixture.sources(t) + } return fixture } @@ -835,7 +844,8 @@ func TestAppWorkstreamWireConformance(t *testing.T) { "page.list", "page.get", "page.save", "page.create", "page.update", "page.move", "page.delete", "page.history", "page.restore", "record.list", "record.propose", "record.keep", "record.drop", "record.supersede", "goahead.list", "goahead.grant", - "goahead.revoke", "wake.list", "wake.apply", "wake.undo", "wake.not_now", "collections.add", "workstream.changed", + "goahead.revoke", "wake.list", "wake.apply", "wake.undo", "wake.not_now", + "source.list", "source.add", "source.update", "source.remove", "collections.add", "workstream.changed", } errorCases := []workstreamWireErrorCase{ {Name: "bad_params", Method: "workstream.get", Code: "bad_params"}, @@ -857,6 +867,19 @@ func TestAppWorkstreamWireConformance(t *testing.T) { {Name: "records_too_large", Method: "record.propose", Code: "too_large"}, {Name: "unknown_method", Method: "workstream.list", Code: "unknown_method", Example: "workstream.list"}, {Name: "busy", Method: "workstream.update", Code: "busy"}, + // WP-12 (b), (h): each sources door exercises its own refusals. + {Name: "source_list_bad", Method: "source.list", Code: "bad_params"}, + {Name: "source_list_missing", Method: "source.list", Code: "not_found"}, + {Name: "source_add_bad", Method: "source.add", Code: "bad_params"}, + {Name: "source_add_missing", Method: "source.add", Code: "not_found"}, + {Name: "source_add_done", Method: "source.add", Code: "conflict"}, + {Name: "source_add_limit", Method: "source.add", Code: "too_large"}, + {Name: "source_update_bad", Method: "source.update", Code: "bad_params"}, + {Name: "source_update_missing", Method: "source.update", Code: "not_found"}, + {Name: "source_update_done", Method: "source.update", Code: "conflict"}, + {Name: "source_remove_bad", Method: "source.remove", Code: "bad_params"}, + {Name: "source_remove_missing", Method: "source.remove", Code: "not_found"}, + {Name: "source_remove_done", Method: "source.remove", Code: "conflict"}, } comparison := &workstreamWireComparison{Questions: make(map[string]string), Differed: make(map[string]bool)} for _, question := range workstreamWireDocQuestions { @@ -1096,6 +1119,12 @@ func workstreamWireKeptGoAhead(t *testing.T, connection *workstreamWireConnectio func workstreamWireCoverage(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, cases []string, errorCases []workstreamWireErrorCase) { t.Helper() + if len(contract.Methods) != 33 { + t.Errorf("v0.4 has %d methods, want 33", len(contract.Methods)) + } + if len(contract.Events) != 1 || contract.Events[0].Event != appwire.EventWorkstreamChanged { + t.Errorf("v0.4 events: want only workstream.changed, got %v", contract.Events) + } if !reflect.DeepEqual(contract.Methods, appwire.WorkstreamMethods) { t.Errorf("contract methods/order %v differ from appwire.WorkstreamMethods %v", contract.Methods, appwire.WorkstreamMethods) } @@ -1257,6 +1286,9 @@ func workstreamWireEvent(t *testing.T, contract *workstreamWireContract, example func workstreamWireError(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, errorCase workstreamWireErrorCase) { t.Helper() fixture := workstreamWireSeed(t, "workstream.get", 0) + if errorCase.Method == "source.update" || errorCase.Method == "source.remove" { + fixture.sources(t) + } backend := fixture.Backend examples := files[errorCase.Method] params := fixture.prepare(t, examples.Examples.Params[errorCase.ParamsExample]) @@ -1318,7 +1350,10 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m case "busy": release = workstreamWireHoldWriter(t, backend) default: - t.Fatalf("unimplemented error case %s", errorCase.Name) + if !workstreamWireSourceError(t, fixture, errorCase, params) { + t.Fatalf("unimplemented error case %s", errorCase.Name) + } + changedShape = strings.HasSuffix(errorCase.Name, "_bad") } if !changedShape { comparison := &workstreamWireComparison{} diff --git a/cmd/codeaf/appworkstreams_source_open_other.go b/cmd/codeaf/appworkstreams_source_open_other.go new file mode 100644 index 0000000000..cd6d0a0173 --- /dev/null +++ b/cmd/codeaf/appworkstreams_source_open_other.go @@ -0,0 +1,30 @@ +//go:build !unix + +package main + +import "os" + +// Verify the final component and its opened identity before any content is read. +func workstreamAgentOpenSourcePath(path string) (*os.File, error) { + before, err := os.Lstat(path) + if err != nil { + return nil, err + } + if before.Mode()&os.ModeSymlink != 0 { + return nil, errWorkstreamSourceLink + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + after, err := file.Stat() + if err != nil { + file.Close() + return nil, err + } + if !os.SameFile(before, after) { + file.Close() + return nil, os.ErrInvalid + } + return file, nil +} diff --git a/cmd/codeaf/appworkstreams_source_open_unix.go b/cmd/codeaf/appworkstreams_source_open_unix.go new file mode 100644 index 0000000000..a4da049da2 --- /dev/null +++ b/cmd/codeaf/appworkstreams_source_open_unix.go @@ -0,0 +1,21 @@ +//go:build unix + +package main + +import ( + "errors" + "os" + "syscall" +) + +// Refuse final-component links atomically and keep FIFO replacement reads nonblocking. +func workstreamAgentOpenSourcePath(path string) (*os.File, error) { + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NONBLOCK|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0) + if errors.Is(err, syscall.ELOOP) { + return nil, errWorkstreamSourceLink + } + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), path), nil +} diff --git a/cmd/codeaf/appworkstreams_source_open_unix_test.go b/cmd/codeaf/appworkstreams_source_open_unix_test.go new file mode 100644 index 0000000000..0a6c351126 --- /dev/null +++ b/cmd/codeaf/appworkstreams_source_open_unix_test.go @@ -0,0 +1,52 @@ +//go:build unix + +package main + +import ( + "context" + "os" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// WP-12 (g): replacing an added file with a named pipe cannot make a source +// read wait for a writer or read bytes from a non-regular descriptor. +func TestWorkstreamAgentSourceRefusesAReplacementFIFO(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + path := workstreamSourcesFile(t) + item, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(path, 0600); err != nil { + t.Fatal(err) + } + done := make(chan string, 1) + go func() { + raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) + if err != nil { + done <- err.Error() + return + } + done <- string(raw) + }() + select { + case answer := <-done: + if !strings.Contains(answer, "not a regular text file") { + t.Fatalf("special file answer: %s", answer) + } + case <-time.After(5 * time.Second): + if writer, err := os.OpenFile(path, os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil { + writer.Close() + } + t.Fatal("source read blocked on a named pipe") + } +} diff --git a/cmd/codeaf/appworkstreams_sources.go b/cmd/codeaf/appworkstreams_sources.go new file mode 100644 index 0000000000..31a44b644b --- /dev/null +++ b/cmd/codeaf/appworkstreams_sources.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func appWorkstreamSourceItem(item workstream.SourceItem) appwire.WorkstreamSourceItem { + out := appwire.WorkstreamSourceItem{ID: item.ID, WorkstreamID: item.WorkstreamID, Kind: item.Kind, Title: item.Title, Path: workstreamOptional(item.Path), Repo: workstreamOptional(item.Repo), Number: workstreamPositive(item.Number), URL: workstreamOptional(item.URL), ReadByJobs: item.ReadByJobs, Proposed: item.Proposed, Why: workstreamOptional(item.Why), AddedBy: item.AddedBy, AddedAt: item.AddedAt} + if item.Ref != nil { + out.Ref = &appwire.WorkstreamKeptRef{Kind: item.Ref.Kind, ID: item.Ref.ID, ChatID: item.Ref.ChatID, Factory: item.Ref.Factory} + } + if item.Gone { + out.Gone = &item.Gone + } + return out +} + +func (b *appBackend) WorkstreamSourceList(p appwire.WorkstreamSourceListParams) (appwire.WorkstreamSourceListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceListResult, workstream.Change, error) { + items, err := s.Sources(context.Background(), p.WorkstreamID) + out := appwire.WorkstreamSourceListResult{Sources: []appwire.WorkstreamSourceItem{}} + for _, item := range items { + out.Sources = append(out.Sources, appWorkstreamSourceItem(item)) + } + return out, workstream.Change{}, err + }) +} + +func (b *appBackend) WorkstreamSourceAdd(p appwire.WorkstreamSourceAddParams) (appwire.WorkstreamSourceAddResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceAddResult, workstream.Change, error) { + item, already, change, err := s.AddSource(context.Background(), p.WorkstreamID, p.SourceAdd()) + return appwire.WorkstreamSourceAddResult{Source: appWorkstreamSourceItem(item), Already: already}, change, err + }) +} + +func (b *appBackend) WorkstreamSourceUpdate(p appwire.WorkstreamSourceUpdateParams) (appwire.WorkstreamSourceUpdateResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceUpdateResult, workstream.Change, error) { + item, change, err := s.UpdateSource(context.Background(), p.ID, workstream.SourcePatch{Title: p.Title, ReadByJobs: p.ReadByJobs, Keep: p.Keep}) + return appwire.WorkstreamSourceUpdateResult{Source: appWorkstreamSourceItem(item)}, change, err + }) +} + +func (b *appBackend) WorkstreamSourceRemove(p appwire.WorkstreamSourceRemoveParams) (appwire.WorkstreamSourceRemoveResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceRemoveResult, workstream.Change, error) { + item, change, err := s.RemoveSource(context.Background(), p.ID) + return appwire.WorkstreamSourceRemoveResult{Source: appWorkstreamSourceItem(item)}, change, err + }) +} diff --git a/cmd/codeaf/appworkstreams_sources_test.go b/cmd/codeaf/appworkstreams_sources_test.go new file mode 100644 index 0000000000..0782bb6477 --- /dev/null +++ b/cmd/codeaf/appworkstreams_sources_test.go @@ -0,0 +1,514 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func workstreamSourcesFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "usage-pricing.md") + if err := os.WriteFile(path, []byte("Recorded usage.\n"), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func workstreamSourceWireCall(t *testing.T, connection *workstreamWireConnection, method string, params any) map[string]any { + t.Helper() + return workstreamWireResult(t, connection.call(t, method, params)).(map[string]any) +} + +// WP-12 (a), (d), (f): all target forms round-trip through actual typed wire +// dispatch, with exact kind keys, derived titles, order and duplicate receipts. +func TestAppWorkstreamSourcesEveryKindOnWire(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Sources wire", workstreamJobRef("7")) + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) + if err != nil { + t.Fatal(err) + } + record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "decision", Text: "Use recorded totals.\nDetails.", Kept: workstreamPtr(true)}) + if err != nil { + t.Fatal(err) + } + path := workstreamSourcesFile(t) + folder := t.TempDir() + cases := []struct { + fields map[string]any + title string + }{ + {map[string]any{"kind": "file", "path": path}, "usage-pricing.md"}, + {map[string]any{"kind": "repo", "repo": "ACME/Billing"}, "ACME/Billing"}, + {map[string]any{"kind": "repo", "repo": folder}, filepath.Base(folder)}, + {map[string]any{"kind": "issue", "repo": "acme/billing", "number": 412}, "acme/billing#412"}, + {map[string]any{"kind": "link", "url": "HTTPS://Docs.Example.com/x/?q=1#part"}, "docs.example.com/x"}, + {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "record", "id": record.Record.ID}}, "Use recorded totals."}, + {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "page", "id": pages.Pages[0].ID}}, "Overview"}, + {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "message", "id": "m", "chat_id": "chat"}}, "Kept message"}, + {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "factory_job", "id": "7", "factory": "f"}}, "Job 7"}, + } + membersBefore, err := b.collections.Members(context.Background(), w.ID) + if err != nil { + t.Fatal(err) + } + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + want := []any{} + for i, tc := range cases { + params := map[string]any{"workstream_id": w.ID} + for key, value := range tc.fields { + params[key] = value + } + before := len(workstreamEvents(rec)) + result := workstreamSourceWireCall(t, connection, "source.add", params) + item := result["source"].(map[string]any) + keys := []string{"id", "workstream_id", "kind", "title", "read_by_jobs", "proposed", "added_by", "added_at"} + for key := range tc.fields { + if key != "kind" { + keys = append(keys, key) + } + } + slices.Sort(keys) + if result["already"] != false || item["title"] != tc.title || item["added_by"] != "you" || item["read_by_jobs"] != false || item["proposed"] != false || !reflect.DeepEqual(workstreamWireKeys(item), keys) { + t.Fatalf("kind %d: %+v", i, result) + } + for key, value := range tc.fields { + if key == "url" { + value = "https://docs.example.com/x/?q=1#part" + } + if !reflect.DeepEqual(item[key], workstreamWireDecode(t, workstreamWireEncode(t, value))) { + t.Fatalf("kind %d field %s: %v", i, key, item) + } + } + if i == 1 { + params["repo"] = "acme/billing" + } + if i == 3 { + params["repo"] = "ACME/Billing" + } + if i == 4 { + params["url"] = "https://docs.example.com/x/?q=1#part" + } + again := workstreamSourceWireCall(t, connection, "source.add", params) + if again["already"] != true || !reflect.DeepEqual(again["source"], item) || len(workstreamEvents(rec)) != before+1 { + t.Fatalf("duplicate kind %d: %+v", i, again) + } + want = append(want, item) + } + listed := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"] + if !reflect.DeepEqual(listed, want) { + t.Fatalf("source list order or fields: %v", listed) + } + membersAfter, err := b.collections.Members(context.Background(), w.ID) + if err != nil || !reflect.DeepEqual(membersAfter, membersBefore) { + t.Fatalf("sources changed members: %+v %v", membersAfter, err) + } + for _, index := range []int{0, 5, 6} { + item := want[index].(map[string]any) + removed := workstreamSourceWireCall(t, connection, "source.remove", map[string]any{"id": item["id"]})["source"] + if !reflect.DeepEqual(item, removed) { + t.Fatalf("remove changed receipt: %v", removed) + } + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "Recorded usage.\n" { + t.Fatalf("removed source changed file: %q %v", data, err) + } + if _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: pages.Pages[0].ID}); err != nil { + t.Fatal(err) + } + if records, err := b.WorkstreamRecordList(appwire.WorkstreamRecordListParams{WorkstreamID: w.ID}); err != nil || len(records.Records) != 1 { + t.Fatalf("source removal changed records: %v %v", records, err) + } +} + +// WP-12 (b): invalid paths, URLs, kind fields, refs and updates return +// bad_params/not_found without rows, timestamps or changed events. +func TestAppWorkstreamSourcesRefusalsOnWire(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Invalid source wire") + path := workstreamSourcesFile(t) + bad := []map[string]any{} + for _, p := range []string{"relative", filepath.Dir(path) + "/../file", filepath.Dir(path) + "/./usage-pricing.md", filepath.Dir(path) + "//usage-pricing.md", path + "/", path + "\x00", path + "\n", filepath.Join(t.TempDir(), "missing"), "/" + strings.Repeat("x", 4096)} { + bad = append(bad, map[string]any{"kind": "file", "path": p}) + } + for _, repo := range []string{"../x", "owner/..", "-owner/x", "owner/name/extra", "relative", path} { + bad = append(bad, map[string]any{"kind": "repo", "repo": repo}) + } + for _, number := range []int{0, -1, 2147483648} { + bad = append(bad, map[string]any{"kind": "issue", "repo": "owner/name", "number": number}) + } + bad = append(bad, map[string]any{"kind": "issue", "repo": filepath.Dir(path), "number": 1}) + for _, url := range []string{"file:///etc/passwd", "javascript:alert(1)", "ftp://h/x", "//h/x", "https:///x", "https://user:pw@h/x", "https://h/x y", "https://h/x\n", "https://h/" + strings.Repeat("x", 2039)} { + bad = append(bad, map[string]any{"kind": "link", "url": url}) + } + bad = append(bad, map[string]any{"kind": "file", "path": path, "url": "https://example.com"}) + for _, title := range []string{"", strings.Repeat("x", 201), "two\nlines", " untrimmed "} { + bad = append(bad, map[string]any{"kind": "file", "path": path, "title": title}) + } + bad = append(bad, map[string]any{"kind": "kept", "ref": map[string]any{"kind": "factory_job", "id": "7", "factory": "f"}}, map[string]any{"kind": "kept", "ref": map[string]any{"kind": "message", "id": "m", "chat_id": "c", "factory": ""}}) + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + before := len(workstreamEvents(rec)) + for i, params := range bad { + params["workstream_id"] = w.ID + frame := connection.call(t, "source.add", params) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { + t.Fatalf("invalid %d: %v", i, frame) + } + } + for _, kind := range []string{"record", "page"} { + frame := connection.call(t, "source.add", map[string]any{"workstream_id": w.ID, "kind": "kept", "ref": map[string]any{"kind": kind, "id": "missing"}}) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "not_found" { + t.Fatalf("unknown kept: %v", frame) + } + } + for _, patch := range []map[string]any{{"id": "missing"}, {"id": "missing", "keep": false}, {"id": "missing", "title": ""}} { + frame := connection.call(t, "source.update", patch) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { + t.Fatalf("invalid update: %v", frame) + } + } + listed := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"].([]any) + after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || len(listed) != 0 || len(workstreamEvents(rec)) != before || !after.Workstream.UpdatedAt.Equal(w.UpdatedAt) { + t.Fatalf("refusal mutated state: %v %v", listed, err) + } +} + +// WP-12 Fix 1 Major 3 (b): literal invalid UTF-8 must be refused before JSON +// can repair source strings, with no source, workstream, agent or event added. +func TestAppWorkstreamMajor3InvalidUTF8BOnWire(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "UTF-8 source wire") + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + seed := workstreamSourceWireCall(t, connection, "source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com", "title": "Original title"})["source"].(map[string]any) + path := filepath.Join(t.TempDir(), "source-�.txt") + if err := os.WriteFile(path, []byte("Replacement-character file exists."), 0600); err != nil { + t.Fatal(err) + } + listParams := map[string]any{"workstream_id": w.ID} + before := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] + rowBefore, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil { + t.Fatal(err) + } + events := len(workstreamEvents(rec)) + for _, tc := range []struct { + name string + fields map[string]any + }{ + {"path", map[string]any{"kind": "file", "path": path}}, + {"url", map[string]any{"kind": "link", "url": "https://example.com/�"}}, + {"title", map[string]any{"kind": "link", "url": "https://example.com/title", "title": "Title �"}}, + } { + for _, method := range []string{"source.add", "workstream.create"} { + t.Run(method+"/"+tc.name, func(t *testing.T) { + params := map[string]any{"name": "Invalid UTF-8 " + tc.name, "sources": []any{tc.fields}} + if method == "source.add" { + params = map[string]any{"workstream_id": w.ID} + for key, value := range tc.fields { + params[key] = value + } + } + raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": method, "params": params}) + raw = bytes.ReplaceAll(raw, []byte("�"), []byte{0xff}) + if !bytes.Contains(raw, []byte{0xff}) { + t.Fatal("fixture must contain a literal invalid UTF-8 byte") + } + frame := connection.callRaw(t, raw) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { + t.Fatalf("invalid raw UTF-8 accepted: %v", frame) + } + }) + } + } + raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": "source.update", "params": map[string]any{"id": seed["id"], "title": "Changed �"}}) + frame := connection.callRaw(t, bytes.ReplaceAll(raw, []byte("�"), []byte{0xff})) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { + t.Fatalf("invalid update accepted: %v", frame) + } + after := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] + rowAfter, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || !reflect.DeepEqual(after, before) || !rowAfter.Workstream.UpdatedAt.Equal(rowBefore.Workstream.UpdatedAt) || *calls != 1 || len(workstreamEvents(rec)) != events { + t.Fatalf("invalid UTF-8 changed state: sources=%v agents=%d err=%v", after, *calls, err) + } + if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 1 { + t.Fatalf("invalid UTF-8 created workstreams: %+v %v", list, err) + } +} + +// WP-12 Fix 1 Major 3 (b): source validation preserves create's existing +// replacement decoding for name and why, and accepts literal valid U+FFFD. +func TestAppWorkstreamMajor3InvalidUTF8BLeavesCreateTextDecoding(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + raw := fmt.Appendf(nil, `{"id":%d,"method":"workstream.create","params":{"name":"Name `, connection.Next+1) + raw = append(raw, 0xff) + raw = append(raw, []byte(`","why":"Why `)...) + raw = append(raw, 0xff) + raw = append(raw, []byte(`","sources":[{"kind":"link","url":"https://example.com","title":"Valid �"}]}}`)...) + created := workstreamWireResult(t, connection.callRaw(t, raw)).(map[string]any)["workstream"].(map[string]any) + if created["name"] != "Name �" || created["why"] != "Why �" { + t.Fatalf("create text decoding changed: %v", created) + } + sources := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": created["id"]})["sources"].([]any) + if len(sources) != 1 || sources[0].(map[string]any)["title"] != "Valid �" { + t.Fatalf("valid replacement character refused: %v", sources) + } +} + +// WP-12 Fix 1 Minor 1 (a), (b): wire adds refuse overlong normalized URLs +// without writes/events, while a returned URL at the bound can be re-added. +func TestAppWorkstreamMinor1LinkBoundABOnWire(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Normalized URL wire") + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + events := len(workstreamEvents(rec)) + params := map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://h/" + strings.Repeat("é", 1019)} + frame := connection.call(t, "source.add", params) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { + t.Fatalf("expanded URL accepted: %v", frame) + } + listParams := map[string]any{"workstream_id": w.ID} + list := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"].([]any) + after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil || len(list) != 0 || len(workstreamEvents(rec)) != events || !after.Workstream.UpdatedAt.Equal(w.UpdatedAt) { + t.Fatalf("invalid URL changed state: %v %v", list, err) + } + params["url"] = "https://h/" + strings.Repeat("é", 339) + "abcd" + created := workstreamSourceWireCall(t, connection, "source.add", params) + item := created["source"].(map[string]any) + want := "https://h/" + strings.Repeat("%C3%A9", 339) + "abcd" + if created["already"] != false || item["url"] != want || len(item["url"].(string)) != 2048 { + t.Fatalf("fitting URL: %v", created) + } + params["url"] = item["url"] + again := workstreamSourceWireCall(t, connection, "source.add", params) + list = workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"].([]any) + if again["already"] != true || !reflect.DeepEqual(again["source"], item) || len(list) != 1 || len(workstreamEvents(rec)) != events+1 { + t.Fatalf("returned URL retry: %v sources=%v", again, list) + } +} + +// WP-12 (b), (e): create refuses every bad source before opening an agent; +// good sources are separate from why and duplicate/client retries add none. +func TestAppWorkstreamCreateSourcesPreflight(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + path := workstreamSourcesFile(t) + good := appwire.WorkstreamSourceAdd{Kind: "file", Path: &path, ReadByJobs: workstreamPtr(true)} + for _, bad := range []appwire.WorkstreamSourceAdd{{Kind: "file", Path: workstreamPtr("relative")}, {Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: "missing"}}} { + _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Free name", Sources: []appwire.WorkstreamSourceAdd{good, bad}}) + workstreamCheckError(t, err, appwire.CodeBadParams) + } + if *calls != 0 || len(workstreamEvents(rec)) != 0 { + t.Fatal("refused source created an agent or event") + } + if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 0 { + t.Fatalf("refused create left rows: %+v %v", list, err) + } + why := "Exactly sent.\n" + p := appwire.WorkstreamCreateParams{Name: "Free name", Why: &why, ClientID: workstreamPtr("source-retry"), Sources: []appwire.WorkstreamSourceAdd{{Kind: "repo", Repo: workstreamPtr("acme/billing")}, good, good}} + created, err := b.WorkstreamCreate(p) + if err != nil || created.Workstream.Why != why || *calls != 1 { + t.Fatalf("create: %+v %v", created, err) + } + p.Sources = []appwire.WorkstreamSourceAdd{{Kind: "link", URL: workstreamPtr("https://example.com/new")}} + if again, err := b.WorkstreamCreate(p); err != nil || again.Workstream.ID != created.Workstream.ID || *calls != 1 { + t.Fatalf("retry: %+v %v", again, err) + } + list, err := b.WorkstreamSourceList(appwire.WorkstreamSourceListParams{WorkstreamID: created.Workstream.ID}) + if err != nil || len(list.Sources) != 2 || list.Sources[0].ReadByJobs || !list.Sources[1].ReadByJobs || list.Sources[1].AddedBy != "you" || list.Sources[1].Proposed { + t.Fatalf("created sources: %+v %v", list, err) + } + tooMany := make([]appwire.WorkstreamSourceAdd, workstream.CreateSourcesMax+1) + for i := range tooMany { + tooMany[i] = good + } + _, err = b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Over limit", Sources: tooMany}) + workstreamCheckError(t, err, appwire.CodeBadParams) +} + +// WP-12 Fix 1 Major 2 (e): identical wire retries bypass source existence +// checks and create no agent, sources or events; new clients keep the checks. +func TestAppWorkstreamMajor2CreateRetryEOnWire(t *testing.T) { + b, rec, calls, _ := workstreamBackend(t) + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + path := workstreamSourcesFile(t) + p := map[string]any{"name": "Retry with removed source", "client_id": "retry-removed", "sources": []any{map[string]any{"kind": "file", "path": path}}} + created := workstreamSourceWireCall(t, connection, "workstream.create", p)["workstream"].(map[string]any) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + listParams := map[string]any{"workstream_id": created["id"]} + before := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] + events := len(workstreamEvents(rec)) + again := workstreamSourceWireCall(t, connection, "workstream.create", p)["workstream"] + after := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] + if !reflect.DeepEqual(again, created) || !reflect.DeepEqual(after, before) || len(after.([]any)) != 1 || *calls != 1 || len(workstreamEvents(rec)) != events { + t.Fatalf("retry changed state: workstream=%v sources=%v calls=%d", again, after, *calls) + } + p["client_id"], p["name"] = "new-client", "New client name" + frame := connection.call(t, "workstream.create", p) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" || *calls != 1 || len(workstreamEvents(rec)) != events { + t.Fatalf("new client bypassed validation: %v", frame) + } + if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 1 { + t.Fatalf("new client wrote a workstream: %+v %v", list, err) + } + delete(p, "sources") + workstreamSourceWireCall(t, connection, "workstream.create", p) +} + +// WP-12 (c), (h): toggles and keeping a proposal use the real wire path; +// done rejects all mutations and reopen restores them. +func TestAppWorkstreamSourceUpdatesAndDoneOnWire(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + proposal, err := door.ProposeSource(ctx, sessionWorkstreamSourceProposalForTest()) + if err != nil { + t.Fatal(err) + } + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + before := len(workstreamEvents(rec)) + frame := connection.call(t, "source.update", map[string]any{"id": proposal.ID, "read_by_jobs": true}) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" || len(workstreamEvents(rec)) != before { + t.Fatalf("proposal jobs: %v", frame) + } + kept := workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "keep": true, "read_by_jobs": true})["source"].(map[string]any) + if kept["proposed"] != false || kept["read_by_jobs"] != true || kept["why"] != "Read the guide." || kept["added_by"] != "agent" { + t.Fatalf("keep: %v", kept) + } + for _, flag := range []bool{false, true} { + workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "read_by_jobs": flag}) + list := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"].([]any) + if len(list) != 1 || list[0].(map[string]any)["read_by_jobs"] != flag { + t.Fatalf("toggle: %v", list) + } + } + before = len(workstreamEvents(rec)) + workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "keep": true}) + if len(workstreamEvents(rec)) != before { + t.Fatal("no-op update sent an event") + } + workstreamSourceWireCall(t, connection, "workstream.mark_done", map[string]any{"id": w.ID}) + before = len(workstreamEvents(rec)) + for _, tc := range []struct { + method string + params map[string]any + }{ + {"source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com/new"}}, + {"source.update", map[string]any{"id": proposal.ID, "title": "Changed"}}, + {"source.remove", map[string]any{"id": proposal.ID}}, + } { + frame := connection.call(t, tc.method, tc.params) + if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "conflict" { + t.Fatalf("done: %v", frame) + } + } + if len(workstreamEvents(rec)) != before { + t.Fatal("done write sent an event") + } + if _, err := door.ProposeSource(ctx, sessionWorkstreamSourceProposalForTest()); err == nil { + t.Fatal("done proposal succeeded") + } + workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID}) + workstreamSourceWireCall(t, connection, "workstream.reopen", map[string]any{"id": w.ID}) + workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "title": "Reopened"}) + workstreamSourceWireCall(t, connection, "source.remove", map[string]any{"id": proposal.ID}) + workstreamSourceWireCall(t, connection, "source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com/new"}) +} + +// WP-12 conformance: four heterogeneous source rows retain exact optional keys +// at each illustrated index, including the agent's reason only on its proposal. +func (fixture *workstreamWireFixture) sources(t *testing.T) { + t.Helper() + b := fixture.Backend + fixture.record(t) + adds := []appwire.WorkstreamSourceAddParams{ + {WorkstreamID: fixture.Stream.ID, Kind: "file", Path: workstreamPtr(fixture.IDs["/Users/ada/billing/usage-pricing.md"]), ReadByJobs: workstreamPtr(true)}, + {WorkstreamID: fixture.Stream.ID, Kind: "issue", Repo: workstreamPtr("acme/billing"), Number: workstreamPtr(412), ReadByJobs: workstreamPtr(true)}, + {WorkstreamID: fixture.Stream.ID, Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: fixture.IDs["rec_7a1e"]}}, + } + for i, ask := range adds { + made, err := b.WorkstreamSourceAdd(ask) + if err != nil { + t.Fatal(err) + } + fixture.IDs[fmt.Sprintf("src_2b7%d", i+1)] = made.Source.ID + } + s, err := b.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + item, _, change, err := s.ProposeSource(context.Background(), fixture.Stream.ID, workstream.SourceAdd{Kind: "link", URL: workstreamPtr("https://docs.stripe.com/billing/subscriptions/usage-based")}, "The metering rules the plan follows.") + if err != nil { + t.Fatal(err) + } + b.workstreamChanged(change) + fixture.IDs["src_2b74"] = item.ID +} + +func workstreamSeedSources(t *testing.T, b *appBackend, id string, total int) { + t.Helper() + store, err := b.collectionStore(false) + if err != nil { + t.Fatal(err) + } + if err := store.Write(context.Background(), func(tx *sql.Tx) error { + statement, err := tx.Prepare(`INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,added_by,added_at) VALUES (?,?,'link',?,'Example',?,'you',100)`) + if err != nil { + return err + } + defer statement.Close() + for i := 0; i < total; i++ { + url := fmt.Sprintf("https://example.com/source/%d", i) + if _, err := statement.Exec(fmt.Sprintf("fixture-source-%d", i), id, url, url); err != nil { + return err + } + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +func workstreamWireSourceError(t *testing.T, fixture *workstreamWireFixture, errorCase workstreamWireErrorCase, params map[string]any) bool { + t.Helper() + if !strings.HasPrefix(errorCase.Name, "source_") { + return false + } + switch { + case strings.HasSuffix(errorCase.Name, "_bad"): + if errorCase.Method == "source.list" || errorCase.Method == "source.add" { + delete(params, "workstream_id") + } else { + delete(params, "id") + } + case strings.HasSuffix(errorCase.Name, "_missing"): + if errorCase.Method == "source.list" || errorCase.Method == "source.add" { + params["workstream_id"] = "missing-workstream" + } else { + params["id"] = "missing-source" + } + case strings.HasSuffix(errorCase.Name, "_done"): + if _, err := fixture.Backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: fixture.Stream.ID}); err != nil { + t.Fatal(err) + } + case strings.HasSuffix(errorCase.Name, "_limit"): + workstreamSeedSources(t, fixture.Backend, fixture.Stream.ID, workstream.SourcesMax) + default: + return false + } + return true +} diff --git a/cmd/codeaf/testdata/workstreams-wire/contract.json b/cmd/codeaf/testdata/workstreams-wire/contract.json index 313f576d9d..fd9f656c9f 100644 --- a/cmd/codeaf/testdata/workstreams-wire/contract.json +++ b/cmd/codeaf/testdata/workstreams-wire/contract.json @@ -28,7 +28,11 @@ "wake.list", "wake.apply", "wake.undo", - "wake.not_now" + "wake.not_now", + "source.list", + "source.add", + "source.update", + "source.remove" ], "events": [ { @@ -160,7 +164,8 @@ "records", "goaheads", "jobs", - "wakes" + "wakes", + "sources" ], "WorkstreamWakeKind": [ "morning", @@ -186,6 +191,19 @@ "undone", "proposed", "dismissed" + ], + "WorkstreamSourceItemKind": [ + "file", + "repo", + "issue", + "link", + "kept" + ], + "WorkstreamKeptKind": [ + "record", + "page", + "message", + "factory_job" ] }, "types": { @@ -925,6 +943,120 @@ "required": true } }, + "WorkstreamKeptRef": { + "kind": { + "type": "WorkstreamKeptKind", + "required": true + }, + "id": { + "type": "string", + "required": true + }, + "chat_id": { + "type": "string", + "required": false + }, + "factory": { + "type": "string", + "required": false + } + }, + "WorkstreamSourceAdd": { + "kind": { + "type": "WorkstreamSourceItemKind", + "required": true + }, + "path": { + "type": "string", + "required": false + }, + "repo": { + "type": "string", + "required": false + }, + "number": { + "type": "integer", + "required": false + }, + "url": { + "type": "string", + "required": false + }, + "ref": { + "type": "WorkstreamKeptRef", + "required": false + }, + "title": { + "type": "string", + "required": false + }, + "read_by_jobs": { + "type": "boolean", + "required": false + } + }, + "WorkstreamSourceItem": { + "id": { + "type": "string", + "required": true + }, + "workstream_id": { + "type": "string", + "required": true + }, + "kind": { + "type": "WorkstreamSourceItemKind", + "required": true + }, + "title": { + "type": "string", + "required": true + }, + "path": { + "type": "string", + "required": false + }, + "repo": { + "type": "string", + "required": false + }, + "number": { + "type": "integer", + "required": false + }, + "url": { + "type": "string", + "required": false + }, + "ref": { + "type": "WorkstreamKeptRef", + "required": false + }, + "read_by_jobs": { + "type": "boolean", + "required": true + }, + "proposed": { + "type": "boolean", + "required": true + }, + "why": { + "type": "string", + "required": false + }, + "added_by": { + "type": "WorkstreamActor", + "required": true + }, + "added_at": { + "type": "string (RFC 3339)", + "required": true + }, + "gone": { + "type": "boolean", + "required": false + } + }, "WorkstreamListParams": {}, "WorkstreamListResult": { "workstreams": { @@ -968,6 +1100,10 @@ "client_id": { "type": "string", "required": false + }, + "sources": { + "type": "WorkstreamSourceAdd[]", + "required": false } }, "WorkstreamCreateResult": { @@ -1491,6 +1627,102 @@ "type": "WorkstreamWakeRun", "required": true } + }, + "WorkstreamSourceListParams": { + "workstream_id": { + "type": "string", + "required": true + } + }, + "WorkstreamSourceListResult": { + "sources": { + "type": "WorkstreamSourceItem[]", + "required": true + } + }, + "WorkstreamSourceAddParams": { + "workstream_id": { + "type": "string", + "required": true + }, + "kind": { + "type": "WorkstreamSourceItemKind", + "required": true + }, + "path": { + "type": "string", + "required": false + }, + "repo": { + "type": "string", + "required": false + }, + "number": { + "type": "integer", + "required": false + }, + "url": { + "type": "string", + "required": false + }, + "ref": { + "type": "WorkstreamKeptRef", + "required": false + }, + "title": { + "type": "string", + "required": false + }, + "read_by_jobs": { + "type": "boolean", + "required": false + } + }, + "WorkstreamSourceAddResult": { + "source": { + "type": "WorkstreamSourceItem", + "required": true + }, + "already": { + "type": "boolean", + "required": true + } + }, + "WorkstreamSourceUpdateParams": { + "id": { + "type": "string", + "required": true + }, + "title": { + "type": "string", + "required": false + }, + "read_by_jobs": { + "type": "boolean", + "required": false + }, + "keep": { + "type": "boolean", + "required": false + } + }, + "WorkstreamSourceUpdateResult": { + "source": { + "type": "WorkstreamSourceItem", + "required": true + } + }, + "WorkstreamSourceRemoveParams": { + "id": { + "type": "string", + "required": true + } + }, + "WorkstreamSourceRemoveResult": { + "source": { + "type": "WorkstreamSourceItem", + "required": true + } } } } diff --git a/cmd/codeaf/testdata/workstreams-wire/source.add.json b/cmd/codeaf/testdata/workstreams-wire/source.add.json new file mode 100644 index 0000000000..fc70f80f17 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/source.add.json @@ -0,0 +1,59 @@ +{ + "method": "source.add", + "errors": [ + "bad_params", + "not_found", + "conflict", + "too_large", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamSourceAddParams", + "result_type": "WorkstreamSourceAddResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a", + "kind": "file", + "path": "/Users/ada/billing/usage-pricing.md", + "read_by_jobs": true + }, + { + "workstream_id": "ws_8f2c1a", + "kind": "link", + "url": "HTTPS://Docs.Stripe.com/billing/subscriptions/usage-based", + "title": "Usage-based billing guide" + } + ], + "result": [ + { + "source": { + "id": "src_2b71", + "workstream_id": "ws_8f2c1a", + "kind": "file", + "title": "usage-pricing.md", + "path": "/Users/ada/billing/usage-pricing.md", + "read_by_jobs": true, + "proposed": false, + "added_by": "you", + "added_at": "2026-11-02T14:05:00Z" + }, + "already": false + }, + { + "source": { + "id": "src_2b75", + "workstream_id": "ws_8f2c1a", + "kind": "link", + "title": "Usage-based billing guide", + "url": "https://docs.stripe.com/billing/subscriptions/usage-based", + "read_by_jobs": false, + "proposed": false, + "added_by": "you", + "added_at": "2026-11-02T14:40:00Z" + }, + "already": false + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.list.json b/cmd/codeaf/testdata/workstreams-wire/source.list.json new file mode 100644 index 0000000000..3251c7924e --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/source.list.json @@ -0,0 +1,73 @@ +{ + "method": "source.list", + "errors": [ + "bad_params", + "not_found", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamSourceListParams", + "result_type": "WorkstreamSourceListResult", + "examples": { + "params": [ + { + "workstream_id": "ws_8f2c1a" + } + ], + "result": [ + { + "sources": [ + { + "id": "src_2b71", + "workstream_id": "ws_8f2c1a", + "kind": "file", + "title": "usage-pricing.md", + "path": "/Users/ada/billing/usage-pricing.md", + "read_by_jobs": true, + "proposed": false, + "added_by": "you", + "added_at": "2026-11-02T14:05:00Z" + }, + { + "id": "src_2b72", + "workstream_id": "ws_8f2c1a", + "kind": "issue", + "title": "acme/billing#412", + "repo": "acme/billing", + "number": 412, + "read_by_jobs": true, + "proposed": false, + "added_by": "you", + "added_at": "2026-11-02T14:06:00Z" + }, + { + "id": "src_2b73", + "workstream_id": "ws_8f2c1a", + "kind": "kept", + "title": "Show the recorded usage.", + "ref": { + "kind": "record", + "id": "rec_7a1e" + }, + "read_by_jobs": false, + "proposed": false, + "added_by": "you", + "added_at": "2026-11-02T14:20:00Z" + }, + { + "id": "src_2b74", + "workstream_id": "ws_8f2c1a", + "kind": "link", + "title": "docs.stripe.com/billing/subscriptions/usage-based", + "url": "https://docs.stripe.com/billing/subscriptions/usage-based", + "read_by_jobs": false, + "proposed": true, + "why": "The metering rules the plan follows.", + "added_by": "agent", + "added_at": "2026-11-02T14:30:00Z" + } + ] + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.remove.json b/cmd/codeaf/testdata/workstreams-wire/source.remove.json new file mode 100644 index 0000000000..141efdd06b --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/source.remove.json @@ -0,0 +1,34 @@ +{ + "method": "source.remove", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamSourceRemoveParams", + "result_type": "WorkstreamSourceRemoveResult", + "examples": { + "params": [ + { + "id": "src_2b71" + } + ], + "result": [ + { + "source": { + "id": "src_2b71", + "workstream_id": "ws_8f2c1a", + "kind": "file", + "title": "usage-pricing.md", + "path": "/Users/ada/billing/usage-pricing.md", + "read_by_jobs": true, + "proposed": false, + "added_by": "you", + "added_at": "2026-11-02T14:05:00Z" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.update.json b/cmd/codeaf/testdata/workstreams-wire/source.update.json new file mode 100644 index 0000000000..2946814af3 --- /dev/null +++ b/cmd/codeaf/testdata/workstreams-wire/source.update.json @@ -0,0 +1,37 @@ +{ + "method": "source.update", + "errors": [ + "bad_params", + "not_found", + "conflict", + "busy", + "unknown_method" + ], + "params_type": "WorkstreamSourceUpdateParams", + "result_type": "WorkstreamSourceUpdateResult", + "examples": { + "params": [ + { + "id": "src_2b74", + "keep": true, + "read_by_jobs": true + } + ], + "result": [ + { + "source": { + "id": "src_2b74", + "workstream_id": "ws_8f2c1a", + "kind": "link", + "title": "docs.stripe.com/billing/subscriptions/usage-based", + "url": "https://docs.stripe.com/billing/subscriptions/usage-based", + "read_by_jobs": true, + "proposed": false, + "why": "The metering rules the plan follows.", + "added_by": "agent", + "added_at": "2026-11-02T14:30:00Z" + } + } + ] + } +} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json b/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json index 2c7a8f387f..6709273c39 100644 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json @@ -12,7 +12,8 @@ "records", "goaheads", "jobs", - "wakes" + "wakes", + "sources" ] }, { diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.create.json b/cmd/codeaf/testdata/workstreams-wire/workstream.create.json index 8b400ede0c..85e5e9e4ac 100644 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.create.json +++ b/cmd/codeaf/testdata/workstreams-wire/workstream.create.json @@ -24,7 +24,19 @@ } ], "model": "openai/gpt-5.2", - "client_id": "create_684a" + "client_id": "create_684a", + "sources": [ + { + "kind": "repo", + "repo": "acme/billing", + "read_by_jobs": true + }, + { + "kind": "file", + "path": "/Users/ada/billing/usage-pricing.md", + "read_by_jobs": true + } + ] } ], "result": [ diff --git a/docs/changes/unreleased/2320-app-workstream-sources.md b/docs/changes/unreleased/2320-app-workstream-sources.md new file mode 100644 index 0000000000..f737b72118 --- /dev/null +++ b/docs/changes/unreleased/2320-app-workstream-sources.md @@ -0,0 +1,28 @@ +--- +kind: added +title: Keep sources and agent source proposals in app workstreams +pr: 2320 +surface: [app] +invalidates: + - Collections database files now become schema 5 on their first successful write; an engine built before this change refuses them, leaving collections unavailable there and all data intact. + - Workstream inputs can now be kept as separate sources instead of adding Repos or Files lines to why. +--- + +Workstreams keep file, repo, issue, link and kept-work pointers through the wire +contract's v0.4 source methods, including atomic initial sources on creation. The +agent reads sources and proposes additions for the person to keep or drop. +Removing a source leaves its target intact. Sources are separate from collection +members, with at most 2,000 per workstream; done workstreams refuse source writes. + +Agent source reads refuse final-component links, including links planted after +a file was added, while allowing intermediate directory links. Create retries +return the original workstream even after an initial source disappears. Invalid +UTF-8 source request bytes are refused before decoding, and links obey the +2,048-byte bound both before and after URL normalization. + +Schemas 3 and 4 remain readable without migration. The first successful write adds +the sources table and advances to schema 5 atomically, preserving existing rows. +Schema 3 first gains the wake tables; schema 4 already has them and keeps every wake. +Older engines refuse that newer database with their existing unsupported-database +error; nothing is lost. Source indexing and jobs consuming Read by jobs selections +are separate follow-ups. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index f80464efd0..f10d8d4d2e 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -1225,6 +1225,20 @@ role pins and checked as the other pins are. |---|---|---| | `slash.catalog` | `{}` | `SlashCatalogResult` | +## source methods + +| method | params | result | +|---|---|---| +| `source.add` | `in.WorkstreamSourceAddParams` | `WorkstreamSourceAddResult` | +| `source.list` | `in.WorkstreamSourceListParams` | `WorkstreamSourceListResult` | +| `source.remove` | `in.WorkstreamSourceRemoveParams` | `WorkstreamSourceRemoveResult` | +| `source.update` | `in.WorkstreamSourceUpdateParams` | `WorkstreamSourceUpdateResult` | + +A workstream's sources: files, repos, issues, links and things kept from its work. Each is a +pointer in its own table, never a collection member; removing one never touches what it names. +The engine's workstream agent only proposes sources; the person keeps or drops them. Served with +the hello feature `workstream_sources`. + ## One engine on a home diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go index 95aa4b56a5..94a8de39b0 100644 --- a/internal/appwire/workstreams.go +++ b/internal/appwire/workstreams.go @@ -9,10 +9,11 @@ import ( ) const ( - FeatureWorkstreams = "workstreams" - EventWorkstreamChanged = "workstream.changed" - CodeInOtherWorkstream = "in_other_workstream" - CodeVersionConflict = "version_conflict" + FeatureWorkstreams = "workstreams" + FeatureWorkstreamSources = "workstream_sources" + EventWorkstreamChanged = "workstream.changed" + CodeInOtherWorkstream = "in_other_workstream" + CodeVersionConflict = "version_conflict" ) // ErrWorkstreamsNotYet is the capability refusal for a shapes-only engine. @@ -49,6 +50,10 @@ var WorkstreamMethods = []string{ "wake.apply", "wake.undo", "wake.not_now", + "source.list", + "source.add", + "source.update", + "source.remove", } // Optional numbers and booleans use pointers so known zero and false survive. @@ -229,7 +234,7 @@ type WorkstreamGoAhead struct { type WorkstreamChanged struct { ID string `json:"id"` - Parts []string `json:"parts,omitempty" enum:"workstream,plan,pages,records,goaheads,jobs,wakes"` + Parts []string `json:"parts,omitempty" enum:"workstream,plan,pages,records,goaheads,jobs,wakes,sources"` } type WorkstreamTaken struct { @@ -269,12 +274,13 @@ type WorkstreamGetResult struct { } type WorkstreamCreateParams struct { - Name string `json:"name"` - Why *string `json:"why,omitempty"` - OriginChatID *string `json:"origin_chat_id,omitempty"` - Refs []WorkstreamRef `json:"refs,omitempty"` - Model *string `json:"model,omitempty"` - ClientID *string `json:"client_id,omitempty"` + Name string `json:"name"` + Why *string `json:"why,omitempty"` + OriginChatID *string `json:"origin_chat_id,omitempty"` + Refs []WorkstreamRef `json:"refs,omitempty"` + Model *string `json:"model,omitempty"` + ClientID *string `json:"client_id,omitempty"` + Sources []WorkstreamSourceAdd `json:"sources,omitempty"` } type WorkstreamCreateResult struct { @@ -605,6 +611,10 @@ type Workstreamer interface { WorkstreamWakeApply(WorkstreamWakeApplyParams) (WorkstreamWakeApplyResult, error) WorkstreamWakeUndo(WorkstreamWakeUndoParams) (WorkstreamWakeUndoResult, error) WorkstreamWakeNotNow(WorkstreamWakeNotNowParams) (WorkstreamWakeNotNowResult, error) + WorkstreamSourceList(WorkstreamSourceListParams) (WorkstreamSourceListResult, error) + WorkstreamSourceAdd(WorkstreamSourceAddParams) (WorkstreamSourceAddResult, error) + WorkstreamSourceUpdate(WorkstreamSourceUpdateParams) (WorkstreamSourceUpdateResult, error) + WorkstreamSourceRemove(WorkstreamSourceRemoveParams) (WorkstreamSourceRemoveResult, error) } // workstreamCall preserves the params/result types used by the schema generator. @@ -666,12 +676,16 @@ func init() { methods["wake.apply"] = workstreamCall(Workstreamer.WorkstreamWakeApply) methods["wake.undo"] = workstreamCall(Workstreamer.WorkstreamWakeUndo) methods["wake.not_now"] = workstreamCall(Workstreamer.WorkstreamWakeNotNow) + methods["source.list"] = workstreamCall(Workstreamer.WorkstreamSourceList) + methods["source.add"] = workstreamCall(Workstreamer.WorkstreamSourceAdd) + methods["source.update"] = workstreamCall(Workstreamer.WorkstreamSourceUpdate) + methods["source.remove"] = workstreamCall(Workstreamer.WorkstreamSourceRemove) eventShapes[EventWorkstreamChanged] = reflect.TypeFor[WorkstreamChanged]() } func withWorkstreamsFeature(hello Hello, e Engine) Hello { if w, ok := e.(Workstreamer); ok && w.ServesWorkstreams() { - return withFeature(hello, FeatureWorkstreams) + return withFeature(withFeature(hello, FeatureWorkstreams), FeatureWorkstreamSources) } return hello } diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go index 28efde3f8a..286f1077de 100644 --- a/internal/appwire/workstreams_check.go +++ b/internal/appwire/workstreams_check.go @@ -235,6 +235,9 @@ func (p WorkstreamCreateParams) Check() error { return err } } + if len(p.Sources) > workstream.CreateSourcesMax { + return Errorf(CodeBadParams, "create takes at most %d sources", workstream.CreateSourcesMax) + } return nil } diff --git a/internal/appwire/workstreams_sources.go b/internal/appwire/workstreams_sources.go new file mode 100644 index 0000000000..9fb97e8888 --- /dev/null +++ b/internal/appwire/workstreams_sources.go @@ -0,0 +1,146 @@ +package appwire + +import ( + "encoding/json" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workstream" +) + +type WorkstreamKeptRef struct { + Kind string `json:"kind" enum:"record,page,message,factory_job"` + ID string `json:"id"` + ChatID *string `json:"chat_id,omitempty"` + Factory *string `json:"factory,omitempty"` +} + +type WorkstreamSourceAdd struct { + Kind string `json:"kind" enum:"file,repo,issue,link,kept"` + Path *string `json:"path,omitempty"` + Repo *string `json:"repo,omitempty"` + Number *int `json:"number,omitempty"` + URL *string `json:"url,omitempty"` + Ref *WorkstreamKeptRef `json:"ref,omitempty"` + Title *string `json:"title,omitempty"` + ReadByJobs *bool `json:"read_by_jobs,omitempty"` +} + +type WorkstreamSourceItem struct { + ID string `json:"id"` + WorkstreamID string `json:"workstream_id"` + Kind string `json:"kind" enum:"file,repo,issue,link,kept"` + Title string `json:"title"` + Path *string `json:"path,omitempty"` + Repo *string `json:"repo,omitempty"` + Number *int `json:"number,omitempty"` + URL *string `json:"url,omitempty"` + Ref *WorkstreamKeptRef `json:"ref,omitempty"` + ReadByJobs bool `json:"read_by_jobs"` + Proposed bool `json:"proposed"` + Why *string `json:"why,omitempty"` + AddedBy string `json:"added_by" enum:"you,agent"` + AddedAt time.Time `json:"added_at"` + Gone *bool `json:"gone,omitempty"` +} + +type WorkstreamSourceListParams struct { + WorkstreamID string `json:"workstream_id"` +} +type WorkstreamSourceListResult struct { + Sources []WorkstreamSourceItem `json:"sources"` +} + +type WorkstreamSourceAddParams struct { + WorkstreamID string `json:"workstream_id"` + Kind string `json:"kind" enum:"file,repo,issue,link,kept"` + Path *string `json:"path,omitempty"` + Repo *string `json:"repo,omitempty"` + Number *int `json:"number,omitempty"` + URL *string `json:"url,omitempty"` + Ref *WorkstreamKeptRef `json:"ref,omitempty"` + Title *string `json:"title,omitempty"` + ReadByJobs *bool `json:"read_by_jobs,omitempty"` +} +type WorkstreamSourceAddResult struct { + Source WorkstreamSourceItem `json:"source"` + Already bool `json:"already"` +} + +type WorkstreamSourceUpdateParams struct { + ID string `json:"id"` + Title *string `json:"title,omitempty"` + ReadByJobs *bool `json:"read_by_jobs,omitempty"` + Keep *bool `json:"keep,omitempty"` +} +type WorkstreamSourceUpdateResult struct { + Source WorkstreamSourceItem `json:"source"` +} +type WorkstreamSourceRemoveParams struct { + ID string `json:"id"` +} +type WorkstreamSourceRemoveResult struct { + Source WorkstreamSourceItem `json:"source"` +} + +// Check raw source bytes before the JSON decoder can replace invalid UTF-8. +func workstreamDecodeSource(raw []byte, target any) error { + if !utf8.Valid(raw) { + return Errorf(CodeBadParams, "source fields must be valid UTF-8") + } + return json.Unmarshal(raw, target) +} + +func (p *WorkstreamSourceAdd) UnmarshalJSON(raw []byte) error { + type plain WorkstreamSourceAdd + return workstreamDecodeSource(raw, (*plain)(p)) +} + +func (p *WorkstreamSourceAddParams) UnmarshalJSON(raw []byte) error { + type plain WorkstreamSourceAddParams + return workstreamDecodeSource(raw, (*plain)(p)) +} + +func (p *WorkstreamSourceUpdateParams) UnmarshalJSON(raw []byte) error { + type plain WorkstreamSourceUpdateParams + return workstreamDecodeSource(raw, (*plain)(p)) +} + +// SourceAdd preserves optional field presence for the domain's kind checks. +func (p WorkstreamSourceAdd) SourceAdd() workstream.SourceAdd { + out := workstream.SourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Title: p.Title} + if p.ReadByJobs != nil { + out.ReadByJobs = *p.ReadByJobs + } + if p.Ref != nil { + out.Ref = &workstream.KeptRef{Kind: p.Ref.Kind, ID: p.Ref.ID, ChatID: p.Ref.ChatID, Factory: p.Ref.Factory} + } + return out +} + +func (p WorkstreamSourceAddParams) SourceAdd() workstream.SourceAdd { + return (WorkstreamSourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Ref: p.Ref, Title: p.Title, ReadByJobs: p.ReadByJobs}).SourceAdd() +} + +func (p WorkstreamSourceListParams) Check() error { return workstreamID(p.WorkstreamID) } +func (p WorkstreamSourceRemoveParams) Check() error { return workstreamID(p.ID) } + +func (p WorkstreamSourceAddParams) Check() error { + if err := workstreamID(p.WorkstreamID); err != nil { + return err + } + if err := workstream.ValidateSourceAdd(p.SourceAdd()); err != nil { + return Errorf(CodeBadParams, "%s", err) + } + return nil +} + +func (p WorkstreamSourceUpdateParams) Check() error { + if err := workstreamID(p.ID); err != nil { + return err + } + if err := workstream.ValidateSourcePatch(workstream.SourcePatch{Title: p.Title, ReadByJobs: p.ReadByJobs, Keep: p.Keep}); err != nil { + return Errorf(CodeBadParams, "%s", err) + } + return nil +} diff --git a/internal/appwire/workstreams_sources_test.go b/internal/appwire/workstreams_sources_test.go new file mode 100644 index 0000000000..7804081326 --- /dev/null +++ b/internal/appwire/workstreams_sources_test.go @@ -0,0 +1,63 @@ +package appwire + +import ( + "encoding/json" + "reflect" + "testing" +) + +// WP-12 wire capability: both features follow the same hook and remain absent +// for stub/plain engines. Optional fields omit while required false flags stay. +func TestWorkstreamSourcesFeatureAndRequiredBooleans(t *testing.T) { + for _, serves := range []bool{false, true} { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: serves} + hello := withWorkstreamsFeature(withWorkstreamsFeature(Hello{}, fake), fake) + want := []string(nil) + if serves { + want = []string{FeatureWorkstreams, FeatureWorkstreamSources} + } + if !reflect.DeepEqual(hello.Features, want) { + t.Fatalf("features: %v", hello.Features) + } + } + raw, err := json.Marshal(WorkstreamSourceItem{ID: "source", WorkstreamID: "stream", Kind: "link", Title: "Example", AddedBy: "you"}) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + for _, key := range []string{"read_by_jobs", "proposed"} { + if value, exists := fields[key]; !exists || value != false { + t.Fatalf("required false missing: %s", raw) + } + } + for _, key := range []string{"path", "repo", "number", "url", "ref", "why", "gone"} { + if _, exists := fields[key]; exists { + t.Fatalf("absent optional present: %s", raw) + } + } +} + +// WP-12 (b): typed dispatch rejects missing/null fields before reaching the +// serving engine, including inside a kept ref and create's sources array. +func TestWorkstreamSourcesRequiredFieldsOnWire(t *testing.T) { + fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} + call := serveOver(t, fake) + for _, tc := range []struct{ method, params string }{ + {"source.list", `{}`}, + {"source.add", `{"workstream_id":"w","kind":"link","url":null}`}, + {"source.add", `{"workstream_id":"w","kind":"kept","ref":{"kind":"message","chat_id":"c"}}`}, + {"source.update", `{"id":"s","keep":null}`}, + {"source.remove", `{"id":null}`}, + {"workstream.create", `{"name":"Sources","sources":[{"path":"x"}]}`}, + {"workstream.create", `{"name":"Sources","sources":null}`}, + } { + frame := call(`{"id":1,"method":"` + tc.method + `","params":` + tc.params + `}`) + refusal, _ := frame["error"].(map[string]any) + if refusal["code"] != CodeBadParams { + t.Fatalf("%s: %v", tc.method, frame) + } + } +} diff --git a/internal/appwire/workstreams_test.go b/internal/appwire/workstreams_test.go index 9d1e9a4c3a..679db8219a 100644 --- a/internal/appwire/workstreams_test.go +++ b/internal/appwire/workstreams_test.go @@ -118,6 +118,19 @@ func (f *fakeWorkstreamer) WorkstreamGoAheadRevoke(WorkstreamGoAheadRevokeParams return WorkstreamGoAheadRevokeResult{}, ErrWorkstreamsNotYet } +func (f *fakeWorkstreamer) WorkstreamSourceList(WorkstreamSourceListParams) (WorkstreamSourceListResult, error) { + return WorkstreamSourceListResult{}, ErrWorkstreamsNotYet +} +func (f *fakeWorkstreamer) WorkstreamSourceAdd(WorkstreamSourceAddParams) (WorkstreamSourceAddResult, error) { + return WorkstreamSourceAddResult{}, ErrWorkstreamsNotYet +} +func (f *fakeWorkstreamer) WorkstreamSourceUpdate(WorkstreamSourceUpdateParams) (WorkstreamSourceUpdateResult, error) { + return WorkstreamSourceUpdateResult{}, ErrWorkstreamsNotYet +} +func (f *fakeWorkstreamer) WorkstreamSourceRemove(WorkstreamSourceRemoveParams) (WorkstreamSourceRemoveResult, error) { + return WorkstreamSourceRemoveResult{}, ErrWorkstreamsNotYet +} + func workstreamHelloHasFeature(hello map[string]any) bool { for _, feature := range features(hello) { if feature == FeatureWorkstreams { @@ -127,10 +140,10 @@ func workstreamHelloHasFeature(hello map[string]any) bool { return false } -// V1: the contract index and dispatch table name exactly the same 29 methods. +// V1, WP-12: the v0.4 contract index and dispatch table name the same 33 methods. func TestEveryWorkstreamMethodIsRegistered(t *testing.T) { - if len(WorkstreamMethods) != 29 { - t.Fatalf("contract has %d methods, want 29", len(WorkstreamMethods)) + if len(WorkstreamMethods) != 33 { + t.Fatalf("contract has %d methods, want 33", len(WorkstreamMethods)) } known := make(map[string]bool, len(WorkstreamMethods)) served := Methods() @@ -144,7 +157,7 @@ func TestEveryWorkstreamMethodIsRegistered(t *testing.T) { } } for _, name := range served { - for _, prefix := range []string{"workstream.", "page.", "record.", "goahead.", "wake."} { + for _, prefix := range []string{"workstream.", "page.", "record.", "goahead.", "wake.", "source."} { if strings.HasPrefix(name, prefix) && !known[name] { t.Errorf("registered method not in contract: %s", name) } @@ -205,8 +218,8 @@ func TestWorkstreamsFeatureFollowsTheEngine(t *testing.T) { } hello := withWorkstreamsFeature(Hello{}, fake) hello = withWorkstreamsFeature(hello, fake) - if len(hello.Features) != 1 || hello.Features[0] != FeatureWorkstreams { - t.Fatal("feature hook must add workstreams exactly once") + if len(hello.Features) != 2 || hello.Features[0] != FeatureWorkstreams || hello.Features[1] != FeatureWorkstreamSources { + t.Fatal("feature hook must add workstreams and sources exactly once") } } diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 2ff81605cb..4b540d1752 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -176,9 +176,9 @@ 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 +A workstream's own agent in the app has seven tools. Other conversations, tasks and errands do not receive them. `workstream_read` reads the workstream, pages, -decisions, questions, go-ahead proposals, plan and grants. Counts, costs, states and dates come +decisions, questions, go-ahead proposals, plan, grants and sources. 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. @@ -275,3 +275,57 @@ with one turn at a time. A running wake left over 15 minutes becomes failed on the next check or list: “Stopped before it finished.” Its cost is unknown. An unreadable answer says “The agent's answer could not be read.” Any recorded cost still counts toward spend. + +## Workstream sources: files, repos, issues, links and kept work + +The app's workstream Sources are pointers kept with `source.add` and returned +oldest first by `source.list`. Five kinds are available: a file or folder on +this computer; a repo named `owner/name` or a local folder; an issue with its +repo and number; an HTTP(S) link; or a kept record, page, message or member +Factory job. They are separate from collection members and canvases. Removing +a source with `source.remove` removes only its pointer and leaves its target +intact. The engine does not copy or index source contents. + +File paths must exist, be absolute and already clean, with no control +characters, dot segments, doubled separators or trailing separator. Local +repos must be folders. Source strings must be valid UTF-8. Paths take at most +4,096 bytes; links at most 2,048 both as sent and after URL normalization, +with a host and no whitespace or credentials. Titles take 1–200 bytes of +trimmed text on one line; absent titles are derived from the target. Issue +numbers run from 1 to 2,147,483,647. Kept reference IDs take 1–128 bytes. +Kept records and live pages must belong to the workstream; a kept job must +be a member. A missing file or local repo gets `gone: true` when listed. + +`workstream.create` accepts up to 200 initial sources, excluding kept work. +All are checked before creation, and a refusal creates no agent or collection. +Repeating a client ID returns its first workstream without rechecking source +paths, even if a file has since disappeared. Duplicate targets keep one source. +Sources never add text to the workstream's +why. A workstream holds at most 2,000 sources, proposals included; the next +addition returns `too_large`. Done workstreams permit listing but refuse +adding, updating and removing until reopened. + +## Agent source proposals, Read by jobs and reading content + +`propose_source` asks the person to keep or drop a source, with a reason of +1–500 bytes. A proposal is marked `added_by: agent`, `proposed: true` and +`read_by_jobs: false`. `source.update` with `keep: true` keeps it; removing it +drops it. Keeping preserves the reason and author. Adding the same proposed +target as the person also keeps it. Repeating an existing target adds nothing. + +`source.update` changes the title or Read by jobs, or keeps a proposal. At +least one field is required. `keep: false` is refused, and enabling Read by +jobs on a proposal requires keeping it in the same request. A change that +alters nothing sends no event. Jobs must exclude proposals; consuming the +Read by jobs selection is the Factory plan step's responsibility. + +The workstream agent reads the list with `workstream_read` (`what: sources`) +as complete JSON, up to the 2,000-source limit. It reads one source with +`what: source` and `source_id`. A regular UTF-8 text file is read within the +existing 60,000-byte answer cap, with “The file was cut.” +when cut. Binary or unreadable files get a one-line explanation. A link in the +final path component answers “The source is a link and was not read.” Links in +intermediate folders remain usable. Folders list up to 200 entry names without +following entries. Kept pages render as Markdown and records as +text; named repos, issues, links, messages and jobs remain pointers. Reading +never writes the target. These tools remain scoped to the workstream's agent. diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index 525d82fa57..2ca23269b3 100644 --- a/internal/session/actioncategory.go +++ b/internal/session/actioncategory.go @@ -235,7 +235,7 @@ 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", - "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page": + "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "propose_source", "write_agent_page": return ActionCreate // Executing and waiting on what it prints. diff --git a/internal/session/workstream_contract.go b/internal/session/workstream_contract.go index d20e62dc2c..2f6874fb6f 100644 --- a/internal/session/workstream_contract.go +++ b/internal/session/workstream_contract.go @@ -18,6 +18,7 @@ type WorkstreamDoor interface { Plan(context.Context) (WorkstreamPlanState, error) SetPlan(context.Context, int, WorkstreamPlanState) (WorkstreamPlanState, error) WritePage(context.Context, WorkstreamPageWrite) (WorkstreamPageWritten, error) + ProposeSource(context.Context, WorkstreamSourceProposal) (WorkstreamSourceProposed, error) } var ErrWorkstreamMissing = errors.New("No workstream holds this conversation.") @@ -40,10 +41,11 @@ type WorkstreamBrief struct { } type WorkstreamReadAsk struct { - What string `json:"what"` - PageID string `json:"page_id,omitempty"` - Kind string `json:"kind,omitempty"` - State string `json:"state,omitempty"` + What string `json:"what"` + PageID string `json:"page_id,omitempty"` + SourceID string `json:"source_id,omitempty"` + Kind string `json:"kind,omitempty"` + State string `json:"state,omitempty"` } type WorkstreamProposal struct { diff --git a/internal/session/workstream_schema.go b/internal/session/workstream_schema.go index 9541ef873a..e319e03f19 100644 --- a/internal/session/workstream_schema.go +++ b/internal/session/workstream_schema.go @@ -7,11 +7,12 @@ import ( "io" "sort" "strings" + "unicode/utf8" ) 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","goahead"]},"state":{"type":"string","enum":["proposed","kept","dropped","superseded"]}},"required":["what"],"additionalProperties":false}` +const workstreamReadSchema = `{"type":"object","properties":{"what":{"type":"string","enum":["workstream","pages","page","records","plan","goaheads","sources","source"]},"page_id":{"type":"string"},"source_id":{"type":"string"},"kind":{"type":"string","enum":["decision","question","goahead"]},"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) @@ -30,6 +31,9 @@ type workstreamArgumentSchema struct { } func workstreamDecode(args json.RawMessage, schema workstreamArgumentSchema, target any) (map[string]any, error) { + if _, source := target.(*WorkstreamSourceProposal); source && !utf8.Valid(args) { + return nil, fmt.Errorf("source arguments must be valid UTF-8") + } if err := decodeToolArguments(args, target); err != nil { return nil, err } diff --git a/internal/session/workstream_sources.go b/internal/session/workstream_sources.go new file mode 100644 index 0000000000..ccb77c3705 --- /dev/null +++ b/internal/session/workstream_sources.go @@ -0,0 +1,53 @@ +package session + +import ( + "context" + "fmt" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// WorkstreamSourceReadMax lets the app bound reads before JSON encoding. +const WorkstreamSourceReadMax = workstreamReadMax + +type WorkstreamSourceProposal struct { + Kind string `json:"kind"` + Path *string `json:"path,omitempty"` + Repo *string `json:"repo,omitempty"` + Number *int `json:"number,omitempty"` + URL *string `json:"url,omitempty"` + Ref *workstream.KeptRef `json:"ref,omitempty"` + Title *string `json:"title,omitempty"` + Why string `json:"why"` +} + +type WorkstreamSourceProposed struct { + ID string + Already bool +} + +func (p WorkstreamSourceProposal) SourceAdd() workstream.SourceAdd { + return workstream.SourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Ref: p.Ref, Title: p.Title} +} + +var workstreamProposeSourceSchema = fmt.Sprintf(`{"type":"object","properties":{"kind":{"type":"string","enum":["file","repo","issue","link","kept"]},"path":{"type":"string","minLength":1,"maxLength":%d},"repo":{"type":"string"},"number":{"type":"integer","minimum":1,"maximum":%d},"url":{"type":"string","minLength":1,"maxLength":%d},"ref":{"type":"object","properties":{"kind":{"type":"string","enum":["record","page","message","factory_job"]},"id":{"type":"string","minLength":1,"maxLength":%d},"chat_id":{"type":"string","minLength":1,"maxLength":%d},"factory":{"type":"string","minLength":1,"maxLength":%d}},"required":["kind","id"],"additionalProperties":false},"title":{"type":"string","minLength":1,"maxLength":%d},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["kind","why"],"additionalProperties":false}`, workstream.SourcePathMax, workstream.SourceIssueMax, workstream.SourceURLMax, workstream.SourceRefMax, workstream.SourceRefMax, workstream.SourceRefMax, workstream.SourceTitleMax, workstream.SourceWhyMax) + +func workstreamSourceTool(door WorkstreamDoor) bare.Tool { + return workstreamTool("propose_source", "Propose a source with a reason. The person keeps or drops it; jobs cannot read a proposal.", workstreamProposeSourceSchema, func(ctx context.Context, ask WorkstreamSourceProposal) (string, error) { + if err := workstream.ValidateSourceWhy(ask.Why); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + if err := workstream.ValidateSourceAdd(ask.SourceAdd()); err != nil { + return "", &toolArgumentError{repair: err.Error()} + } + out, err := door.ProposeSource(ctx, ask) + if err != nil { + return "", err + } + if out.Already { + return "Source " + out.ID + " is already present. The person keeps or drops it.", nil + } + return "Proposed source " + out.ID + ". The person keeps or drops it.", nil + }) +} diff --git a/internal/session/workstream_sources_test.go b/internal/session/workstream_sources_test.go new file mode 100644 index 0000000000..040bdcda0f --- /dev/null +++ b/internal/session/workstream_sources_test.go @@ -0,0 +1,105 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// WP-12 (g): the new tool reaches only its proposal door and tells the person +// who keeps or drops it. Its strict schema refuses invented fields and nulls. +func TestWorkstreamProposeSourceToolAndStrictArguments(t *testing.T) { + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, "propose_source", `{"kind":"link","url":"https://example.com","why":"Read the guide."}`, false) + if !strings.Contains(got, "source-1") || !strings.Contains(got, "person keeps or drops") || len(d.sourceProposals) != 1 || len(d.proposals) != 0 || len(d.saves) != 0 || len(d.pageWrites) != 0 { + t.Fatalf("proposal: %q %+v", got, d) + } + for _, args := range []string{ + `{"kind":"link","url":"https://example.com","why":"Read it.","read_by_jobs":true}`, + `{"kind":"link","url":"https://example.com","why":"Read it.","proposed":false}`, + `{"kind":"file","path":"relative","why":"Read it."}`, + `{"kind":"link","url":"file:///etc/passwd","why":"Read it."}`, + `{"kind":"link","url":"https://example.com","why":""}`, + `{"kind":"link","url":"https://example.com","why":"` + strings.Repeat("x", 501) + `"}`, + `{"kind":"link","url":"https://example.com","why":"Read it.","title":null}`, + `{"kind":"kept","ref":{"kind":"message","id":"m","chat_id":"c","extra":true},"why":"Read it."}`, + `{"kind":"kept","ref":{"kind":"message","id":"m","id":"m","chat_id":"c"},"why":"Read it."}`, + `{"kind":"link","url":"https://example.com","url":"https://example.com","why":"Read it."}`, + } { + d := &workstreamFakeDoor{} + ctx, mark := withToolRejection(context.Background()) + text, refused, err := workstreamToolForTest(t, d, "propose_source").Execute(ctx, json.RawMessage(args)) + if err != nil || !refused || !mark.RejectedBeforeRun() || d.otherCalls != 0 { + t.Fatalf("invalid proposal: %s %q %+v %v", args, text, d, err) + } + } +} + +// WP-12 Fix 1 Major 3 (b), (g): a raw invalid path is refused before the +// proposal door, even when its JSON-repaired replacement would be a real file. +func TestWorkstreamMajor3InvalidUTF8BProposeSource(t *testing.T) { + path := filepath.Join(t.TempDir(), "source-�.txt") + if err := os.WriteFile(path, []byte("Existing replacement path."), 0600); err != nil { + t.Fatal(err) + } + args, err := json.Marshal(map[string]any{"kind": "file", "path": path, "why": "Read the source."}) + if err != nil { + t.Fatal(err) + } + d := &workstreamFakeDoor{} + ctx, mark := withToolRejection(context.Background()) + invalid := bytes.ReplaceAll(args, []byte("�"), []byte{0xff}) + if !bytes.Contains(invalid, []byte{0xff}) { + t.Fatal("fixture must contain a literal invalid UTF-8 byte") + } + answer, refused, err := workstreamToolForTest(t, d, "propose_source").Execute(ctx, invalid) + if err != nil || !refused || !mark.RejectedBeforeRun() || !strings.Contains(answer, "UTF-8") || d.otherCalls != 0 || len(d.sourceProposals) != 0 { + t.Fatalf("invalid raw path reached proposal door: %q %+v %v", answer, d, err) + } + workstreamCallForTest(t, d, "propose_source", string(args), false) + if len(d.sourceProposals) != 1 || *d.sourceProposals[0].Path != path { + t.Fatalf("valid replacement character proposal: %+v", d.sourceProposals) + } +} + +// WP-12 (g): sources and source are valid reads; source_id is required only +// for the latter and never permits another part's selector fields. +func TestWorkstreamSourceReadArguments(t *testing.T) { + for _, args := range []string{`{"what":"sources"}`, `{"what":"source","source_id":"source-1"}`} { + d := &workstreamFakeDoor{read: json.RawMessage(`{"sources":[]}`)} + workstreamCallForTest(t, d, "workstream_read", args, false) + if d.otherCalls != 1 { + t.Fatal("source read did not reach the door") + } + } + for _, args := range []string{`{"what":"source"}`, `{"what":"source","source_id":""}`, `{"what":"sources","source_id":"source-1"}`, `{"what":"source","source_id":"source-1","page_id":"page-1"}`, `{"what":"source","source_id":"source-1","kind":"decision"}`} { + d := &workstreamFakeDoor{} + workstreamCallForTest(t, d, "workstream_read", args, true) + if d.otherCalls != 0 { + t.Fatal("invalid source selector reached the door") + } + } +} + +// WP-12 (g): every source remains in the JSON inventory even when it exceeds +// the content-read cap; page and file content retain their existing bounds. +func TestWorkstreamSourceListKeepsEveryRowAsJSON(t *testing.T) { + items := make([]map[string]any, 2000) + for i := range items { + items[i] = map[string]any{"id": fmt.Sprintf("source-%d", i), "workstream_id": "w", "kind": "link", "title": strings.Repeat("x", 100), "url": "https://example.com", "read_by_jobs": false, "proposed": false, "added_by": "you", "added_at": "2026-11-03T12:00:00Z"} + } + raw, err := json.Marshal(map[string]any{"sources": items}) + if err != nil { + t.Fatal(err) + } + d := &workstreamFakeDoor{read: raw} + got := workstreamCallForTest(t, d, "workstream_read", `{"what":"sources"}`, false) + if len(got) <= workstreamReadMax || !json.Valid([]byte(got)) || got != string(raw) { + t.Fatal("source inventory was cut or changed") + } +} diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go index e53f2b1cbe..1eb1a26590 100644 --- a/internal/session/workstream_tools.go +++ b/internal/session/workstream_tools.go @@ -24,7 +24,8 @@ const workstreamPromptRules = "\n- You propose; the person keeps. Decisions, que "- 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" + - "- Keeping a go-ahead proposal grants it. You start nothing; the person launches jobs." + "- Keeping a go-ahead proposal grants it. You start nothing; the person launches jobs.\n" + + "- Sources: workstream_read (sources, source) reads; propose_source proposes. The person keeps them." func (c Config) mayWorkstream() bool { return c.Workstream != nil && !c.readingTask() && !c.InTask && !c.Errand @@ -76,6 +77,7 @@ func (a *Agent) workstreamToolsAt(now func() time.Time) []bare.Tool { }), workstreamRecordTool(door, "decision"), workstreamRecordTool(door, "question"), + workstreamSourceTool(door), 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) }), @@ -127,6 +129,10 @@ func workstreamFieldRules(fields map[string]any, ask any) error { if page != (ask.What == "page") { return errors.New("page_id is required for and only for page") } + _, source := fields["source_id"] + if source != (ask.What == "source") || source && strings.TrimSpace(ask.SourceID) == "" { + return errors.New("source_id is required for and only for source") + } if ask.What != "records" { return workstreamRefuseFields(fields, "kind", "state") } @@ -207,7 +213,8 @@ func workstreamRead(ctx context.Context, door WorkstreamDoor, ask WorkstreamRead if ask.What == "workstream" { text = "Numbers absent below are unknown; say so and never estimate them.\n" + text } - if len(text) > workstreamReadMax { + // Source inventories have a row bound and must remain complete JSON. + if len(text) > workstreamReadMax && ask.What != "sources" { const tail = "\nThe workstream read was cut." text = workstreamCut(text, workstreamReadMax-len(tail)) + tail } diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go index 8cf44e6a9a..85f81e0faa 100644 --- a/internal/session/workstream_tools_test.go +++ b/internal/session/workstream_tools_test.go @@ -14,21 +14,22 @@ import ( ) 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 - sourceCalls int - sourceError error + 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 + sourceCalls int + sourceError error + sourceProposals []WorkstreamSourceProposal } func (d *workstreamFakeDoor) Source(_ context.Context, source *workstream.Source) (*workstream.Source, error) { @@ -55,6 +56,12 @@ func (d *workstreamFakeDoor) Propose(_ context.Context, p WorkstreamProposal) (W return WorkstreamProposed{ID: "record-1"}, nil } +func (d *workstreamFakeDoor) ProposeSource(_ context.Context, p WorkstreamSourceProposal) (WorkstreamSourceProposed, error) { + d.otherCalls++ + d.sourceProposals = append(d.sourceProposals, p) + return WorkstreamSourceProposed{ID: "source-1"}, d.sourceError +} + func (d *workstreamFakeDoor) Plan(context.Context) (WorkstreamPlanState, error) { d.reads++ return d.plan, nil @@ -111,8 +118,8 @@ func TestWorkstreamAgentToolsAndPromptAreScoped(t *testing.T) { 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 len(with.belt()) != len(belt)+7 || len(with.workstreamTools()) != 7 { + t.Fatal("the workstream must add exactly seven tools") } if !reflect.DeepEqual(workstreamOrdinaryBelt(belt), workstreamOrdinaryBelt(with.belt())) { t.Fatal("the workstream changed the ordinary belt's bytes") @@ -197,7 +204,7 @@ func workstreamPromptUnderLock(a *Agent) string { } func workstreamOrdinaryBelt(tools []bare.Tool) []string { - names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page"} + names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "propose_source", "write_agent_page"} var out []string for _, tool := range tools { if !workstreamHasWord(names, tool.Name) { @@ -379,7 +386,7 @@ func TestWorkstreamPageOwnershipRefusal(t *testing.T) { // 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"} + names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "propose_source", "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 { diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go index 8ddc386987..403d7f026d 100644 --- a/internal/workspace/schema3_test.go +++ b/internal/workspace/schema3_test.go @@ -74,8 +74,8 @@ func TestPartASchemaTwoReadsWithoutWritingAndMigratesAllRows(t *testing.T) { if err := s.Rename(ctx, "aaaa", "Products"); err != nil { t.Fatal(err) } - if userVersion(t, s) != 4 { - t.Fatal("first write did not migrate to 4") + if userVersion(t, s) != schemaVersion { + t.Fatal("first write did not migrate to the current schema") } gotC, gotM := legacyRows(t, s.db) wantC[0] = strings.Replace(wantC[0], "Product|", "Products|", 1) @@ -124,7 +124,7 @@ func TestPartASchemaTwoReadsWithoutWritingAndMigratesAllRows(t *testing.T) { if !reflect.DeepEqual(memberTimes, [][2]int64{{2, 4567}, {6, 5678}, {11, 6789}}) { t.Fatalf("membership times changed: %+v", memberTimes) } - for _, name := range []string{"workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} { + for _, name := range []string{"workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans", "workstream_wakes", "workstream_wake_items", "workstream_sources"} { var n int if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n); err != nil || n != 1 { t.Fatalf("missing %s: %v", name, err) @@ -309,7 +309,7 @@ func TestStabEB1AnOlderWriterCannotDeleteAWorkstream(t *testing.T) { older := workstreamSchemaTwoWriter(t, path) s := openTestStore(t, path) seedWorkstream(t, s, "protected", "Protected") - if userVersion(t, s) != 4 { + if userVersion(t, s) != schemaVersion { t.Fatal("workstream creation did not migrate schema 2") } // The domain store imports workspace, so this fixture uses its shared SQL diff --git a/internal/workspace/schema4_test.go b/internal/workspace/schema4_test.go index c3fe9f8e52..3b50373aa2 100644 --- a/internal/workspace/schema4_test.go +++ b/internal/workspace/schema4_test.go @@ -12,9 +12,10 @@ import ( func workstreamSchemaThreeFixture(t *testing.T, s *Store) { t.Helper() - if _, err := s.db.Exec("DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3"); err != nil { + if _, err := s.db.Exec("DROP TABLE workstream_sources; DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3"); err != nil { t.Fatal(err) } + s.version = 3 } func TestWakeI_SchemaThreeMigratesAdditively(t *testing.T) { @@ -61,7 +62,7 @@ func TestWakeI_SchemaThreeMigratesAdditively(t *testing.T) { if err := s.Write(context.Background(), func(*sql.Tx) error { return nil }); err != nil { t.Fatal(err) } - if userVersion(t, s) != 4 || !reflect.DeepEqual(snapshot(), before) { + if userVersion(t, s) != 5 || !reflect.DeepEqual(snapshot(), before) { t.Fatal("migration lost existing rows") } var count int @@ -74,7 +75,7 @@ func TestWakeI_NewerSchemaIsRefusedWithoutWriting(t *testing.T) { path := filepath.Join(t.TempDir(), "future.db") s := openTestStore(t, path) createTestCollection(t, s, "Future") - if _, err := s.db.Exec("PRAGMA user_version=5"); err != nil { + if _, err := s.db.Exec("PRAGMA user_version=6"); err != nil { t.Fatal(err) } s.Close() @@ -86,7 +87,7 @@ func TestWakeI_NewerSchemaIsRefusedWithoutWriting(t *testing.T) { if opened != nil { opened.Close() } - if err == nil || !strings.Contains(err.Error(), "version 5") { + if err == nil || !strings.Contains(err.Error(), "version 6") { t.Fatalf("newer schema: %v", err) } after, err := os.ReadFile(path) diff --git a/internal/workspace/store.go b/internal/workspace/store.go index e84defbce0..bcc6d6986b 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -20,10 +20,10 @@ import ( sqlite3 "modernc.org/sqlite/lib" ) -// schemaVersion is the schema this build writes. Schemas 1, 2 and 3 remain -// readable without migration; the first successful write upgrades them to 4. +// schemaVersion is the schema this build writes. Earlier schemas remain +// readable without migration; the first successful write upgrades them. const ( - schemaVersion = WorkstreamWakeSchemaVersion + schemaVersion = WorkstreamSourcesSchemaVersion legacySchemaVersion = 1 ) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. @@ -42,6 +42,7 @@ const ( schemaLegacy schemaTwo schemaThree + schemaFour schemaRefused ) @@ -54,6 +55,8 @@ func (s schemaState) version() int { return 2 case schemaThree: return 3 + case schemaFour: + return WorkstreamWakeSchemaVersion case schemaLegacy: return legacySchemaVersion } @@ -177,7 +180,7 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := q.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return schemaBlank, err } - if app == applicationID && (version == schemaVersion || version == 3 || version == 2 || version == legacySchemaVersion) { + if app == applicationID && (version == schemaVersion || version == WorkstreamWakeSchemaVersion || version == 3 || version == 2 || version == legacySchemaVersion) { state := schemaReady collections := "SELECT seq,id,name,created_at FROM collections LIMIT 0" memberships := "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at FROM memberships LIMIT 0" @@ -187,6 +190,9 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if version == 3 { state = schemaThree } + if version == WorkstreamWakeSchemaVersion { + state = schemaFour + } if version >= 3 { memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships LIMIT 0" } @@ -218,6 +224,11 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { return state, err } } + if version >= WorkstreamSourcesSchemaVersion { + if _, err := q.ExecContext(ctx, workstreamSourcesRead); err != nil { + return state, err + } + } return state, nil } if app != 0 || version != 0 { @@ -326,13 +337,24 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { switch state { case schemaReady: _, err = tx.ExecContext(ctx, workstreamCollectionKeep) + case schemaFour: + _, err = tx.ExecContext(ctx, workstreamCollectionKeep) + if err == nil { + err = migrateWorkstreamSources(ctx, tx) + } case schemaThree: err = migrateWorkstreamWakes(ctx, tx) + if err == nil { + err = migrateWorkstreamSources(ctx, tx) + } case schemaLegacy, schemaTwo: err = migrateLegacy(ctx, tx, state.version()) if err == nil { err = migrateWorkstreamWakes(ctx, tx) } + if err == nil { + err = migrateWorkstreamSources(ctx, tx) + } case schemaBlank: if !create { err = ErrNotFound @@ -341,6 +363,9 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { if err == nil { err = migrateWorkstreamWakes(ctx, tx) } + if err == nil { + err = migrateWorkstreamSources(ctx, tx) + } } } } diff --git a/internal/workspace/workstream_sources.go b/internal/workspace/workstream_sources.go new file mode 100644 index 0000000000..eef802eb1e --- /dev/null +++ b/internal/workspace/workstream_sources.go @@ -0,0 +1,36 @@ +package workspace + +import ( + "context" + "database/sql" +) + +// WorkstreamSourcesSchemaVersion is the first schema with source pointers. +const WorkstreamSourcesSchemaVersion = 5 + +// Sources are pointers with their own lifetime, separate from memberships. +const workstreamSourcesSchema = ` +CREATE TABLE workstream_sources ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('file','repo','issue','link','kept')), + target TEXT NOT NULL, + title TEXT NOT NULL, + path TEXT, repo TEXT, number INTEGER, url TEXT, ref TEXT, why TEXT, + read_by_jobs INTEGER NOT NULL DEFAULT 0, + proposed INTEGER NOT NULL DEFAULT 0, + added_by TEXT NOT NULL CHECK(added_by IN ('you','agent')), + added_at INTEGER NOT NULL, + UNIQUE(workstream_id,kind,target) +); +CREATE INDEX workstream_sources_order ON workstream_sources(workstream_id,seq); +` + +const workstreamSourcesRead = "SELECT seq,id,workstream_id,kind,target,title,path,repo,number,url,ref,why,read_by_jobs,proposed,added_by,added_at FROM workstream_sources LIMIT 0" + +// migrateWorkstreamSources is additive so existing rows and schema text stay intact. +func migrateWorkstreamSources(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, workstreamSourcesSchema) + return err +} diff --git a/internal/workspace/workstream_sources_test.go b/internal/workspace/workstream_sources_test.go new file mode 100644 index 0000000000..265fbd799b --- /dev/null +++ b/internal/workspace/workstream_sources_test.go @@ -0,0 +1,155 @@ +package workspace + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func workstreamSchemaFourFixture(t *testing.T, s *Store) { + t.Helper() + if _, err := s.db.Exec("DROP TABLE workstream_sources; PRAGMA user_version=4"); err != nil { + t.Fatal(err) + } + s.version = 4 +} + +// WP-12 (i): every existing table and value survives the additive migration; +// failed first writes and all reads leave schemas three and four unchanged. +func TestWorkstreamSourcesSchemaFiveMigration(t *testing.T) { + t.Run("schema three", func(t *testing.T) { workstreamSourcesMigration(t, 3) }) + t.Run("schema four", func(t *testing.T) { workstreamSourcesMigration(t, 4) }) +} + +func workstreamSourcesMigration(t *testing.T, version int) { + t.Helper() + ctx := context.Background() + path := filepath.Join(t.TempDir(), "sources.db") + s := openTestStore(t, path) + seedWorkstream(t, s, "stream", "Sources") + if err := s.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec(` +INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('stream','factory_job','7','','f'); +INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('page','stream','Overview','you',0,1,100,'you','{"type":"doc","content":[]}'); +INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('page',1,100,'you','Overview','{"type":"doc","content":[]}',27); +INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) VALUES ('grant','stream','wave',1,1,200,'active',100); +INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at,goahead,goahead_id) VALUES ('record','stream','goahead','Keep this','kept','agent',100,'{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"1970-01-01T00:00:00.2Z"}','grant'); +INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('stream',1,100,'you','[]','[]');`) + return err + }); err != nil { + t.Fatal(err) + } + tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} + if version == 4 { + if _, err := s.db.Exec(` +INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at) + VALUES ('wake','stream','morning:2026-11-03','{"kind":"morning","slot":"2026-11-03"}','settled','Review this.','',0.04,100,200,300); +INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at) + VALUES ('wake-item','wake',0,'decision','undone','Keep the context.','{"kind":"decision","text":"Review this."}','{"record_id":"record"}','Changed since.',150,250);`); err != nil { + t.Fatal(err) + } + workstreamSchemaFourFixture(t, s) + tables = append(tables, "workstream_wakes", "workstream_wake_items") + } else { + workstreamSchemaThreeFixture(t, s) + } + before := map[string][][]any{} + for _, table := range tables { + before[table] = workstreamStoredRows(t, s.db, table) + if len(before[table]) == 0 { + t.Fatalf("empty migration fixture: %s", table) + } + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + bytesBefore, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s = openTestStore(t, path) + if _, err := s.Members(ctx, "stream"); err != nil { + t.Fatal(err) + } + if err := s.Read(ctx, func(_ *sql.Tx, gotVersion int) error { + if gotVersion != version { + t.Fatalf("read schema %d, want %d", gotVersion, version) + } + return nil + }); err != nil { + t.Fatal(err) + } + refused := errors.New("first write refused") + if err := s.Write(ctx, func(tx *sql.Tx) error { + if _, err := tx.Exec("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) VALUES ('rollback','stream','link','https://example.com','Example','you',100)"); err != nil { + return err + } + return refused + }); !errors.Is(err, refused) { + t.Fatal(err) + } + bytesAfter, err := os.ReadFile(path) + if err != nil || string(bytesAfter) != string(bytesBefore) || userVersion(t, s) != version { + t.Fatalf("read/refusal changed schema %d: %v", version, err) + } + if err := s.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) VALUES ('source','stream','link','https://example.com','Example','you',100)") + return err + }); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 5 { + t.Fatal("first write did not migrate") + } + for _, table := range tables { + if got := workstreamStoredRows(t, s.db, table); !reflect.DeepEqual(got, before[table]) { + t.Fatalf("migration changed %s: %v", table, got) + } + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + opened := openTestStore(t, path) + if userVersion(t, opened) != 5 { + t.Fatal("schema five did not reopen") + } +} + +// WP-12 (i): missing sources in schema five and future versions are refused. +func TestWorkstreamSourcesSchemaFiveRequiresItsTable(t *testing.T) { + for _, future := range []bool{false, true} { + path := filepath.Join(t.TempDir(), "invalid.db") + s := openTestStore(t, path) + createTestCollection(t, s, "Sources") + query := "DROP TABLE workstream_sources" + if future { + query = "PRAGMA user_version=6" + } + if _, err := s.db.Exec(query); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + opened, err := Open(path) + if opened != nil { + opened.Close() + } + if err == nil || future && !strings.Contains(err.Error(), "unsupported collections database") || !future && !strings.Contains(err.Error(), "workstream_sources") { + t.Fatalf("invalid schema opened: %v", err) + } + after, err := os.ReadFile(path) + if err != nil || string(before) != string(after) { + t.Fatalf("refusal changed invalid schema: %v", err) + } + } +} diff --git a/internal/workspace/workstreams_test.go b/internal/workspace/workstreams_test.go index 3b1feda36e..6d62f879df 100644 --- a/internal/workspace/workstreams_test.go +++ b/internal/workspace/workstreams_test.go @@ -88,18 +88,19 @@ func TestPartAWorkstreamMembershipOwnershipIsEnforcedInSQLite(t *testing.T) { } } -// Part A (a): schema 1 upgrades to the current schema, preserving unknown times. -func TestPartASchemaOneMigratesStraightToCurrent(t *testing.T) { +// Part A (a), WP-12 (i): schema 1 upgrades directly to the current schema, +// preserving unknown timestamps and checking a job reference's factory. +func TestPartASchemaOneMigratesStraightToFive(t *testing.T) { s := openTestStore(t, writeLegacyStore(t)) ctx := context.Background() if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "11", Factory: "f"}); err != nil { t.Fatal(err) } - if userVersion(t, s) != 4 { - t.Fatal("did not migrate directly to schema 4") + if userVersion(t, s) != schemaVersion { + t.Fatal("did not migrate directly to the current schema") } var count int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 9 { + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 10 { t.Fatalf("workstream tables: %d %v", count, err) } var created, added int64 diff --git a/internal/workstream/sources.go b/internal/workstream/sources.go new file mode 100644 index 0000000000..3d75422ca0 --- /dev/null +++ b/internal/workstream/sources.go @@ -0,0 +1,391 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const ( + SourceFile = "file" + SourceRepo = "repo" + SourceIssue = "issue" + SourceLink = "link" + SourceKept = "kept" + SourcesMax = 2000 + CreateSourcesMax = 200 + SourceTitleMax = 200 + SourcePathMax = 4096 + SourceURLMax = 2048 + SourceIssueMax = 2147483647 + SourceRefMax = 128 + SourceWhyMax = 500 +) + +type KeptRef struct { + Kind string `json:"kind"` + ID string `json:"id"` + ChatID *string `json:"chat_id,omitempty"` + Factory *string `json:"factory,omitempty"` +} + +// Pointers distinguish an absent field from a supplied field of another kind. +type SourceAdd struct { + Kind string `json:"kind"` + Path *string `json:"path,omitempty"` + Repo *string `json:"repo,omitempty"` + Number *int `json:"number,omitempty"` + URL *string `json:"url,omitempty"` + Ref *KeptRef `json:"ref,omitempty"` + Title *string `json:"title,omitempty"` + ReadByJobs bool `json:"read_by_jobs,omitempty"` +} + +type SourceItem struct { + ID string + WorkstreamID string + Kind string + Title string + Path string + Repo string + Number int + URL string + Ref *KeptRef + ReadByJobs bool + Proposed bool + Why string + AddedBy string + AddedAt time.Time + Gone bool + target string +} + +type SourcePatch struct { + Title *string + ReadByJobs *bool + Keep *bool +} + +type preparedSource struct { + SourceAdd + target string + title string +} + +const sourceItemSelect = `SELECT id,workstream_id,kind,title,coalesce(path,''),coalesce(repo,''),coalesce(number,0),coalesce(url,''),ref,read_by_jobs,proposed,coalesce(why,''),added_by,added_at,target FROM workstream_sources` + +type sourceScanner interface{ Scan(...any) error } + +func scanSourceItem(row sourceScanner) (SourceItem, error) { + var out SourceItem + var ref sql.NullString + var at int64 + err := row.Scan(&out.ID, &out.WorkstreamID, &out.Kind, &out.Title, &out.Path, &out.Repo, &out.Number, &out.URL, &ref, &out.ReadByJobs, &out.Proposed, &out.Why, &out.AddedBy, &at, &out.target) + if err != nil { + return SourceItem{}, found(err) + } + out.AddedAt = instant(at) + if ref.Valid { + err = json.Unmarshal([]byte(ref.String), &out.Ref) + } + return out, err +} + +func sourceItemIn(ctx context.Context, tx *sql.Tx, id string) (SourceItem, error) { + return scanSourceItem(tx.QueryRowContext(ctx, sourceItemSelect+" WHERE id=?", id)) +} + +func (s *Store) GetSource(ctx context.Context, id string) (SourceItem, error) { + var out SourceItem + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < workspace.WorkstreamSourcesSchemaVersion { + return ErrNotFound + } + var err error + out, err = sourceItemIn(ctx, tx, id) + return err + }) + return out, err +} + +func (s *Store) Sources(ctx context.Context, id string) ([]SourceItem, error) { + out := []SourceItem{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return ErrNotFound + } + if _, err := getIn(ctx, tx, id); err != nil { + return err + } + if version < workspace.WorkstreamSourcesSchemaVersion { + return nil + } + rows, err := tx.QueryContext(ctx, sourceItemSelect+" WHERE workstream_id=? ORDER BY seq", id) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + item, err := scanSourceItem(rows) + if err != nil { + return err + } + out = append(out, item) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + // Filesystem observations must not hold the database's read transaction. + for i := range out { + path := sourceItemPath(out[i]) + if path == "" { + continue + } + _, err := os.Stat(path) + out[i].Gone = errors.Is(err, os.ErrNotExist) + } + return out, nil +} + +func sourceItemPath(item SourceItem) string { + if item.Kind == SourceFile { + return item.Path + } + if item.Kind == SourceRepo && filepath.IsAbs(item.Repo) { + return item.Repo + } + return "" +} + +func sourceWriteAllowed(ctx context.Context, tx *sql.Tx, id string, proposed bool) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if w.DoneAt.IsZero() { + return nil + } + if proposed { + return ErrDone + } + return ErrConflict +} + +func (s *Store) AddSource(ctx context.Context, id string, p SourceAdd) (SourceItem, bool, Change, error) { + return s.addSource(ctx, id, p, false, "") +} + +func (s *Store) ProposeSource(ctx context.Context, id string, p SourceAdd, why string) (SourceItem, bool, Change, error) { + if err := ValidateSourceWhy(why); err != nil { + return SourceItem{}, false, Change{}, err + } + if p.ReadByJobs { + return SourceItem{}, false, Change{}, invalid("a proposal cannot be read by jobs") + } + return s.addSource(ctx, id, p, true, why) +} + +func (s *Store) addSource(ctx context.Context, id string, p SourceAdd, proposed bool, why string) (SourceItem, bool, Change, error) { + prepared, err := prepareSource(p) + if err != nil { + return SourceItem{}, false, Change{}, err + } + // Check references before a writer can initialize or migrate the file. + err = s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return ErrNotFound + } + if err := sourceWriteAllowed(ctx, tx, id, proposed); err != nil { + return err + } + return sourceKeptIn(ctx, tx, id, &prepared) + }) + if err != nil { + return SourceItem{}, false, Change{}, err + } + var out SourceItem + var already bool + var change Change + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + if err := sourceWriteAllowed(ctx, tx, id, proposed); err != nil { + return err + } + if err := sourceKeptIn(ctx, tx, id, &prepared); err != nil { + return err + } + var wrote bool + var err error + out, already, wrote, err = s.addSourceIn(ctx, tx, id, prepared, proposed, why) + if err != nil || !wrote { + return err + } + if err := touch(ctx, tx, id, s.now().UnixMilli()); err != nil { + return err + } + change = changed(id, PartSources) + return nil + }) + if err != nil { + return SourceItem{}, false, Change{}, err + } + return out, already, change, nil +} + +func (s *Store) addSourceIn(ctx context.Context, tx *sql.Tx, id string, p preparedSource, proposed bool, why string) (SourceItem, bool, bool, error) { + existing, err := scanSourceItem(tx.QueryRowContext(ctx, sourceItemSelect+" WHERE workstream_id=? AND kind=? AND target=?", id, p.Kind, p.target)) + if err == nil { + if proposed || !existing.Proposed { + return existing, true, false, nil + } + title := existing.Title + if p.Title != nil { + title = *p.Title + } + _, err = tx.ExecContext(ctx, "UPDATE workstream_sources SET proposed=0,title=?,read_by_jobs=? WHERE id=?", title, p.ReadByJobs, existing.ID) + if err != nil { + return SourceItem{}, false, false, err + } + out, err := sourceItemIn(ctx, tx, existing.ID) + return out, true, true, err + } + if !errors.Is(err, ErrNotFound) { + return SourceItem{}, false, false, err + } + if err := sourceLimitIn(ctx, tx, id); err != nil { + return SourceItem{}, false, false, err + } + return s.insertSourceIn(ctx, tx, id, p, proposed, why) +} + +func sourceLimitIn(ctx context.Context, tx *sql.Tx, id string) error { + var count int + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_sources WHERE workstream_id=?", id).Scan(&count); err != nil { + return err + } + if count >= SourcesMax { + return ErrLimit + } + return nil +} + +func (s *Store) insertSourceIn(ctx context.Context, tx *sql.Tx, id string, p preparedSource, proposed bool, why string) (SourceItem, bool, bool, error) { + sourceID, err := s.newID() + if err != nil { + return SourceItem{}, false, false, err + } + var ref any + if p.Ref != nil { + b, err := json.Marshal(p.Ref) + if err != nil { + return SourceItem{}, false, false, err + } + ref = string(b) + } + by := "you" + if proposed { + by = "agent" + } + _, err = tx.ExecContext(ctx, `INSERT INTO workstream_sources(id,workstream_id,kind,target,title,path,repo,number,url,ref,why,read_by_jobs,proposed,added_by,added_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, sourceID, id, p.Kind, p.target, p.title, p.Path, p.Repo, p.Number, p.URL, ref, nullString(why), p.ReadByJobs, proposed, by, s.now().UnixMilli()) + if err != nil { + return SourceItem{}, false, false, err + } + out, err := sourceItemIn(ctx, tx, sourceID) + return out, false, true, err +} + +func ValidateSourcePatch(p SourcePatch) error { + if p.Title == nil && p.ReadByJobs == nil && p.Keep == nil { + return invalid("source update needs title, read_by_jobs or keep") + } + if p.Keep != nil && !*p.Keep { + return invalid("keep can only be true") + } + return sourceTitle(p.Title) +} + +func (s *Store) UpdateSource(ctx context.Context, id string, p SourcePatch) (SourceItem, Change, error) { + if err := ValidateSourcePatch(p); err != nil { + return SourceItem{}, Change{}, err + } + var out SourceItem + var change Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + item, err := sourceItemIn(ctx, tx, id) + if err != nil { + return err + } + if err := sourceWriteAllowed(ctx, tx, item.WorkstreamID, false); err != nil { + return err + } + out = item + if err := sourcePatchItem(&out, p); err != nil { + return err + } + if out.Title == item.Title && out.ReadByJobs == item.ReadByJobs && out.Proposed == item.Proposed { + return nil + } + if _, err := tx.ExecContext(ctx, "UPDATE workstream_sources SET title=?,read_by_jobs=?,proposed=? WHERE id=?", out.Title, out.ReadByJobs, out.Proposed, id); err != nil { + return err + } + if err := touch(ctx, tx, item.WorkstreamID, s.now().UnixMilli()); err != nil { + return err + } + change = changed(item.WorkstreamID, PartSources) + return nil + }) + if err != nil { + return SourceItem{}, Change{}, err + } + return out, change, nil +} + +func sourcePatchItem(item *SourceItem, p SourcePatch) error { + if p.Keep != nil { + item.Proposed = false + } + if p.ReadByJobs != nil { + if *p.ReadByJobs && item.Proposed { + return invalid("keep a proposal before enabling read_by_jobs") + } + item.ReadByJobs = *p.ReadByJobs + } + if p.Title != nil { + item.Title = *p.Title + } + return nil +} + +func (s *Store) RemoveSource(ctx context.Context, id string) (SourceItem, Change, error) { + var out SourceItem + var change Change + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + var err error + out, err = sourceItemIn(ctx, tx, id) + if err != nil { + return err + } + if err := sourceWriteAllowed(ctx, tx, out.WorkstreamID, false); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "DELETE FROM workstream_sources WHERE id=?", id); err != nil { + return err + } + if err := touch(ctx, tx, out.WorkstreamID, s.now().UnixMilli()); err != nil { + return err + } + change = changed(out.WorkstreamID, PartSources) + return nil + }) + if err != nil { + return SourceItem{}, Change{}, err + } + return out, change, nil +} diff --git a/internal/workstream/sources_migration_test.go b/internal/workstream/sources_migration_test.go new file mode 100644 index 0000000000..8476de345f --- /dev/null +++ b/internal/workstream/sources_migration_test.go @@ -0,0 +1,209 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +func workstreamSourceStoredRows(t *testing.T, collections *workspace.Store) map[string][][]any { + t.Helper() + out := map[string][][]any{} + err := collections.Read(context.Background(), func(tx *sql.Tx, version int) error { + tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} + if version >= workspace.WorkstreamWakeSchemaVersion { + tables = append(tables, "workstream_wakes", "workstream_wake_items") + } + for _, table := range tables { + rows, err := tx.Query("SELECT * FROM " + table + " ORDER BY 1,2") + if err != nil { + return err + } + columns, err := rows.Columns() + if err != nil { + rows.Close() + return err + } + out[table] = nil + for rows.Next() { + values := make([]any, len(columns)) + pointers := make([]any, len(columns)) + for i := range values { + pointers[i] = &values[i] + } + if err := rows.Scan(pointers...); err != nil { + rows.Close() + return err + } + out[table] = append(out[table], values) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func workstreamSourceSchemaVersion(t *testing.T, collections *workspace.Store) int { + t.Helper() + var version int + if err := collections.Read(context.Background(), func(tx *sql.Tx, _ int) error { return tx.QueryRow("PRAGMA user_version").Scan(&version) }); err != nil { + t.Fatal(err) + } + return version +} + +// WP-12 (i): the real source.add path reads older schemas without writing, then +// migrates to schema five while retaining every earlier row except its required +// workstream updated_at touch. Every existing domain read keeps its behavior. +func TestWorkstreamSourceAddMigratesSchemaThreeAndPreservesRows(t *testing.T) { + workstreamSourceMigration(t, 3) +} + +func TestWorkstreamSourceAddMigratesSchemaFourAndPreservesRows(t *testing.T) { + workstreamSourceMigration(t, 4) +} + +func workstreamSourceMigration(t *testing.T, version int) { + t.Helper() + ctx := context.Background() + path := filepath.Join(t.TempDir(), "collections.db") + collections, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) + s := New(collections, Options{Now: func() time.Time { return now }}) + w := createStream(t, s, "Migrated sources", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}) + pages, err := s.Pages(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.SavePage(ctx, pages[0].ID, 1, json.RawMessage(`{"type":"doc","content":[]}`), Attribution{}); err != nil { + t.Fatal(err) + } + if _, _, err := s.SetPlan(ctx, w.ID, 0, []Milestone{{ID: "m", Title: "Milestone"}}, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { + t.Fatal(err) + } + if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Keep rows.", Kept: true}); err != nil { + t.Fatal(err) + } + proposal, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Allow wave.", GoAhead: &GoAheadProposal{Scope: "wave", Wave: 1, CapUSD: 1, EndsAt: now.Add(time.Hour)}}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.KeepRecord(ctx, proposal.ID); err != nil { + t.Fatal(err) + } + if err := collections.Write(ctx, func(tx *sql.Tx) error { + query := "DROP TABLE workstream_sources; DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3" + if version == 4 { + if _, err := tx.Exec(`INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,cost_usd,started_at,settled_at) + VALUES ('wake',?,'morning:2026-11-03','{"kind":"morning","at":"2026-11-03T08:00:00Z"}','settled','Review this.',0.04,100,200)`, w.ID); err != nil { + return err + } + if err := workstreamSaveWakeItemIn(ctx, tx, "wake", 0, WakeItem{ID: "wake-item", Kind: "decision", State: "proposed", Text: "Keep the context.", Why: "For review."}); err != nil { + return err + } + query = "DROP TABLE workstream_sources; PRAGMA user_version=4" + } + _, err := tx.Exec(query) + return err + }); err != nil { + t.Fatal(err) + } + if err := collections.Close(); err != nil { + t.Fatal(err) + } + bytesBefore, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + collections, err = workspace.Open(path) + if err != nil { + t.Fatal(err) + } + defer collections.Close() + s = New(collections, Options{Now: func() time.Time { return now }}) + before := workstreamSourceStoredRows(t, collections) + items, err := s.Sources(ctx, w.ID) + if err != nil || items == nil || len(items) != 0 || workstreamSourceSchemaVersion(t, collections) != version { + t.Fatalf("schema %d sources: %v %v", version, items, err) + } + if _, err := s.Get(ctx, w.ID); err != nil { + t.Fatal(err) + } + if got, err := collections.Members(ctx, w.ID); err != nil || len(got) != 2 || got[0].Factory != "f" { + t.Fatalf("schema %d members: %+v %v", version, got, err) + } + if _, err := s.Pages(ctx, w.ID); err != nil { + t.Fatal(err) + } + if _, err := s.PageHistory(ctx, pages[0].ID, 50); err != nil { + t.Fatal(err) + } + if _, err := s.Records(ctx, w.ID, "", "", true); err != nil { + t.Fatal(err) + } + if _, err := s.GoAheads(ctx, w.ID, true); err != nil { + t.Fatal(err) + } + if _, err := s.Plan(ctx, w.ID); err != nil { + t.Fatal(err) + } + wakes, err := s.Wakes(ctx, w.ID, 20) + if err != nil || version == 3 && len(wakes) != 0 || version == 4 && (len(wakes) != 1 || len(wakes[0].Items) != 1 || wakes[0].Items[0].ID != "wake-item") { + t.Fatalf("schema %d wakes: %+v %v", version, wakes, err) + } + cost, err := s.WakeSpendByAgent(ctx, w.AgentID) + if err != nil || version == 3 && cost != 0 || version == 4 && cost != 0.04 { + t.Fatalf("schema %d wake cost: %v %v", version, cost, err) + } + if changes, err := s.ExpireWakes(ctx); err != nil || len(changes) != 0 { + t.Fatalf("settled wakes changed: %+v %v", changes, err) + } + bytesAfter, err := os.ReadFile(path) + if err != nil || string(bytesBefore) != string(bytesAfter) { + t.Fatalf("schema %d reads wrote: %v", version, err) + } + item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")}) + if err != nil || workstreamSourceSchemaVersion(t, collections) != 5 { + t.Fatalf("migration: %+v %v", item, err) + } + after := workstreamSourceStoredRows(t, collections) + if version == 3 { + for _, table := range []string{"workstream_wakes", "workstream_wake_items"} { + if len(after[table]) != 0 { + t.Fatalf("migration populated %s", table) + } + delete(after, table) + } + } + // updated_at is the tenth column in the unchanged workstreams DDL. Source writes + // advance that timestamp; migration itself changes no existing value. + if after["workstreams"][0][9].(int64) <= before["workstreams"][0][9].(int64) { + t.Fatal("source write did not touch updated_at") + } + after["workstreams"][0][9] = before["workstreams"][0][9] + if !reflect.DeepEqual(after, before) { + t.Fatalf("migration changed existing rows: before=%v after=%v", before, after) + } + items, err = s.Sources(ctx, w.ID) + if err != nil || len(items) != 1 || items[0].ID != item.ID { + t.Fatalf("migrated sources: %v %v", items, err) + } +} diff --git a/internal/workstream/sources_test.go b/internal/workstream/sources_test.go new file mode 100644 index 0000000000..7d8b1da726 --- /dev/null +++ b/internal/workstream/sources_test.go @@ -0,0 +1,556 @@ +package workstream + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +func workstreamSourcePtr[T any](v T) *T { return &v } + +func workstreamSourceFile(t *testing.T) string { + t.Helper() + p := filepath.Join(t.TempDir(), "usage.md") + if err := os.WriteFile(p, []byte("Recorded usage.\n"), 0600); err != nil { + t.Fatal(err) + } + return p +} + +// WP-12 (a): all nine target forms derive titles and retain only their kind's +// fields, in added order; every duplicate returns the original row. +func TestWorkstreamSourcesEveryKind(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Kinds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}) + record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Use totals.\nDetails.", Kept: true}) + if err != nil { + t.Fatal(err) + } + pages, err := s.Pages(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + path := workstreamSourceFile(t) + folder := t.TempDir() + cases := []struct { + add SourceAdd + title string + }{ + {SourceAdd{Kind: SourceFile, Path: &path}, "usage.md"}, + {SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing")}, "ACME/Billing"}, + {SourceAdd{Kind: SourceRepo, Repo: &folder}, filepath.Base(folder)}, + {SourceAdd{Kind: SourceIssue, Repo: workstreamSourcePtr("acme/billing"), Number: workstreamSourcePtr(412)}, "acme/billing#412"}, + {SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("HTTPS://Docs.Example.com/x/?query=yes#part")}, "docs.example.com/x"}, + {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: record.ID}}, "Use totals."}, + {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "page", ID: pages[0].ID}}, "Overview"}, + {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "message", ID: "message", ChatID: workstreamSourcePtr("chat")}}, "Kept message"}, + {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "factory_job", ID: "7", Factory: workstreamSourcePtr("f")}}, "Job 7"}, + } + want := []SourceItem{} + for _, tc := range cases { + item, already, _, err := s.AddSource(ctx, w.ID, tc.add) + if err != nil || already || item.Title != tc.title || item.Proposed || item.ReadByJobs || item.AddedBy != "you" || item.Why != "" { + t.Fatalf("%+v: %+v %v %v", tc.add, item, already, err) + } + duplicate := tc.add + if tc.add.Kind == SourceRepo && tc.add.Repo != &folder { + duplicate.Repo = workstreamSourcePtr("acme/billing") + } + if tc.add.Kind == SourceIssue { + duplicate.Repo = workstreamSourcePtr("ACME/Billing") + } + if tc.add.Kind == SourceLink { + duplicate.URL = workstreamSourcePtr("https://docs.example.com/x/?query=yes#part") + } + again, already, change, err := s.AddSource(ctx, w.ID, duplicate) + if err != nil || !already || !reflect.DeepEqual(item, again) || len(change.Parts) != 0 { + t.Fatalf("duplicate: %+v %v %+v %v", again, already, change, err) + } + want = append(want, item) + } + list, err := s.Sources(ctx, w.ID) + if err != nil || !reflect.DeepEqual(list, want) { + t.Fatalf("round trip: %+v %v", list, err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Remove(folder); err != nil { + t.Fatal(err) + } + list, err = s.Sources(ctx, w.ID) + if err != nil || !list[0].Gone || !list[2].Gone || list[1].Gone || list[3].Gone { + t.Fatalf("gone: %+v %v", list, err) + } +} + +// WP-12 (b), (c), (g): proposals cannot enable jobs, duplicates do not mutate +// them, and either person keep path preserves their attribution and reason. +func TestWorkstreamSourceProposalsAndKeeping(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Proposals") + add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/guide")} + proposal, already, _, err := s.ProposeSource(ctx, w.ID, add, "Read the guide.") + if err != nil || already || !proposal.Proposed || proposal.ReadByJobs || proposal.AddedBy != "agent" || proposal.Why != "Read the guide." { + t.Fatalf("proposal: %+v %v", proposal, err) + } + again, already, change, err := s.ProposeSource(ctx, w.ID, add, "Another reason.") + if err != nil || !already || !reflect.DeepEqual(again, proposal) || len(change.Parts) != 0 { + t.Fatalf("duplicate proposal: %+v %v", again, err) + } + if _, _, err := s.UpdateSource(ctx, proposal.ID, SourcePatch{ReadByJobs: workstreamSourcePtr(true)}); !errors.Is(err, ErrInvalid) { + t.Fatalf("proposal jobs: %v", err) + } + unchanged, err := s.GetSource(ctx, proposal.ID) + if err != nil || !reflect.DeepEqual(unchanged, proposal) { + t.Fatalf("refusal changed proposal: %+v %v", unchanged, err) + } + add.Title, add.ReadByJobs = workstreamSourcePtr("The guide"), true + kept, already, change, err := s.AddSource(ctx, w.ID, add) + if err != nil || !already || kept.ID != proposal.ID || kept.Title != "The guide" || kept.Proposed || !kept.ReadByJobs || kept.AddedBy != "agent" || kept.Why != proposal.Why || len(change.Parts) == 0 { + t.Fatalf("person keep: %+v %v %+v %v", kept, already, change, err) + } + if _, _, change, err := s.ProposeSource(ctx, w.ID, add, "Again."); !errors.Is(err, ErrInvalid) || len(change.Parts) != 0 { + t.Fatalf("agent cannot set jobs: %v", err) + } + add.ReadByJobs = false + if got, already, change, err := s.ProposeSource(ctx, w.ID, add, "Again."); err != nil || !already || got.Proposed || len(change.Parts) != 0 { + t.Fatalf("duplicate kept: %+v %v", got, err) + } + for _, patch := range []SourcePatch{{Keep: workstreamSourcePtr(true)}, {Title: workstreamSourcePtr(kept.Title)}, {ReadByJobs: workstreamSourcePtr(true)}} { + got, change, err := s.UpdateSource(ctx, kept.ID, patch) + if err != nil || !reflect.DeepEqual(got, kept) || len(change.Parts) != 0 { + t.Fatalf("no-op: %+v %+v %v", got, change, err) + } + } +} + +// WP-12 (b): every invalid kind field is refused before a write and leaves +// the source list and the workstream's timestamp unchanged. +func TestWorkstreamSourcesInvalidInputsStoreNothing(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Invalid sources") + path := workstreamSourceFile(t) + bad := []SourceAdd{ + {Kind: SourceFile, Path: workstreamSourcePtr("relative")}, + {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "/../usage.md")}, + {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "/./usage.md")}, + {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "//usage.md")}, + {Kind: SourceFile, Path: workstreamSourcePtr(path + "/")}, + {Kind: SourceFile, Path: workstreamSourcePtr(path + "\x00")}, + {Kind: SourceFile, Path: workstreamSourcePtr(path + "\n")}, + {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Join(t.TempDir(), "missing"))}, + {Kind: SourceFile, Path: workstreamSourcePtr("/" + strings.Repeat("x", 4096))}, + {Kind: SourceFile, Path: workstreamSourcePtr(string([]byte{'/', 255}))}, + {Kind: SourceFile, Path: &path, URL: workstreamSourcePtr("")}, + {Kind: SourceRepo, Repo: &path}, + {Kind: SourceIssue, Repo: &path, Number: workstreamSourcePtr(1)}, + {Kind: SourceKept, Ref: &KeptRef{Kind: "factory_job", ID: "7", Factory: workstreamSourcePtr("elsewhere")}}, + } + for _, repo := range []string{"../x", "owner/..", "-owner/x", "owner/name/extra", "relative", "owner/.", "owner/"} { + bad = append(bad, SourceAdd{Kind: SourceRepo, Repo: &repo}) + } + for _, number := range []int{0, -1, 2147483648} { + bad = append(bad, SourceAdd{Kind: SourceIssue, Repo: workstreamSourcePtr("owner/name"), Number: &number}) + } + for _, url := range []string{"file:///etc/passwd", "javascript:alert(1)", "ftp://h/x", "//h/x", "https:///x", "https://user:pw@h/x", "https://h/x y", "https://h/x\n", "https://h/" + strings.Repeat("x", 2039)} { + bad = append(bad, SourceAdd{Kind: SourceLink, URL: &url}) + } + for _, title := range []string{"", strings.Repeat("x", 201), "two\nlines", " untrimmed ", "two\u2028lines"} { + bad = append(bad, SourceAdd{Kind: SourceFile, Path: &path, Title: &title}) + } + for i, add := range bad { + _, _, change, err := s.AddSource(ctx, w.ID, add) + if !errors.Is(err, ErrInvalid) || len(change.Parts) != 0 { + t.Fatalf("invalid %d: %v %+v", i, err, change) + } + } + for _, ref := range []*KeptRef{{Kind: "record", ID: "missing"}, {Kind: "page", ID: "missing"}} { + if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown kept: %v", err) + } + } + list, err := s.Sources(ctx, w.ID) + after, getErr := s.Get(ctx, w.ID) + if err != nil || getErr != nil || len(list) != 0 || !after.UpdatedAt.Equal(w.UpdatedAt) { + t.Fatalf("refused sources changed state: %+v %v %v", list, err, getErr) + } +} + +// WP-12 (b), (d): kept rows must belong here and removal never deletes a +// record or page. Deleted pages cannot be kept. +func TestWorkstreamSourcesKeptOwnershipAndRemoval(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Kept") + other := createStream(t, s, "Other") + record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Keep?"}) + if err != nil { + t.Fatal(err) + } + page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes"}) + if err != nil { + t.Fatal(err) + } + for _, ref := range []*KeptRef{{Kind: "record", ID: record.ID}, {Kind: "page", ID: page.ID}} { + if _, _, _, err := s.AddSource(ctx, other.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign ref: %v", err) + } + item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.RemoveSource(ctx, item.ID); err != nil { + t.Fatal(err) + } + } + if records, err := s.Records(ctx, w.ID, "", ""); err != nil || len(records) != 1 { + t.Fatalf("removed record: %v %v", records, err) + } + if _, err := s.Page(ctx, page.ID, false); err != nil { + t.Fatal(err) + } + if _, _, err := s.DeletePage(ctx, page.ID); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "page", ID: page.ID}}); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted page: %v", err) + } +} + +// WP-12 (e), (f): create validates all sources atomically, preserves why, +// folds duplicate targets, and ignores sources on a client retry. +func TestWorkstreamCreateSourcesAreAtomic(t *testing.T) { + s, collections, _ := testStore(t) + ctx := context.Background() + path := workstreamSourceFile(t) + p := Create{Name: "Created sources", AgentID: "agent", ClientID: "client", Why: " Exactly as sent.\n", Sources: []SourceAdd{{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing"), ReadByJobs: true}, {Kind: SourceFile, Path: &path}, {Kind: SourceRepo, Repo: workstreamSourcePtr("acme/billing")}}} + w, _, err := s.Create(ctx, p) + if err != nil || w.Why != p.Why { + t.Fatalf("create: %+v %v", w, err) + } + items, err := s.Sources(ctx, w.ID) + if err != nil || len(items) != 2 || !items[0].ReadByJobs || items[1].ReadByJobs { + t.Fatalf("create sources: %+v %v", items, err) + } + p.Sources = []SourceAdd{{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/new")}} + again, change, err := s.Create(ctx, p) + if err != nil || again.ID != w.ID || len(change.Parts) != 0 { + t.Fatalf("retry: %+v %v", again, err) + } + items, err = s.Sources(ctx, w.ID) + if err != nil || len(items) != 2 { + t.Fatalf("retry added sources: %v %v", items, err) + } + for _, bad := range []SourceAdd{{Kind: SourceFile, Path: workstreamSourcePtr("relative")}, {Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: "missing"}}} { + p.Name, p.ClientID, p.Sources = "Free name", "", []SourceAdd{{Kind: SourceFile, Path: &path}, bad} + if _, _, err := s.Create(ctx, p); !errors.Is(err, ErrInvalid) { + t.Fatalf("bad create: %v", err) + } + } + rows, err := collections.Collections(ctx) + if err != nil || len(rows) != 1 { + t.Fatalf("partial create: %v %v", rows, err) + } + p.Sources = nil + if _, _, err := s.Create(ctx, p); err != nil { + t.Fatalf("name was taken: %v", err) + } + if err := collections.Add(ctx, w.ID, workspace.Ref{Kind: workspace.ArtifactKind, ID: path}); err != nil { + t.Fatal(err) + } + items, err = s.Sources(ctx, w.ID) + if err != nil || len(items) != 2 { + t.Fatalf("artifact became a source: %v %v", items, err) + } +} + +// WP-12 Fix 1 Major 2 (e): an identical client retry returns its first row +// even after an initial source disappears; a new client still validates it. +func TestWorkstreamMajor2CreateRetryE(t *testing.T) { + s, collections, _ := testStore(t) + ctx := context.Background() + path := workstreamSourceFile(t) + p := Create{Name: "Retry with removed source", AgentID: "agent", ClientID: "retry-removed", Sources: []SourceAdd{{Kind: SourceFile, Path: &path}}} + w, _, err := s.Create(ctx, p) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + before, err := s.Sources(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + again, change, err := s.Create(ctx, p) + if err != nil || !reflect.DeepEqual(again, w) || change.ID != "" || len(change.Parts) != 0 { + t.Fatalf("retry: %+v %+v %v", again, change, err) + } + after, err := s.Sources(ctx, w.ID) + if err != nil || len(after) != 1 || !reflect.DeepEqual(after, before) { + t.Fatalf("retry changed sources: %+v %v", after, err) + } + p.ClientID, p.Name = "new-client", "New client name" + if _, _, err := s.Create(ctx, p); !errors.Is(err, ErrInvalid) { + t.Fatalf("new client bypassed validation: %v", err) + } + rows, err := collections.Collections(ctx) + if err != nil || len(rows) != 1 { + t.Fatalf("new client wrote rows: %+v %v", rows, err) + } + p.Sources = nil + if _, _, err := s.Create(ctx, p); err != nil { + t.Fatalf("refusal reserved name or client: %v", err) + } +} + +// WP-12 Fix 1 Minor 1 (a), (b): the normalized URL obeys the byte bound and +// a fitting multibyte URL remains an idempotent target when re-added as returned. +func TestWorkstreamMinor1LinkBoundAB(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Normalized URL bounds") + tooLong := "https://h/" + strings.Repeat("é", 1019) + if len(tooLong) != 2048 { + t.Fatal("fixture must be exactly 2048 input bytes") + } + _, _, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &tooLong}) + if !errors.Is(err, ErrInvalid) || change.ID != "" || len(change.Parts) != 0 { + t.Fatalf("expanded URL accepted: %+v %v", change, err) + } + items, err := s.Sources(ctx, w.ID) + if err != nil || len(items) != 0 { + t.Fatalf("invalid URL stored: %+v %v", items, err) + } + unchanged, err := s.Get(ctx, w.ID) + if err != nil || !unchanged.UpdatedAt.Equal(w.UpdatedAt) { + t.Fatalf("invalid URL touched workstream: %+v %v", unchanged, err) + } + fits := "https://h/" + strings.Repeat("é", 339) + "abcd" + want := "https://h/" + strings.Repeat("%C3%A9", 339) + "abcd" + item, already, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &fits}) + if err != nil || already || item.URL != want || len(item.URL) != 2048 { + t.Fatalf("fitting URL: %+v %v %v", item, already, err) + } + again, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &item.URL}) + if err != nil || !already || !reflect.DeepEqual(again, item) || change.ID != "" || len(change.Parts) != 0 { + t.Fatalf("returned URL retry: %+v %v %+v %v", again, already, change, err) + } +} + +// WP-12 (c): the Read by jobs toggle persists through reopening the file. +func TestWorkstreamSourcesToggleSurvivesReopen(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "collections.db") + collections, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + s := New(collections, Options{}) + w := createStream(t, s, "Persistence") + item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")}) + if err != nil { + t.Fatal(err) + } + for _, flag := range []bool{true, false, true} { + if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{ReadByJobs: &flag}); err != nil { + t.Fatal(err) + } + list, err := s.Sources(ctx, w.ID) + if err != nil || len(list) != 1 || list[0].ReadByJobs != flag { + t.Fatalf("toggle: %v %v", list, err) + } + } + if err := collections.Close(); err != nil { + t.Fatal(err) + } + collections, err = workspace.Open(path) + if err != nil { + t.Fatal(err) + } + defer collections.Close() + s = New(collections, Options{}) + list, err := s.Sources(ctx, w.ID) + if err != nil || len(list) != 1 || !list[0].ReadByJobs { + t.Fatalf("reopen: %+v %v", list, err) + } +} + +// WP-12 (h): done is read-only for every source write, and reopen restores them. +func TestWorkstreamSourcesDoneAndReopen(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Done sources") + add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")} + item, _, _, err := s.AddSource(ctx, w.ID, add) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.AddSource(ctx, w.ID, add); !errors.Is(err, ErrConflict) { + t.Fatalf("add done: %v", err) + } + if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("New")}); !errors.Is(err, ErrConflict) { + t.Fatalf("update done: %v", err) + } + if _, _, err := s.RemoveSource(ctx, item.ID); !errors.Is(err, ErrConflict) { + t.Fatalf("remove done: %v", err) + } + if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); !errors.Is(err, ErrDone) { + t.Fatalf("propose done: %v", err) + } + list, err := s.Sources(ctx, w.ID) + if err != nil || len(list) != 1 || !reflect.DeepEqual(item, list[0]) { + t.Fatalf("done list: %v %v", list, err) + } + if _, _, err := s.Reopen(ctx, w.ID); err != nil { + t.Fatal(err) + } + if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("New")}); err != nil { + t.Fatal(err) + } + if _, _, err := s.RemoveSource(ctx, item.ID); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); err != nil { + t.Fatal(err) + } +} + +// WP-12 (b): the 2,001st source refuses atomically, while duplicates still work. +func TestWorkstreamSourcesLimit(t *testing.T) { + s, collections, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Source limit") + if err := collections.Write(ctx, func(tx *sql.Tx) error { + statement, err := tx.Prepare("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,proposed,added_by,added_at) VALUES (?,?,'link',?,'Example',?,1,'agent',100)") + if err != nil { + return err + } + defer statement.Close() + for i := 0; i < SourcesMax; i++ { + url := fmt.Sprintf("https://example.com/%d", i) + if _, err := statement.Exec(fmt.Sprintf("limit-%d", i), w.ID, url, url); err != nil { + return err + } + } + return nil + }); err != nil { + t.Fatal(err) + } + add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/extra")} + if _, _, _, err := s.AddSource(ctx, w.ID, add); !errors.Is(err, ErrLimit) { + t.Fatalf("source limit: %v", err) + } + if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); !errors.Is(err, ErrLimit) { + t.Fatalf("proposal limit: %v", err) + } + add.URL = workstreamSourcePtr("https://example.com/0") + item, already, _, err := s.AddSource(ctx, w.ID, add) + if err != nil || !already || item.Proposed { + t.Fatalf("keep at limit: %+v %v", item, err) + } +} + +// WP-12 (a), (b): derived titles cut at a UTF-8 boundary, supplied titles and +// file base names preserve internal spaces, and kept ids enforce byte bounds. +func TestWorkstreamSourceTitlesAndKeptRefBounds(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Source text bounds") + record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: strings.Repeat("界", 67) + "\nDetails."}) + if err != nil { + t.Fatal(err) + } + item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: record.ID}}) + if err != nil || item.Title != strings.Repeat("界", 66) || !utf8.ValidString(item.Title) { + t.Fatalf("derived cut: %+v %v", item, err) + } + path := filepath.Join(t.TempDir(), "two spaces.md") + if err := os.WriteFile(path, []byte("Words"), 0600); err != nil { + t.Fatal(err) + } + item, _, _, err = s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceFile, Path: &path}) + if err != nil || item.Title != "two spaces.md" { + t.Fatalf("base title: %+v %v", item, err) + } + item, _, err = s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("Two spaces")}) + if err != nil || item.Title != "Two spaces" { + t.Fatalf("given title: %+v %v", item, err) + } + for _, length := range []int{0, 129} { + ref := &KeptRef{Kind: "message", ID: strings.Repeat("x", length), ChatID: workstreamSourcePtr("chat")} + if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrInvalid) { + t.Fatalf("kept id %d: %v", length, err) + } + ref.ID = "message" + ref.ChatID = workstreamSourcePtr(strings.Repeat("x", length)) + if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrInvalid) { + t.Fatalf("kept chat %d: %v", length, err) + } + } + if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "message", ID: strings.Repeat("x", 128), ChatID: workstreamSourcePtr(strings.Repeat("c", 128))}}); err != nil { + t.Fatal(err) + } +} + +// WP-12 (a), (c), (d), (f): sources retain identity, flags and order, without +// becoming members or changing the things they point at. +func TestWorkstreamSourcesRoundTripAndRemove(t *testing.T) { + s, collections, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Sources") + before, err := collections.Members(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + path := workstreamSourceFile(t) + file, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceFile, Path: &path, ReadByJobs: true}) + if err != nil || already || file.Title != "usage.md" || file.AddedBy != "you" || file.Proposed || !file.ReadByJobs || len(change.Parts) == 0 { + t.Fatalf("file: %+v %v %+v %v", file, already, change, err) + } + repo, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing")}) + if err != nil { + t.Fatal(err) + } + duplicate, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("acme/billing")}) + if err != nil || !already || duplicate.ID != repo.ID || len(change.Parts) != 0 { + t.Fatalf("duplicate: %+v %v %+v %v", duplicate, already, change, err) + } + for _, flag := range []bool{false, true} { + if _, _, err := s.UpdateSource(ctx, file.ID, SourcePatch{ReadByJobs: &flag}); err != nil { + t.Fatal(err) + } + list, err := s.Sources(ctx, w.ID) + if err != nil || len(list) != 2 || list[0].ReadByJobs != flag || list[1].ID != repo.ID { + t.Fatalf("list: %+v %v", list, err) + } + } + after, err := collections.Members(ctx, w.ID) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("sources changed members: %+v %v", after, err) + } + removed, _, err := s.RemoveSource(ctx, file.ID) + if err != nil || !reflect.DeepEqual(removed, file) { + t.Fatalf("remove: %+v %v", removed, err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "Recorded usage.\n" { + t.Fatalf("source removal changed file: %q %v", data, err) + } +} diff --git a/internal/workstream/sources_validation.go b/internal/workstream/sources_validation.go new file mode 100644 index 0000000000..484e0cad90 --- /dev/null +++ b/internal/workstream/sources_validation.go @@ -0,0 +1,297 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +var sourceRepoPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$`) + +// ValidateSourceAdd checks kind fields and local existence without writing or reading contents. +func ValidateSourceAdd(p SourceAdd) error { + _, err := prepareSource(p) + return err +} + +func ValidateCreateSources(sources []SourceAdd) error { + _, err := prepareCreateSources(sources) + return err +} + +func prepareCreateSources(sources []SourceAdd) ([]preparedSource, error) { + if len(sources) > CreateSourcesMax { + return nil, invalid("create takes at most 200 sources") + } + out := make([]preparedSource, 0, len(sources)) + for _, p := range sources { + if p.Kind == SourceKept { + return nil, invalid("create cannot keep a source") + } + prepared, err := prepareSource(p) + if err != nil { + return nil, err + } + out = append(out, prepared) + } + return out, nil +} + +func sourceTitle(title *string) error { + if title == nil { + return nil + } + if len(*title) == 0 || !lineWithin(*title, SourceTitleMax) || strings.TrimSpace(*title) != *title || strings.TrimSpace(*title) == "" || strings.ContainsAny(*title, "\u2028\u2029") { + return invalid("source title must be 1–200 bytes of trimmed single-line text") + } + return nil +} + +func ValidateSourceWhy(why string) error { + if len(why) == 0 || !textWithin(why, SourceWhyMax) { + return invalid("a source proposal needs why of 1–500 bytes") + } + return nil +} + +func sourcePath(path string, folder bool) error { + if len(path) == 0 || !lineWithin(path, SourcePathMax) || !filepath.IsAbs(path) || filepath.Clean(path) != path { + return invalid("source path must be an existing clean absolute path of 1–4096 bytes") + } + info, err := os.Stat(path) + if err != nil { + return invalid("nothing readable exists at the source path") + } + if folder && !info.IsDir() { + return invalid("a local repo must be a folder") + } + return nil +} + +func sourceRepoName(repo string) bool { + if !sourceRepoPattern.MatchString(repo) { + return false + } + _, name, _ := strings.Cut(repo, "/") + return name != "." && name != ".." +} + +func sourceURL(raw string) (string, string, error) { + if len(raw) == 0 || len(raw) > SourceURLMax || !utf8.ValidString(raw) || strings.IndexFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { + return "", "", invalid("source URL must be 1–2048 bytes without whitespace or controls") + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { + return "", "", invalid("source URL needs an http or https scheme, a host and no credentials") + } + u.Scheme, u.Host = strings.ToLower(u.Scheme), strings.ToLower(u.Host) + stored := u.String() + if len(stored) > SourceURLMax { + return "", "", invalid("normalized source URL must be at most " + strconv.Itoa(SourceURLMax) + " bytes") + } + return stored, strings.TrimRight(u.Host+u.Path, "/"), nil +} + +func sourceFields(p SourceAdd) error { + if p.Path != nil && p.Kind != SourceFile || p.Repo != nil && p.Kind != SourceRepo && p.Kind != SourceIssue || p.Number != nil && p.Kind != SourceIssue || p.URL != nil && p.Kind != SourceLink || p.Ref != nil && p.Kind != SourceKept { + return invalid("source has a field used by another kind") + } + return sourceTitle(p.Title) +} + +func prepareSource(p SourceAdd) (preparedSource, error) { + out := preparedSource{SourceAdd: p} + if err := sourceFields(p); err != nil { + return out, err + } + var err error + switch p.Kind { + case SourceFile: + err = prepareFileSource(&out) + case SourceRepo: + err = prepareRepoSource(&out) + case SourceIssue: + err = prepareIssueSource(&out) + case SourceLink: + err = prepareLinkSource(&out) + case SourceKept: + err = prepareKeptSource(&out) + default: + err = invalid("unknown source kind") + } + if p.Title != nil { + out.title = *p.Title + } else { + out.title = sourceDerivedTitle(out.title) + } + return out, err +} + +func prepareFileSource(p *preparedSource) error { + if p.Path == nil { + return invalid("file source needs path") + } + if err := sourcePath(*p.Path, false); err != nil { + return err + } + p.target, p.title = *p.Path, filepath.Base(*p.Path) + return nil +} + +func prepareRepoSource(p *preparedSource) error { + if p.Repo == nil { + return invalid("repo source needs repo") + } + p.target, p.title = strings.ToLower(*p.Repo), *p.Repo + if sourceRepoName(*p.Repo) { + return nil + } + if err := sourcePath(*p.Repo, true); err != nil { + return err + } + p.target, p.title = *p.Repo, filepath.Base(*p.Repo) + return nil +} + +func prepareIssueSource(p *preparedSource) error { + if p.Repo == nil || !sourceRepoName(*p.Repo) || p.Number == nil || *p.Number < 1 || *p.Number > SourceIssueMax { + return invalid("issue needs owner/name and number 1–2147483647") + } + p.title = *p.Repo + "#" + strconv.Itoa(*p.Number) + p.target = strings.ToLower(p.title) + return nil +} + +func prepareLinkSource(p *preparedSource) error { + if p.URL == nil { + return invalid("link source needs url") + } + stored, title, err := sourceURL(*p.URL) + if err != nil { + return err + } + p.URL, p.target, p.title = &stored, stored, title + return nil +} + +func prepareKeptSource(p *preparedSource) error { + if err := sourceKeptRef(p.Ref); err != nil { + return err + } + b, err := json.Marshal(p.Ref) + p.target = string(b) + return err +} + +func sourceRefID(id string) bool { + return len(id) > 0 && len(id) <= SourceRefMax && utf8.ValidString(id) +} + +func sourceKeptRef(ref *KeptRef) error { + if ref == nil || !sourceRefID(ref.ID) { + return invalid("kept source needs a ref with an id of 1–128 bytes") + } + if ref.Kind != "message" && ref.ChatID != nil || ref.Kind != "factory_job" && ref.Factory != nil { + return invalid("kept ref has fields used by another kind") + } + switch ref.Kind { + case "record", "page": + case "message": + if ref.ChatID == nil || !sourceRefID(*ref.ChatID) { + return invalid("kept message needs chat_id of 1–128 bytes") + } + case "factory_job": + if ref.Factory == nil || !sourceRefID(*ref.Factory) { + return invalid("kept job needs factory of 1–128 bytes") + } + if _, err := sourceJobNumber(ref.ID); err != nil { + return err + } + default: + return invalid("unknown kept ref kind") + } + return nil +} + +func sourceJobNumber(id string) (int, error) { + if strings.IndexFunc(id, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return 0, invalid("kept job needs a positive decimal job number") + } + n, err := strconv.Atoi(id) + if err != nil || n < 1 { + return 0, invalid("kept job needs a positive decimal job number") + } + return n, nil +} + +func sourceKeptIn(ctx context.Context, tx *sql.Tx, id string, p *preparedSource) error { + if p.Kind != SourceKept { + return nil + } + var title string + switch p.Ref.Kind { + case "record": + if err := tx.QueryRowContext(ctx, "SELECT text FROM workstream_records WHERE id=? AND workstream_id=?", p.Ref.ID, id).Scan(&title); err != nil { + return found(err) + } + title, _, _ = strings.Cut(title, "\n") + case "page": + if err := tx.QueryRowContext(ctx, "SELECT title FROM workstream_pages WHERE id=? AND workstream_id=? AND deleted_at IS NULL", p.Ref.ID, id).Scan(&title); err != nil { + return found(err) + } + case "message": + title = "Kept message" + case "factory_job": + if err := sourceKeptJobIn(ctx, tx, id, p.Ref); err != nil { + return err + } + title = "Job " + p.Ref.ID + } + if p.Title == nil { + p.title = sourceDerivedTitle(title) + } + return nil +} + +func sourceKeptJobIn(ctx context.Context, tx *sql.Tx, id string, ref *KeptRef) error { + number, err := sourceJobNumber(ref.ID) + if err != nil { + return err + } + var member bool + if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_jobs WHERE workstream_id=? AND factory=? AND job=?)", id, *ref.Factory, strconv.Itoa(number)).Scan(&member); err != nil { + return err + } + if !member { + return invalid("kept job is not a member of this workstream") + } + return nil +} + +func sourceDerivedTitle(title string) string { + title = strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' { + return ' ' + } + return r + }, title)) + if title == "" { + title = "Source" + } + if len(title) <= SourceTitleMax { + return title + } + end := SourceTitleMax + for end > 0 && !utf8.RuneStart(title[end]) { + end-- + } + return strings.TrimSpace(title[:end]) +} diff --git a/internal/workstream/store.go b/internal/workstream/store.go index d57b476aea..ab9f0104fe 100644 --- a/internal/workstream/store.go +++ b/internal/workstream/store.go @@ -262,6 +262,20 @@ func (s *Store) Create(ctx context.Context, p Create) (Workstream, Change, error if !textWithin(p.Why, 4000) || !lineWithin(p.ClientID, 128) || len(p.Refs) > 200 { return Workstream{}, Change{}, invalid("create exceeds workstream bounds") } + // A retry does not insert sources, so their paths need no revalidation. + if p.ClientID != "" { + w, err := s.ByClientID(ctx, p.ClientID) + if err == nil { + return w, Change{}, nil + } + if !errors.Is(err, ErrNotFound) { + return Workstream{}, Change{}, err + } + } + sources, err := prepareCreateSources(p.Sources) + if err != nil { + return Workstream{}, Change{}, err + } refs := append([]workspace.Ref(nil), p.Refs...) refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.AgentID}) if p.OriginChatID != "" { @@ -276,7 +290,7 @@ func (s *Store) Create(ctx context.Context, p Create) (Workstream, Change, error } var out Workstream var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { if p.ClientID != "" { var id string err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", p.ClientID).Scan(&id) @@ -312,8 +326,16 @@ func (s *Store) Create(ctx context.Context, p Create) (Workstream, Change, error if err := saveVersion(ctx, tx, pageID); err != nil { return err } + for _, source := range sources { + if _, _, _, err := s.addSourceIn(ctx, tx, id, source, false, ""); err != nil { + return err + } + } out, err = getIn(ctx, tx, id) c = Change{ID: id, Parts: []ChangedPart{PartWorkstream, PartPages, PartJobs}} + if len(sources) > 0 { + c.Parts = append(c.Parts, PartSources) + } return err }) if err != nil { diff --git a/internal/workstream/workstream.go b/internal/workstream/workstream.go index af0e56b535..f9dc634ab8 100644 --- a/internal/workstream/workstream.go +++ b/internal/workstream/workstream.go @@ -40,6 +40,7 @@ const ( PartRecords ChangedPart = "records" PartGoAheads ChangedPart = "goaheads" PartJobs ChangedPart = "jobs" + PartSources ChangedPart = "sources" ) // Change is empty for an unchanged write. Adapters emit it only after success. @@ -85,6 +86,7 @@ type Create struct { AgentID string ClientID string Refs []workspace.Ref + Sources []SourceAdd } type Patch struct { Name, Why, Autonomy *string From e75aa1f1acd5ef2ee7dc0756836eb7e458e6a6a0 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 13:01:31 -0500 Subject: [PATCH 16/20] app: workstream suggested edits (wire v0.5: suggestion.list/keep/drop, suggest_page_edit, schema 6) (#2318) * workstreams: page text blocks, edit anchoring and keeping by code point Co-Authored-By: Claude Opus 5.5 * workstreams: suggestions store: list, keep one or all as one version, drop Co-Authored-By: Claude Opus 5.5 * app: workstreams wire v0.3: suggestion.list, suggestion.keep, suggestion.drop Co-Authored-By: Claude Opus 5.5 * app: the workstream agent suggests edits on the person's pages (suggest_page_edit) Co-Authored-By: Claude Opus 5.5 * docs: change entry for workstream suggestions Co-Authored-By: Claude Opus 5.5 * docs: number the workstream suggestions change entry Co-Authored-By: Claude Opus 5.5 * workstreams: keeping an edit leaves other text nodes' fields intact; empty marks equal no marks Co-Authored-By: Claude Opus 5.5 * docs: the protocol lists the suggestion methods Co-Authored-By: Claude Opus 5.5 * workstreams: schema 5 adds the suggestion tables after the watching agent's schema 4 Files at 1-4 stay readable as they are (suggestion reads answer empty); the first write applies every missing step in order inside its transaction. Co-Authored-By: Claude Opus 5.5 * docs: the suggestions change entry names schema 5 and wire v0.4 Co-Authored-By: Claude Opus 5.5 * workstreams: schema 6 adds the suggestion tables after Sources' schema 5 Files at 1-5 stay readable as they are (suggestion reads answer empty); the first write applies every missing step in order inside its transaction. Co-Authored-By: Claude Opus 5.5 * app: the Suggestions addition counts 36 methods and eight agent tools; change entry names schema 6 Co-Authored-By: Claude Opus 5.5 * app: the suggestion prompt rule fits the workstream section's 1000-byte budget beside the Sources rule Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 39 ++ cmd/codeaf/appworkstreams_agent.go | 46 +- cmd/codeaf/appworkstreams_conformance_test.go | 54 +- cmd/codeaf/appworkstreams_suggestions_test.go | 323 ++++++++++ .../testdata/workstreams-wire/contract.json | 139 ++++- .../workstreams-wire/suggestion.drop.json | 111 ++++ .../workstreams-wire/suggestion.keep.json | 203 +++++++ .../workstreams-wire/suggestion.list.json | 65 ++ .../2318-app-workstream-suggestions.md | 23 + docs/design/app-v3/PROTOCOL.md | 8 + internal/appwire/workstreams.go | 59 +- internal/appwire/workstreams_check.go | 10 + internal/appwire/workstreams_test.go | 17 +- internal/manual/chat/collections.md | 25 +- internal/session/actioncategory.go | 2 +- internal/session/manual_test.go | 4 +- internal/session/programhold_test.go | 3 +- internal/session/workstream_contract.go | 14 +- internal/session/workstream_schema.go | 4 + .../session/workstream_suggestions_test.go | 126 ++++ internal/session/workstream_tools.go | 25 +- internal/session/workstream_tools_test.go | 18 +- internal/workspace/schema4_test.go | 8 +- internal/workspace/schema6_test.go | 228 +++++++ internal/workspace/store.go | 30 +- internal/workspace/workstream_sources_test.go | 10 +- internal/workspace/workstreams.go | 26 + internal/workspace/workstreams_test.go | 4 +- .../pagedoc/testdata/suggestion-keep.json | 306 ++++++++++ internal/workstream/pagedoc/textblocks.go | 275 +++++++++ .../workstream/pagedoc/textblocks_test.go | 235 ++++++++ internal/workstream/sources_migration_test.go | 3 +- internal/workstream/suggestions.go | 532 +++++++++++++++++ internal/workstream/suggestions_test.go | 554 ++++++++++++++++++ 34 files changed, 3478 insertions(+), 51 deletions(-) create mode 100644 cmd/codeaf/appworkstreams_suggestions_test.go create mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json create mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.list.json create mode 100644 docs/changes/unreleased/2318-app-workstream-suggestions.md create mode 100644 internal/session/workstream_suggestions_test.go create mode 100644 internal/workspace/schema6_test.go create mode 100644 internal/workstream/pagedoc/testdata/suggestion-keep.json create mode 100644 internal/workstream/pagedoc/textblocks.go create mode 100644 internal/workstream/pagedoc/textblocks_test.go create mode 100644 internal/workstream/suggestions.go create mode 100644 internal/workstream/suggestions_test.go diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 14cb7b658c..848292dc8a 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -523,6 +523,45 @@ func appWorkstreamSource(p *workstream.Source) *appwire.WorkstreamSource { } return &appwire.WorkstreamSource{Kind: p.Kind, ID: p.ID, ChatID: workstreamOptional(p.ChatID), Factory: workstreamOptional(p.Factory), Label: workstreamOptional(p.Label)} } + +func appWorkstreamSuggestion(s workstream.Suggestion) appwire.WorkstreamSuggestion { + out := appwire.WorkstreamSuggestion{ID: s.ID, WorkstreamID: s.WorkstreamID, PageID: s.PageID, BaseVersion: s.BaseVersion, Why: s.Why, By: s.By, Source: appWorkstreamSource(s.Source), CreatedAt: s.CreatedAt, Edits: []appwire.WorkstreamSuggestionEdit{}} + for _, e := range s.Edits { + out.Edits = append(out.Edits, appwire.WorkstreamSuggestionEdit{ID: e.ID, Block: e.Block, Offset: e.Offset, Delete: e.Delete, Insert: e.Insert, BlockText: e.BlockText, Why: e.Why, State: e.State, Applies: e.Applies, DecidedAt: e.DecidedAt}) + } + return out +} + +func (b *appBackend) WorkstreamSuggestionList(p appwire.WorkstreamSuggestionListParams) (appwire.WorkstreamSuggestionListResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionListResult, workstream.Change, error) { + out := appwire.WorkstreamSuggestionListResult{Suggestions: []appwire.WorkstreamSuggestion{}} + suggestions, err := s.Suggestions(context.Background(), p.PageID) + for _, suggestion := range suggestions { + out.Suggestions = append(out.Suggestions, appWorkstreamSuggestion(suggestion)) + } + return out, workstream.Change{}, err + }) +} + +func (b *appBackend) WorkstreamSuggestionKeep(p appwire.WorkstreamSuggestionKeepParams) (appwire.WorkstreamSuggestionKeepResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionKeepResult, workstream.Change, error) { + page, suggestion, change, err := s.KeepSuggestion(context.Background(), p.ID, workstreamValue(p.EditID)) + if err != nil { + var stale *workstream.VersionConflict + if errors.As(err, &stale) { + return appwire.WorkstreamSuggestionKeepResult{}, change, &appwire.Error{Code: appwire.CodeVersionConflict, Message: "the page changed and this edit no longer applies", Detail: appwire.WorkstreamVersionConflict{ID: stale.ID, Version: stale.Version, UpdatedAt: stale.UpdatedAt, UpdatedBy: stale.UpdatedBy}} + } + } + return appwire.WorkstreamSuggestionKeepResult{Page: appWorkstreamPage(page), Suggestion: appWorkstreamSuggestion(suggestion)}, change, err + }) +} + +func (b *appBackend) WorkstreamSuggestionDrop(p appwire.WorkstreamSuggestionDropParams) (appwire.WorkstreamSuggestionDropResult, error) { + return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionDropResult, workstream.Change, error) { + suggestion, change, err := s.DropSuggestion(context.Background(), p.ID, workstreamValue(p.EditID)) + return appwire.WorkstreamSuggestionDropResult{Suggestion: appWorkstreamSuggestion(suggestion)}, change, err + }) +} func workstreamSource(p *appwire.WorkstreamSource) *workstream.Source { if p == nil { return nil diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go index adaff29491..5fc7e6c842 100644 --- a/cmd/codeaf/appworkstreams_agent.go +++ b/cmd/codeaf/appworkstreams_agent.go @@ -98,14 +98,7 @@ func (d *workstreamAgentDoor) Read(ctx context.Context, ask session.WorkstreamRe 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}) + return workstreamAgentReadPage(ctx, s, w.ID, ask.PageID) case "records": return workstreamAgentReadRecords(ctx, s, w.ID, ask) case "plan": @@ -124,6 +117,26 @@ func (d *workstreamAgentDoor) Read(ctx context.Context, ask session.WorkstreamRe return nil, errors.New("what must name a workstream part") } +func workstreamAgentReadPage(ctx context.Context, s *workstream.Store, id, pageID string) (json.RawMessage, error) { + p, err := workstreamAgentPage(ctx, s, id, pageID, true) + if err != nil { + return nil, err + } + suggestions, err := s.Suggestions(ctx, p.ID) + if err != nil { + return nil, err + } + open := make([]appwire.WorkstreamSuggestion, 0, len(suggestions)) + for _, suggestion := range suggestions { + open = append(open, appWorkstreamSuggestion(suggestion)) + } + return json.Marshal(struct { + appwire.WorkstreamPageNode + Markdown *string `json:"markdown"` + Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` + }{appWorkstreamPageNode(p.PageNode), p.Markdown, open}) +} + func workstreamAgentReadPages(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { pages, err := s.Pages(ctx, id) if err != nil { @@ -300,6 +313,23 @@ func (d *workstreamAgentDoor) WritePage(ctx context.Context, ask session.Workstr return session.WorkstreamPageWritten{ID: p.ID, Title: p.Title, Version: p.Version}, workstreamAgentError(err) } +func (d *workstreamAgentDoor) SuggestEdits(ctx context.Context, ask session.WorkstreamSuggestionAsk) (session.WorkstreamSuggested, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return session.WorkstreamSuggested{}, err + } + page, err := workstreamAgentPage(ctx, s, w.ID, ask.PageID, false) + if err != nil { + return session.WorkstreamSuggested{}, err + } + suggestion, change, err := s.CreateSuggestion(ctx, workstream.SuggestionCreate{WorkstreamID: w.ID, PageID: ask.PageID, BaseVersion: ask.BaseVersion, Why: ask.Why, Edits: ask.Edits, Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: d.agentID}}}) + d.backend.workstreamChanged(change) + if errors.Is(err, workstream.ErrTooLarge) { + return session.WorkstreamSuggested{}, errors.New("This workstream holds too many suggestions; nothing was suggested.") + } + return session.WorkstreamSuggested{ID: suggestion.ID, Title: page.Title, Count: len(suggestion.Edits)}, 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: workstream.ReadByJobsDefault(ask.Title), Doc: doc, Attribution: who}) diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go index bd331b4394..02a92026be 100644 --- a/cmd/codeaf/appworkstreams_conformance_test.go +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -615,6 +615,13 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir } else { fixture.saveByAgent(t) } + case "suggestion.list", "suggestion.keep", "suggestion.drop": + fixture.suggestion(t) + if name == "suggestion.keep" && situation == 1 { + if _, err := backend.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: fixture.IDs["sg_7c41"], EditID: workstreamPtr("e1")}); err != nil { + t.Fatal(err) + } + } } if name == "record.list" || name == "record.keep" || name == "record.drop" || name == "record.supersede" || name == "workstream.changed" { // WP-F2 (d), (f): only the illustrated kind is present in each example. @@ -658,6 +665,35 @@ func (fixture *workstreamWireFixture) page(t *testing.T, alias, parent string) { fixture.IDs[alias] = page.Page.ID } +// WP-6d conformance: both anchors and every optional key match the wire contract. +func (fixture *workstreamWireFixture) suggestion(t *testing.T) { + t.Helper() + s, err := fixture.Backend.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + id := fixture.IDs["pg_31d0"] + doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Meter usage nightly."}]},{"type":"paragraph","content":[{"type":"text","text":"Bill at month end."}]}]}`) + page, err := s.Page(context.Background(), id, false) + if err != nil { + t.Fatal(err) + } + for page.Version < 3 { + page, _, err = s.SavePage(context.Background(), id, page.Version, doc, workstream.Attribution{}) + if err != nil { + t.Fatal(err) + } + } + suggestion, _, err := s.CreateSuggestion(context.Background(), workstream.SuggestionCreate{WorkstreamID: fixture.Stream.ID, PageID: id, BaseVersion: page.Version, Why: "api#1107 moved metering to hourly", Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: fixture.Stream.AgentID}}, Edits: []workstream.SuggestionFind{ + {Find: "nightly", Replace: "every hour", Why: "The meter runs hourly since api#1107"}, + {Find: "Bill at month end.", Replace: "Invoices stay monthly. Bill at month end.", Why: "Says what did not change"}, + }}) + if err != nil { + t.Fatal(err) + } + fixture.IDs["sg_7c41"] = suggestion.ID +} + func (fixture *workstreamWireFixture) deletedPage(t *testing.T) { t.Helper() fixture.page(t, "pg_33f2", fixture.IDs["pg_31d0"]) @@ -845,6 +881,7 @@ func TestAppWorkstreamWireConformance(t *testing.T) { "page.delete", "page.history", "page.restore", "record.list", "record.propose", "record.keep", "record.drop", "record.supersede", "goahead.list", "goahead.grant", "goahead.revoke", "wake.list", "wake.apply", "wake.undo", "wake.not_now", + "suggestion.list", "suggestion.keep", "suggestion.drop", "source.list", "source.add", "source.update", "source.remove", "collections.add", "workstream.changed", } errorCases := []workstreamWireErrorCase{ @@ -861,6 +898,7 @@ func TestAppWorkstreamWireConformance(t *testing.T) { {Name: "create_owner", Method: "workstream.create", Code: "in_other_workstream", Example: "collections.add"}, {Name: "page_version", Method: "page.save", Code: "version_conflict", Example: "page.save"}, {Name: "plan_version", Method: "workstream.plan_set", Code: "version_conflict", Example: "page.save"}, + {Name: "suggestion_version", Method: "suggestion.keep", Code: "version_conflict", Example: "suggestion.keep"}, {Name: "cycle", Method: "page.move", Code: "cycle"}, {Name: "too_large", Method: "page.save", Code: "too_large"}, {Name: "plan_too_large", Method: "workstream.plan_set", Code: "too_large"}, @@ -1119,11 +1157,11 @@ func workstreamWireKeptGoAhead(t *testing.T, connection *workstreamWireConnectio func workstreamWireCoverage(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, cases []string, errorCases []workstreamWireErrorCase) { t.Helper() - if len(contract.Methods) != 33 { - t.Errorf("v0.4 has %d methods, want 33", len(contract.Methods)) + if len(contract.Methods) != 36 { + t.Errorf("the Suggestions addition (v0.5) has %d methods, want 36", len(contract.Methods)) } if len(contract.Events) != 1 || contract.Events[0].Event != appwire.EventWorkstreamChanged { - t.Errorf("v0.4 events: want only workstream.changed, got %v", contract.Events) + t.Errorf("the Suggestions addition (v0.5) events: want only workstream.changed, got %v", contract.Events) } if !reflect.DeepEqual(contract.Methods, appwire.WorkstreamMethods) { t.Errorf("contract methods/order %v differ from appwire.WorkstreamMethods %v", contract.Methods, appwire.WorkstreamMethods) @@ -1286,6 +1324,9 @@ func workstreamWireEvent(t *testing.T, contract *workstreamWireContract, example func workstreamWireError(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, errorCase workstreamWireErrorCase) { t.Helper() fixture := workstreamWireSeed(t, "workstream.get", 0) + if errorCase.Name == "suggestion_version" { + fixture.suggestion(t) + } if errorCase.Method == "source.update" || errorCase.Method == "source.remove" { fixture.sources(t) } @@ -1325,6 +1366,10 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m fixture.saveByAgent(t) case "plan_version": fixture.plan(t) + case "suggestion_version": + if _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: fixture.IDs["pg_31d0"], BaseVersion: 3, Doc: json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Meter usage daily."}]},{"type":"paragraph","content":[{"type":"text","text":"Bill at month end."}]}]}`)}); err != nil { + t.Fatal(err) + } case "cycle": fixture.page(t, "pg_32e1", fixture.IDs["pg_31d0"]) fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"]) @@ -1390,6 +1435,9 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m if errorCase.Name == "plan_version" { id, version, by = fixture.Stream.ID, "1", "you" } + if errorCase.Name == "suggestion_version" { + version, by = "4", "you" + } if !ok || detail["id"] != id || detail["version"] != json.Number(version) || detail["updated_by"] != by { t.Errorf("stale detail does not describe the current seeded version: %v", object["detail"]) } diff --git a/cmd/codeaf/appworkstreams_suggestions_test.go b/cmd/codeaf/appworkstreams_suggestions_test.go new file mode 100644 index 0000000000..970abafa05 --- /dev/null +++ b/cmd/codeaf/appworkstreams_suggestions_test.go @@ -0,0 +1,323 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream" + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +// WP-6d Schema, Tool: both wire lists and workstream_read pages work before +// suggestion tables exist. Refused decisions do not commit their migration. +func TestAppWorkstreamSuggestionsOnOlderSchemas(t *testing.T) { + for _, version := range []int{3, 4, 5} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) + if err != nil || len(pages.Pages) == 0 { + t.Fatal("missing fixture page", err) + } + pageID := pages.Pages[0].ID + store, err := b.collectionStore(false) + if err != nil { + t.Fatal(err) + } + if err := store.Write(ctx, func(tx *sql.Tx) error { + ddl := "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions;" + if version < workspace.WorkstreamSourcesSchemaVersion { + ddl += "DROP TABLE workstream_sources;" + } + if version == 3 { + ddl += "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes;" + } + _, err := tx.Exec(ddl + fmt.Sprintf("PRAGMA user_version=%d", version)) + return err + }); err != nil { + t.Fatal(err) + } + events := len(workstreamEvents(rec)) + listed, err := b.WorkstreamSuggestionList(appwire.WorkstreamSuggestionListParams{PageID: pageID}) + if err != nil || listed.Suggestions == nil || len(listed.Suggestions) != 0 { + t.Fatalf("old-schema list: %+v %v", listed, err) + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: pageID}) + if err != nil { + t.Fatal(err) + } + var read struct { + Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` + } + if err := json.Unmarshal(raw, &read); err != nil || read.Suggestions == nil || len(read.Suggestions) != 0 { + t.Fatalf("old-schema page read: %s %v", raw, err) + } + _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: "missing"}) + workstreamCheckError(t, err, appwire.CodeNotFound) + _, err = b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: "missing"}) + workstreamCheckError(t, err, appwire.CodeNotFound) + if len(workstreamEvents(rec)) != events { + t.Fatal("old-schema reads or refused decisions emitted an event") + } + if err := store.Read(ctx, func(_ *sql.Tx, got int) error { + if got != version { + t.Fatalf("old-schema reads or refused decisions migrated to %d", got) + } + return nil + }); err != nil { + t.Fatal(err) + } + }) + } +} + +// WP-6d (a), Tool: the real door refuses agent-owned pages and direct writes +// to person pages, then suggests with chat attribution and pages-only events. +func TestAppWorkstreamAgentSuggestionsAndOwnership(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + s, err := b.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + page, _, err := s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old words.")}) + if err != nil { + t.Fatal(err) + } + history, err := s.PageHistory(ctx, page.ID, 50) + if err != nil { + t.Fatal(err) + } + events := len(workstreamEvents(rec)) + _, err = door.WritePage(ctx, session.WorkstreamPageWrite{PageID: page.ID, Markdown: "Direct write", UpdatedFrom: "Discussion"}) + if !errors.Is(err, session.ErrWorkstreamNotAgentPage) || err.Error() != "That page is the person's. You write only your own pages; suggest edits on theirs with suggest_page_edit." { + t.Fatalf("direct write refusal: %v", err) + } + after, _ := s.Page(ctx, page.ID, false) + afterHistory, _ := s.PageHistory(ctx, page.ID, 50) + if !reflect.DeepEqual(page, after) || !reflect.DeepEqual(history, afterHistory) || len(workstreamEvents(rec)) != events { + t.Fatal("direct write changed state") + } + owner := "agent" + if _, _, err := s.UpdatePage(ctx, page.ID, workstream.PagePatch{Owner: &owner}); err != nil { + t.Fatal(err) + } + ask := session.WorkstreamSuggestionAsk{PageID: page.ID, BaseVersion: page.Version, Why: "Clarify", Edits: []workstream.SuggestionFind{{Find: "Old", Replace: "New", Why: "Clearer"}}} + _, err = door.SuggestEdits(ctx, ask) + if !errors.Is(err, workstream.ErrNotPersonPage) || err.Error() != "That page is yours; change it with write_agent_page. Suggest edits only on the person's pages." { + t.Fatalf("own page refusal: %v", err) + } + open, err := s.Suggestions(ctx, page.ID) + if err != nil || len(open) != 0 || len(workstreamEvents(rec)) != events { + t.Fatal("own-page refusal stored or emitted", err) + } + owner = "you" + if _, _, err := s.UpdatePage(ctx, page.ID, workstream.PagePatch{Owner: &owner}); err != nil { + t.Fatal(err) + } + result, err := door.SuggestEdits(ctx, ask) + if err != nil || result.Title != page.Title || result.Count != 1 { + t.Fatalf("suggest: %+v %v", result, err) + } + open, err = s.Suggestions(ctx, page.ID) + if err != nil || len(open) != 1 || open[0].ID != result.ID || !reflect.DeepEqual(open[0].Source, &workstream.Source{Kind: "chat", ID: w.AgentID}) { + t.Fatalf("suggested source: %+v %v", open, err) + } + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: page.ID}) + if err != nil { + t.Fatal(err) + } + var read struct { + Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` + } + if err := json.Unmarshal(raw, &read); err != nil || len(read.Suggestions) != 1 || !reflect.DeepEqual(read.Suggestions[0], appWorkstreamSuggestion(open[0])) { + t.Fatalf("page read lost suggestions: %s %v", raw, err) + } + after, _ = s.Page(ctx, page.ID, false) + afterHistory, _ = s.PageHistory(ctx, page.ID, 50) + if !reflect.DeepEqual(after.Doc, page.Doc) || after.Version != page.Version || !reflect.DeepEqual(history, afterHistory) { + t.Fatal("suggestion changed page") + } + changes := workstreamEvents(rec) + if len(changes) != events+1 || !reflect.DeepEqual(changes[len(changes)-1], appwire.WorkstreamChanged{ID: w.ID, Parts: []string{"pages"}}) { + t.Fatalf("create events: %+v", changes[events:]) + } +} + +// WP-6d (a): every source-taking wire parameter refuses suggestion attribution +// without changing pages, history, records, plans, workstream or event count. +func TestAppWorkstreamSuggestionSourceParamsRefused(t *testing.T) { + b, rec, _, _ := workstreamBackend(t) + w := workstreamCreateForTest(t, b, "Source refusals") + connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) + s, err := b.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + pages, err := s.Pages(context.Background(), w.ID) + if err != nil { + t.Fatal(err) + } + id := pages[0].ID + record, _, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Decision"}) + if err != nil { + t.Fatal(err) + } + snapshot := func() []any { + page, err := s.Page(context.Background(), id, false) + if err != nil { + t.Fatal(err) + } + history, err := s.PageHistory(context.Background(), id, 50) + if err != nil { + t.Fatal(err) + } + nodes, err := s.Pages(context.Background(), w.ID) + if err != nil { + t.Fatal(err) + } + records, err := s.Records(context.Background(), w.ID, "", "", true) + if err != nil { + t.Fatal(err) + } + plan, err := s.Plan(context.Background(), w.ID) + if err != nil { + t.Fatal(err) + } + stream, err := s.Get(context.Background(), w.ID) + if err != nil { + t.Fatal(err) + } + return []any{page, history, nodes, records, plan, stream} + } + before := snapshot() + events := len(workstreamEvents(rec)) + source := map[string]any{"kind": "suggestion", "id": "sg_forged"} + for _, test := range []struct { + method string + params map[string]any + }{ + {"page.save", map[string]any{"id": id, "base_version": 1, "doc": map[string]any{"type": "doc"}, "source": source}}, + {"page.create", map[string]any{"workstream_id": w.ID, "title": "Forged", "source": source}}, + {"record.propose", map[string]any{"workstream_id": w.ID, "kind": "decision", "text": "Forged", "source": source}}, + {"record.supersede", map[string]any{"id": record.ID, "text": "Forged", "source": source}}, + {"workstream.plan_set", map[string]any{"id": w.ID, "base_version": 0, "milestones": []any{}, "waves": []any{map[string]any{"n": 1, "auto_start": false, "items": []any{map[string]any{"kind": "draft", "draft_id": "draft", "title": "Forged", "source": source}}}}}}, + } { + frame := connection.call(t, test.method, test.params) + if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { + t.Fatalf("%s: %v", test.method, frame) + } + if !reflect.DeepEqual(before, snapshot()) || len(workstreamEvents(rec)) != events { + t.Fatalf("%s refusal changed state", test.method) + } + } +} + +// WP-6d (b), (d), (h): wire decisions emit pages only; stale and done decisions +// emit nothing and carry the contract error/detail. Done reads remain available. +func TestAppWorkstreamSuggestionDecisionsAndDone(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + s, err := b.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + page, _, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old words.\n\nOther words.")}) + if err != nil { + t.Fatal(err) + } + ask := session.WorkstreamSuggestionAsk{PageID: page.ID, BaseVersion: page.Version, Why: "Clarity", Edits: []workstream.SuggestionFind{{Find: "Old", Replace: "New", Why: "First"}, {Find: "Other", Replace: "Clear", Why: "Second"}}} + suggested, err := door.SuggestEdits(context.Background(), ask) + if err != nil { + t.Fatal(err) + } + events := len(workstreamEvents(rec)) + if _, err := b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: suggested.ID, EditID: workstreamPtr("e1")}); err != nil { + t.Fatal(err) + } + if _, err := b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: suggested.ID, EditID: workstreamPtr("e2")}); err != nil { + t.Fatal(err) + } + changes := workstreamEvents(rec) + if len(changes) != events+2 { + t.Fatal("missing decision events") + } + for _, change := range changes[events:] { + if !reflect.DeepEqual(change, appwire.WorkstreamChanged{ID: w.ID, Parts: []string{"pages"}}) { + t.Fatalf("decision parts: %+v", change) + } + } + page, err = s.Page(context.Background(), page.ID, false) + if err != nil { + t.Fatal(err) + } + ask.BaseVersion, ask.Edits = page.Version, []workstream.SuggestionFind{{Find: "New", Replace: "Newest", Why: "Update"}} + stale, err := door.SuggestEdits(context.Background(), ask) + if err != nil { + t.Fatal(err) + } + page, _, err = s.SavePage(context.Background(), page.ID, page.Version, pagedoc.FromMarkdown("Changed by the person."), workstream.Attribution{}) + if err != nil { + t.Fatal(err) + } + events = len(workstreamEvents(rec)) + _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: stale.ID}) + failure := workstreamCheckError(t, err, appwire.CodeVersionConflict) + want := appwire.WorkstreamVersionConflict{ID: page.ID, Version: page.Version, UpdatedAt: page.UpdatedAt, UpdatedBy: page.UpdatedBy} + if failure.Message != "the page changed and this edit no longer applies" || !reflect.DeepEqual(failure.Detail, want) || len(workstreamEvents(rec)) != events { + t.Fatalf("stale receipt: %+v", failure) + } + if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { + t.Fatal(err) + } + events = len(workstreamEvents(rec)) + _, err = door.SuggestEdits(context.Background(), ask) + if !errors.Is(err, session.ErrWorkstreamDone) || err.Error() != "This workstream is done; nothing was added or written." { + t.Fatalf("done tool: %v", err) + } + _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: stale.ID}) + workstreamCheckError(t, err, appwire.CodeConflict) + _, err = b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: stale.ID}) + workstreamCheckError(t, err, appwire.CodeConflict) + if _, err := b.WorkstreamSuggestionList(appwire.WorkstreamSuggestionListParams{PageID: page.ID}); err != nil { + t.Fatal(err) + } + if len(workstreamEvents(rec)) != events { + t.Fatal("done refusals or read emitted events") + } +} + +// WP-6d Tool: the adapter maps the all-states count bound to its exact answer. +func TestAppWorkstreamSuggestionCountRefusal(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + s, err := b.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + page, _, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old")}) + if err != nil { + t.Fatal(err) + } + store, err := b.collectionStore(false) + if err != nil { + t.Fatal(err) + } + if err := store.Write(context.Background(), func(tx *sql.Tx) error { + _, err := tx.Exec(`WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x= WorkstreamWakeSchemaVersion { + ddl += workstreamWakeSchema + } + if version >= WorkstreamSourcesSchemaVersion { + ddl += workstreamSourcesSchema + } + if _, err := db.Exec(ddl + fmt.Sprintf(` +PRAGMA application_id=%d; PRAGMA user_version=%d; +INSERT INTO collections(id,name,created_at) VALUES ('existing','Existing',100); +INSERT INTO workstreams(id,agent_id,created_at,updated_at) VALUES ('existing','agent',100,100); +INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('existing','factory_job','7','','f'); +INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('page','existing','Notes','you',0,1,100,'you','{"type":"doc","content":[]}'); +INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('page',1,100,'you','Notes','{"type":"doc","content":[]}',27); +INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) VALUES ('record','existing','decision','Keep','kept','you',100); +INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) VALUES ('grant','existing','wave',1,1,200,'active',100); +INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('existing',1,100,'you','[]','[]'); +`, applicationID, version)); err != nil { + t.Fatal(err) + } + if version >= WorkstreamWakeSchemaVersion { + if _, err := db.Exec(` +INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,cost_usd,started_at,settled_at) VALUES ('wake','existing','morning:2026-11-03','{}','settled','Existing wake',0.25,100,200); +INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,applied_at) VALUES ('item','wake',0,'page','applied','Existing change','{}','{}',200); +`); err != nil { + t.Fatal(err) + } + } + if version >= WorkstreamSourcesSchemaVersion { + if _, err := db.Exec(` +INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,why,read_by_jobs,proposed,added_by,added_at) VALUES ('source','existing','link','https://example.com','Example','https://example.com','Existing context',1,1,'agent',100); +`); err != nil { + t.Fatal(err) + } + } + return path +} + +// WP-6d Schema: opening, reading and a refused first write preserve the file; +// the first committed write upgrades every missing step and keeps all old rows. +func TestWorkstreamSchemaSixIsAnAtomicAdditiveMigration(t *testing.T) { + for _, version := range []int{3, 4, 5} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := writeWorkstreamSuggestionPredecessor(t, version) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} + if version >= WorkstreamWakeSchemaVersion { + tables = append(tables, "workstream_wakes", "workstream_wake_items") + } + if version >= WorkstreamSourcesSchemaVersion { + tables = append(tables, "workstream_sources") + } + rows := map[string][][]any{} + for _, table := range tables { + rows[table] = workstreamStoredRows(t, s.db, table) + if len(rows[table]) == 0 { + t.Fatalf("fixture has no rows in %s", table) + } + } + if list, err := s.List(context.Background(), nil); err != nil || len(list) != 1 || list[0].ID != "existing" { + t.Fatalf("schema %d list: %+v %v", version, list, err) + } + if err := s.Read(context.Background(), func(_ *sql.Tx, got int) error { + if got != version { + t.Fatalf("read version %d, want %d", got, version) + } + return nil + }); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) || userVersion(t, s) != version { + t.Fatal("open or read changed the old file", err) + } + refusal := errors.New("refused write") + if err := s.Write(context.Background(), func(tx *sql.Tx) error { + if _, err := tx.Exec("INSERT INTO collections(id,name) VALUES ('rollback','Rollback')"); err != nil { + return err + } + return refusal + }); !errors.Is(err, refusal) { + t.Fatal(err) + } + after, err = os.ReadFile(path) + if err != nil || string(after) != string(before) || userVersion(t, s) != version { + t.Fatal("refused write changed the old file", err) + } + var n int + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE name IN ('workstream_wakes','workstream_wake_items','workstream_sources','workstream_suggestions','workstream_suggestion_edits')").Scan(&n); err != nil || n != len(tables)-9 { + t.Fatal("refused write left migration tables", n, err) + } + if err := s.Write(context.Background(), func(tx *sql.Tx) error { + _, err := tx.Exec(` +INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,created_at) VALUES ('suggestion','existing','page',1,'Clarify','agent',200); +INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) VALUES ('suggestion','e1',0,'[0]',0,'Old','New','Old','Clearer','proposed');`) + return err + }); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 6 { + t.Fatal("first write did not install schema 6") + } + for _, table := range tables { + if !reflect.DeepEqual(rows[table], workstreamStoredRows(t, s.db, table)) { + t.Errorf("migration changed %s", table) + } + } + for _, table := range []string{"workstream_wakes", "workstream_wake_items", "workstream_sources", "workstream_suggestions", "workstream_suggestion_edits"} { + if _, err := s.db.Exec("SELECT * FROM " + table + " LIMIT 0"); err != nil { + t.Fatal(err) + } + } + }) + } +} + +// WP-6d Schema: blank files write 6; future and incomplete current files refuse +// without repair, including wake and source tables inherited from schemas 4/5. +func TestWorkstreamSchemaSixFreshFutureAndDamaged(t *testing.T) { + for _, damage := range []string{"future", "workstream_suggestions", "workstream_suggestion_edits", "workstream_wakes", "workstream_wake_items", "workstream_sources"} { + t.Run(damage, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "collections.db") + s := openTestStore(t, path) + createTestCollection(t, s, "Fresh") + if userVersion(t, s) != 6 { + t.Fatal("fresh schema is not 6") + } + query := "DROP TABLE " + damage + switch damage { + case "future": + query = "PRAGMA user_version=7" + case "workstream_suggestions": + query = "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions" + case "workstream_wakes": + query = "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes" + } + if _, err := s.db.Exec(query); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + opened, err := Open(path) + if opened != nil { + _ = opened.Close() + } + if err == nil || damage == "future" && !strings.Contains(err.Error(), "unsupported collections database") || damage != "future" && !strings.Contains(err.Error(), damage) { + t.Fatal("accepted unsupported file or returned the wrong refusal", err) + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) { + t.Fatal("refusal changed file", err) + } + }) + } +} + +// WP-6d Schema: accepting schemas 4/5 must still verify their wake/source tables. +func TestWorkstreamSuggestionPredecessorsStillRequireTheirTables(t *testing.T) { + for _, version := range []int{4, 5} { + tables := []string{"workstream_wakes", "workstream_wake_items"} + if version >= WorkstreamSourcesSchemaVersion { + tables = append(tables, "workstream_sources") + } + for _, table := range tables { + t.Run(fmt.Sprintf("%d/%s", version, table), func(t *testing.T) { + path := writeWorkstreamSuggestionPredecessor(t, version) + s := openTestStore(t, path) + query := "DROP TABLE " + table + if table == "workstream_wakes" { + query = "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes" + } + if _, err := s.db.Exec(query); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + opened, err := Open(path) + if opened != nil { + _ = opened.Close() + } + if err == nil || !strings.Contains(err.Error(), table) { + t.Fatalf("schema %d accepted missing %s: %v", version, table, err) + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) { + t.Fatal("refusal changed predecessor file", err) + } + }) + } + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index bcc6d6986b..5d2532d03c 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -23,7 +23,7 @@ import ( // schemaVersion is the schema this build writes. Earlier schemas remain // readable without migration; the first successful write upgrades them. const ( - schemaVersion = WorkstreamSourcesSchemaVersion + schemaVersion = WorkstreamSuggestionSchemaVersion legacySchemaVersion = 1 ) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. @@ -43,6 +43,7 @@ const ( schemaTwo schemaThree schemaFour + schemaFive schemaRefused ) @@ -57,6 +58,8 @@ func (s schemaState) version() int { return 3 case schemaFour: return WorkstreamWakeSchemaVersion + case schemaFive: + return WorkstreamSourcesSchemaVersion case schemaLegacy: return legacySchemaVersion } @@ -72,8 +75,8 @@ func (s schemaState) version() int { // lock to do it — which is what put a plain read behind whichever peer happened // to be adding a membership. `version` is that question already answered (0 // while the file is blank), and `schemaMu` keeps two goroutines on one handle -// from both answering it. The same law covers migration: a schema-1 or schema-2 file is -// read as it stands and rebuilt only by the first write. +// from both answering it. The same law covers migration: older supported files +// are read as they stand and upgraded only by the first write. type Store struct { db *sql.DB schemaMu sync.Mutex @@ -167,7 +170,7 @@ func (s *Store) snapshotSchema(ctx context.Context) (schemaState, error) { } // inspectSchema decides whether the database on the other end of q is ours and -// built (at this schema or the legacy one), ours and still blank, or somebody +// built at a supported schema, ours and still blank, or somebody // else's. It only ever reads; the caller decides what view it reads through. func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := ctx.Err(); err != nil { @@ -180,7 +183,7 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := q.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return schemaBlank, err } - if app == applicationID && (version == schemaVersion || version == WorkstreamWakeSchemaVersion || version == 3 || version == 2 || version == legacySchemaVersion) { + if app == applicationID && (version == schemaVersion || version == WorkstreamSourcesSchemaVersion || version == WorkstreamWakeSchemaVersion || version == 3 || version == 2 || version == legacySchemaVersion) { state := schemaReady collections := "SELECT seq,id,name,created_at FROM collections LIMIT 0" memberships := "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at FROM memberships LIMIT 0" @@ -193,6 +196,9 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if version == WorkstreamWakeSchemaVersion { state = schemaFour } + if version == WorkstreamSourcesSchemaVersion { + state = schemaFive + } if version >= 3 { memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships LIMIT 0" } @@ -229,6 +235,13 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { return state, err } } + if version >= WorkstreamSuggestionSchemaVersion { + for _, query := range workstreamSuggestionSchemaReads { + if _, err := q.ExecContext(ctx, query); err != nil { + return state, err + } + } + } return state, nil } if app != 0 || version != 0 { @@ -335,7 +348,7 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { state, err := inspectSchema(ctx, tx) if err == nil { switch state { - case schemaReady: + case schemaReady, schemaFive: _, err = tx.ExecContext(ctx, workstreamCollectionKeep) case schemaFour: _, err = tx.ExecContext(ctx, workstreamCollectionKeep) @@ -369,6 +382,9 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { } } } + if err == nil && state != schemaReady { + _, err = tx.ExecContext(ctx, workstreamSuggestionSchema) + } if err == nil && state != schemaReady { _, err = tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)) } @@ -385,7 +401,7 @@ func (s *Store) ensureSchema(ctx context.Context) error { } // readyForRead answers the schema a read goes through, and 0 for a blank -// file. A handle that saw a blank or a schema-1 file looks again, because +// file. A handle that saw a blank or an older schema looks again, because // another process may have initialized or migrated it since. The look is // still only plain reads. func (s *Store) readyForRead(ctx context.Context) (int, error) { diff --git a/internal/workspace/workstream_sources_test.go b/internal/workspace/workstream_sources_test.go index 265fbd799b..9ec77d623f 100644 --- a/internal/workspace/workstream_sources_test.go +++ b/internal/workspace/workstream_sources_test.go @@ -13,7 +13,7 @@ import ( func workstreamSchemaFourFixture(t *testing.T, s *Store) { t.Helper() - if _, err := s.db.Exec("DROP TABLE workstream_sources; PRAGMA user_version=4"); err != nil { + if _, err := s.db.Exec("DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions; DROP TABLE workstream_sources; PRAGMA user_version=4"); err != nil { t.Fatal(err) } s.version = 4 @@ -103,7 +103,7 @@ INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,u }); err != nil { t.Fatal(err) } - if userVersion(t, s) != 5 { + if userVersion(t, s) != 6 { t.Fatal("first write did not migrate") } for _, table := range tables { @@ -115,8 +115,8 @@ INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,u t.Fatal(err) } opened := openTestStore(t, path) - if userVersion(t, opened) != 5 { - t.Fatal("schema five did not reopen") + if userVersion(t, opened) != 6 { + t.Fatal("current schema did not reopen") } } @@ -128,7 +128,7 @@ func TestWorkstreamSourcesSchemaFiveRequiresItsTable(t *testing.T) { createTestCollection(t, s, "Sources") query := "DROP TABLE workstream_sources" if future { - query = "PRAGMA user_version=6" + query = "PRAGMA user_version=7" } if _, err := s.db.Exec(query); err != nil { t.Fatal(err) diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go index 9aec4f95d4..63b411a9b2 100644 --- a/internal/workspace/workstreams.go +++ b/internal/workspace/workstreams.go @@ -270,3 +270,29 @@ func migrateWorkstreamWakes(ctx context.Context, tx *sql.Tx) error { _, err := tx.ExecContext(ctx, workstreamWakeSchema+workstreamCollectionKeep) return err } + +// WorkstreamSuggestionSchemaVersion keeps suggestion reads tied to this step. +const WorkstreamSuggestionSchemaVersion = 6 + +// Suggestions are additive to schema 5 and are installed by the first write. +const workstreamSuggestionSchema = ` +CREATE TABLE workstream_suggestions ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + base_version INTEGER NOT NULL, why TEXT NOT NULL, actor TEXT NOT NULL, source TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX workstream_suggestion_page ON workstream_suggestions(page_id,created_at); +CREATE TABLE workstream_suggestion_edits ( + suggestion_id TEXT NOT NULL REFERENCES workstream_suggestions(id) ON DELETE CASCADE, + id TEXT NOT NULL, position INTEGER NOT NULL, block TEXT NOT NULL, at_offset INTEGER NOT NULL, + del TEXT NOT NULL, ins TEXT NOT NULL, block_text TEXT NOT NULL, why TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped')), decided_at INTEGER, + PRIMARY KEY(suggestion_id,id) +); +` + +var workstreamSuggestionSchemaReads = []string{ + "SELECT id,workstream_id,page_id,base_version,why,actor,source,created_at FROM workstream_suggestions LIMIT 0", + "SELECT suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state,decided_at FROM workstream_suggestion_edits LIMIT 0", +} diff --git a/internal/workspace/workstreams_test.go b/internal/workspace/workstreams_test.go index 6d62f879df..128d73a254 100644 --- a/internal/workspace/workstreams_test.go +++ b/internal/workspace/workstreams_test.go @@ -90,7 +90,7 @@ func TestPartAWorkstreamMembershipOwnershipIsEnforcedInSQLite(t *testing.T) { // Part A (a), WP-12 (i): schema 1 upgrades directly to the current schema, // preserving unknown timestamps and checking a job reference's factory. -func TestPartASchemaOneMigratesStraightToFive(t *testing.T) { +func TestPartASchemaOneMigratesStraightToCurrent(t *testing.T) { s := openTestStore(t, writeLegacyStore(t)) ctx := context.Background() if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "11", Factory: "f"}); err != nil { @@ -100,7 +100,7 @@ func TestPartASchemaOneMigratesStraightToFive(t *testing.T) { t.Fatal("did not migrate directly to the current schema") } var count int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 10 { + if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 12 { t.Fatalf("workstream tables: %d %v", count, err) } var created, added int64 diff --git a/internal/workstream/pagedoc/testdata/suggestion-keep.json b/internal/workstream/pagedoc/testdata/suggestion-keep.json new file mode 100644 index 0000000000..08a391447f --- /dev/null +++ b/internal/workstream/pagedoc/testdata/suggestion-keep.json @@ -0,0 +1,306 @@ +{ + "_about": "WP-6d shared fixture: identical bytes in the engine and the desk. doc_before + every edit kept in document order = doc_after; keeping only e3 = doc_after_e3. Offsets count Unicode code points. markdown_* are the exact renderer outputs.", + "doc_before": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Signing" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Every request carries " + }, + { + "type": "text", + "text": "X-Signature", + "marks": [ + { + "type": "code" + } + ] + }, + { + "type": "text", + "text": ", an HMAC 🔏 of the café body. Retries reuse the first signature." + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Partners verify within 5 minutes of the " + }, + { + "type": "text", + "text": "timestamp", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com/ts" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] + } + ] + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": "go" + }, + "content": [ + { + "type": "text", + "text": "sign(body)\nsend()" + } + ] + } + ] + }, + "edits": [ + { + "id": "e1", + "find": "Signing", + "replace": "Signing and retries", + "why": "Covers retries too", + "block": [ + 0 + ], + "offset": 7, + "delete": "", + "insert": " and retries", + "block_text": "Signing" + }, + { + "id": "e2", + "find": "Retries reuse the first signature.", + "replace": "Retries are signed again at send time.", + "why": "Retries are signed at send time since api#1107", + "block": [ + 1 + ], + "offset": 71, + "delete": "reuse the first signature.", + "insert": "are signed again at send time.", + "block_text": "Every request carries X-Signature, an HMAC 🔏 of the café body. Retries reuse the first signature." + }, + { + "id": "e3", + "find": "within 5 minutes", + "replace": "within 10 minutes", + "why": "The backoff cap is now 10 minutes", + "block": [ + 2, + 0, + 0 + ], + "offset": 23, + "delete": "5", + "insert": "10", + "block_text": "Partners verify within 5 minutes of the timestamp." + } + ], + "doc_after": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Signing and retries" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Every request carries " + }, + { + "type": "text", + "text": "X-Signature", + "marks": [ + { + "type": "code" + } + ] + }, + { + "type": "text", + "text": ", an HMAC 🔏 of the café body. Retries are signed again at send time." + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Partners verify within 10 minutes of the " + }, + { + "type": "text", + "text": "timestamp", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com/ts" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] + } + ] + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": "go" + }, + "content": [ + { + "type": "text", + "text": "sign(body)\nsend()" + } + ] + } + ] + }, + "doc_after_e3": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Signing" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Every request carries " + }, + { + "type": "text", + "text": "X-Signature", + "marks": [ + { + "type": "code" + } + ] + }, + { + "type": "text", + "text": ", an HMAC 🔏 of the café body. Retries reuse the first signature." + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Partners verify within 10 minutes of the " + }, + { + "type": "text", + "text": "timestamp", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com/ts" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] + } + ] + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": "go" + }, + "content": [ + { + "type": "text", + "text": "sign(body)\nsend()" + } + ] + } + ] + }, + "markdown_before": "## Signing\n\nEvery request carries `X-Signature`, an HMAC 🔏 of the café body. Retries reuse the first signature.\n\n- Partners verify within 5 minutes of the [timestamp](https://example.com/ts).\n\n```go\nsign(body)\nsend()\n```\n", + "markdown_after": "## Signing and retries\n\nEvery request carries `X-Signature`, an HMAC 🔏 of the café body. Retries are signed again at send time.\n\n- Partners verify within 10 minutes of the [timestamp](https://example.com/ts).\n\n```go\nsign(body)\nsend()\n```\n" +} diff --git a/internal/workstream/pagedoc/textblocks.go b/internal/workstream/pagedoc/textblocks.go new file mode 100644 index 0000000000..20034a62cb --- /dev/null +++ b/internal/workstream/pagedoc/textblocks.go @@ -0,0 +1,275 @@ +package pagedoc + +import ( + "bytes" + "encoding/json" + "errors" + "maps" + "reflect" + "slices" + "strings" + "unicode" + "unicode/utf8" +) + +// TextBlock retains the editor node so a keep changes only its text content. +type TextBlock struct { + Path []int + Kind string + Text string + Node map[string]any +} + +// Decode preserves exact JSON numbers, including unknown editor attributes. +func Decode(raw json.RawMessage) (map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var root map[string]any + err := decoder.Decode(&root) + if err == nil && root == nil { + err = errors.New("pagedoc: expected a document object") + } + return root, err +} + +func TextBlocks(doc map[string]any) []TextBlock { + blocks := make([]TextBlock, 0) + var visit func(map[string]any, []int) + visit = func(node map[string]any, path []int) { + kind, _ := node["type"].(string) + if kind == "paragraph" || kind == "heading" || kind == "codeBlock" { + blocks = append(blocks, TextBlock{Path: slices.Clone(path), Kind: kind, Text: nodeText(node), Node: node}) + } + content, _ := node["content"].([]any) + for i, child := range content { + visit(object(child), append(slices.Clone(path), i)) + } + } + visit(doc, []int{}) + return blocks +} + +func nodeText(node map[string]any) string { + if node["type"] == "text" { + text, _ := node["text"].(string) + return text + } + var text strings.Builder + for _, child := range children(node) { + text.WriteString(nodeText(child)) + } + return text.String() +} + +// Locate tries the stored path before the unique whole-block text fallback. +func Locate(doc map[string]any, path []int, text string) (TextBlock, bool) { + blocks := TextBlocks(doc) + for _, block := range blocks { + if slices.Equal(block.Path, path) && block.Text == text { + return block, true + } + } + var match TextBlock + count := 0 + for _, block := range blocks { + if block.Text == text { + match = block + count++ + } + } + return match, count == 1 +} + +// TrimEdit trims only at whitespace word boundaries in both strings. +func TrimEdit(find, replace string) (offset int, del, ins string) { + a, b := []rune(find), []rune(replace) + p := 0 + for p < len(a) && p < len(b) && a[p] == b[p] { + p++ + } + for !wordBoundary(a, p) || !wordBoundary(b, p) { + p-- + } + s := 0 + for s < len(a)-p && s < len(b)-p && a[len(a)-1-s] == b[len(b)-1-s] { + s++ + } + for !wordBoundary(a, len(a)-s) || !wordBoundary(b, len(b)-s) { + s-- + } + return p, string(a[p : len(a)-s]), string(b[p : len(b)-s]) +} + +func wordBoundary(s []rune, p int) bool { + return p == 0 || p == len(s) || unicode.IsSpace(s[p-1]) || unicode.IsSpace(s[p]) +} + +// Apply rewrites a located block. Offsets and deletion lengths are code points. +func Apply(block TextBlock, offset int, del, ins string) error { + text, removed := []rune(block.Text), []rune(del) + if offset < 0 || offset+len(removed) > len(text) || string(text[offset:offset+len(removed)]) != del { + return errors.New("pagedoc: edit text does not match") + } + if block.Kind != "codeBlock" && strings.ContainsAny(ins, "\r\n") { + return errors.New("pagedoc: line break outside code block") + } + var inherited []any + if len(removed) > 0 { + at := offset + inherited, _ = characterMarks(block.Node, &at) + } else if offset > 0 && offset < len(text) { + leftAt, rightAt := offset-1, offset + left, _ := characterMarks(block.Node, &leftAt) + right, _ := characterMarks(block.Node, &rightAt) + inherited = commonMarks(left, right) + } + edit := textRewrite{start: offset, end: offset + len(removed), insert: ins, marks: inherited} + edit.content(block.Node) + if !edit.inserted && ins != "" { + content, _ := block.Node["content"].([]any) + block.Node["content"] = mergeText(append(content, edit.insertion())) + } + return nil +} + +// characterMarks stops at the requested character without allocating per rune. +func characterMarks(node map[string]any, at *int) ([]any, bool) { + if node["type"] == "text" { + text, _ := node["text"].(string) + length := utf8.RuneCountInString(text) + if *at < length { + marks, _ := node["marks"].([]any) + return marks, true + } + *at -= length + return nil, false + } + for _, child := range children(node) { + if marks, found := characterMarks(child, at); found { + return marks, true + } + } + return nil, false +} + +func commonMarks(a, b []any) []any { + var out []any + for _, mark := range a { + if slices.ContainsFunc(b, func(other any) bool { return reflect.DeepEqual(mark, other) }) { + out = append(out, mark) + } + } + return out +} + +type textRewrite struct { + start, end, position int + insert string + marks []any + inserted bool +} + +func (e *textRewrite) insertion() map[string]any { + node := map[string]any{"type": "text", "text": e.insert} + if len(e.marks) > 0 { + node["marks"] = e.marks + } + return node +} + +func (e *textRewrite) content(node map[string]any) { + content, exists := node["content"].([]any) + if !exists { + return + } + out := make([]any, 0, len(content)) + for _, raw := range content { + child := object(raw) + if child["type"] == "text" { + out = append(out, e.leaf(child)...) + } else { + e.content(child) + out = append(out, raw) + } + } + node["content"] = mergeText(out) +} + +func (e *textRewrite) leaf(node map[string]any) []any { + text, _ := node["text"].(string) + runes := []rune(text) + start, end := e.position, e.position+len(runes) + e.position = end + if end < e.start || start > e.end { + return []any{node} + } + out := []any{} + appendText := func(text string) { + if text != "" { + part := maps.Clone(node) + part["text"] = text + out = append(out, part) + } + } + left := min(len(runes), max(0, e.start-start)) + appendText(string(runes[:left])) + if !e.inserted && e.start >= start && e.start <= end { + e.inserted = true + if e.insert != "" { + out = append(out, e.insertion()) + } + } + right := min(len(runes), max(0, e.end-start)) + appendText(string(runes[right:])) + return out +} + +func mergeText(content []any) []any { + out := make([]any, 0, len(content)) + for _, raw := range content { + node := object(raw) + if node["type"] != "text" { + out = append(out, raw) + continue + } + text, _ := node["text"].(string) + if text == "" { + continue + } + if len(out) > 0 { + previous := object(out[len(out)-1]) + if previous["type"] == "text" && sameTextFields(previous, node) { + previous["text"] = previous["text"].(string) + text + continue + } + } + out = append(out, raw) + } + return out +} + +func sameTextFields(a, b map[string]any) bool { + aMarks, _ := a["marks"].([]any) + bMarks, _ := b["marks"].([]any) + if !slices.EqualFunc(aMarks, bMarks, reflect.DeepEqual) { + return false + } + for key, value := range a { + if key == "text" || key == "marks" { + continue + } + other, exists := b[key] + if !exists || !reflect.DeepEqual(value, other) { + return false + } + } + for key := range b { + if key == "text" || key == "marks" { + continue + } + if _, exists := a[key]; !exists { + return false + } + } + return true +} diff --git a/internal/workstream/pagedoc/textblocks_test.go b/internal/workstream/pagedoc/textblocks_test.go new file mode 100644 index 0000000000..c7350cb435 --- /dev/null +++ b/internal/workstream/pagedoc/textblocks_test.go @@ -0,0 +1,235 @@ +package pagedoc + +import ( + "encoding/json" + "os" + "reflect" + "slices" + "testing" +) + +// WP-6d (g): the shared fixture fixes trimming, code-point offsets, JSON and Markdown. +func TestSuggestionKeepSharedFixture(t *testing.T) { + raw, err := os.ReadFile("testdata/suggestion-keep.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Before json.RawMessage `json:"doc_before"` + After json.RawMessage `json:"doc_after"` + OnlyE3 json.RawMessage `json:"doc_after_e3"` + MarkdownBefore string `json:"markdown_before"` + MarkdownAfter string `json:"markdown_after"` + Edits []struct { + ID, Find, Replace, Delete, Insert string + Block []int + Offset int + BlockText string `json:"block_text"` + } `json:"edits"` + } + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + root, err := Decode(fixture.Before) + if err != nil { + t.Fatal(err) + } + for _, edit := range fixture.Edits { + block, ok := Locate(root, edit.Block, edit.BlockText) + if !ok || !slices.Equal(block.Path, edit.Block) { + t.Fatalf("%s block: %+v", edit.ID, block) + } + prefix, del, ins := TrimEdit(edit.Find, edit.Replace) + // Locate the quoted text in code points, independently of byte indexes. + text, find := []rune(block.Text), []rune(edit.Find) + index := -1 + for i := 0; i+len(find) <= len(text); i++ { + if slices.Equal(text[i:i+len(find)], find) { + index = i + break + } + } + if index < 0 || index+prefix != edit.Offset || del != edit.Delete || ins != edit.Insert { + t.Fatalf("%s trim: offset=%d delete=%q insert=%q", edit.ID, index+prefix, del, ins) + } + if err := Apply(block, edit.Offset, del, ins); err != nil { + t.Fatal(err) + } + } + want, err := Decode(fixture.After) + if err != nil || !reflect.DeepEqual(root, want) { + t.Fatalf("all edits: %+v; %v", root, err) + } + root, err = Decode(fixture.Before) + if err != nil { + t.Fatal(err) + } + e3 := fixture.Edits[2] + block, ok := Locate(root, e3.Block, e3.BlockText) + if !ok { + t.Fatal("e3 missing") + } + if err := Apply(block, e3.Offset, e3.Delete, e3.Insert); err != nil { + t.Fatal(err) + } + want, err = Decode(fixture.OnlyE3) + if err != nil || !reflect.DeepEqual(root, want) { + t.Fatal("e3 changed other text", err) + } + for _, test := range []struct { + doc json.RawMessage + markdown string + }{{fixture.Before, fixture.MarkdownBefore}, {fixture.After, fixture.MarkdownAfter}} { + got, err := Markdown(test.doc) + if err != nil || got != test.markdown { + t.Fatalf("Markdown: %q; %v", got, err) + } + } +} + +// WP-6d (g), Tool: trim stops at word boundaries, including pure insertion. +func TestSuggestionWordBoundaryTrim(t *testing.T) { + for _, test := range []struct { + find, replace string + at int + del, ins string + }{ + {"within 5 minutes", "within 10 minutes", 7, "5", "10"}, + {"Retries reuse the first signature.", "Retries are signed again at send time.", 8, "reuse the first signature.", "are signed again at send time."}, + {"Signing", "Signing and retries", 7, "", " and retries"}, + {"foobar", "foobaz", 0, "foobar", "foobaz"}, + {"α β γ", "α δ γ", 2, "β", "δ"}, + } { + at, del, ins := TrimEdit(test.find, test.replace) + if at != test.at || del != test.del || ins != test.ins { + t.Errorf("%q: %d %q %q", test.find, at, del, ins) + } + } +} + +// WP-6d (g): replacement inherits first-deleted marks, insertion intersects +// both sides, ends inherit none, empty nodes vanish and exact numbers survive. +func TestSuggestionKeepMarksAndUnknownAttributes(t *testing.T) { + for _, test := range []struct { + name string + at int + del, ins, content string + }{ + {"replacement", 1, "bc", "X", `[{"type":"text","text":"aX","marks":[{"type":"code"}]},{"type":"text","text":"d","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, + {"intersection", 2, "", "X", `[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"X"},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, + {"end", 4, "", "X", `[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"X"}]`}, + {"start", 0, "", "X", `[{"type":"text","text":"X"},{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, + {"deletion", 0, "abcd", "", `[]`}, + {"same marks", 1, "", "X", `[{"type":"text","text":"aXb","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, + } { + t.Run(test.name, func(t *testing.T) { + doc, err := Decode(json.RawMessage(`{"type":"doc","unknown":9007199254740993,"content":[{"type":"paragraph","attrs":{"keep":9007199254740993},"content":[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]},{"type":"liveBlock","attrs":{"kind":"jobs"}}]}`)) + if err != nil { + t.Fatal(err) + } + if err := Apply(TextBlocks(doc)[0], test.at, test.del, test.ins); err != nil { + t.Fatal(err) + } + want, err := Decode(json.RawMessage(`{"type":"doc","unknown":9007199254740993,"content":[{"type":"paragraph","attrs":{"keep":9007199254740993},"content":` + test.content + `},{"type":"liveBlock","attrs":{"kind":"jobs"}}]}`)) + if err != nil || !reflect.DeepEqual(doc, want) { + t.Fatalf("rewrote attributes or marks: %+v; %v", doc, err) + } + }) + } +} + +// WP-6d (g): keeping an edit preserves every field on untouched text nodes. +func TestSuggestionKeepPreservesTextNodeFields(t *testing.T) { + for _, test := range []struct { + name, before, after string + }{ + { + "right-only attrs", + `{"type":"text","text":" suffix"},{"type":"text","text":" tail","attrs":{"revision":9007199254740993}}`, + `{"type":"text","text":" suffix"},{"type":"text","text":" tail","attrs":{"revision":9007199254740993}}`, + }, + { + "left-only field", + `{"type":"text","text":" suffix","revision":9007199254740993},{"type":"text","text":" tail"}`, + `{"type":"text","text":" suffix","revision":9007199254740993},{"type":"text","text":" tail"}`, + }, + { + "different nested attrs", + `{"type":"text","text":" suffix","attrs":{"meta":{"revision":9007199254740992}}},{"type":"text","text":" tail","attrs":{"meta":{"revision":9007199254740993}}}`, + `{"type":"text","text":" suffix","attrs":{"meta":{"revision":9007199254740992}}},{"type":"text","text":" tail","attrs":{"meta":{"revision":9007199254740993}}}`, + }, + { + "equal nested fields", + `{"type":"text","text":" suffix","attrs":{"revision":9007199254740993},"extra":[{"value":1}]},{"type":"text","text":" tail","extra":[{"value":1}],"attrs":{"revision":9007199254740993}}`, + `{"type":"text","text":" suffix tail","attrs":{"revision":9007199254740993},"extra":[{"value":1}]}`, + }, + } { + t.Run(test.name, func(t *testing.T) { + doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Old","marks":[{"type":"code"}]},` + test.before + `]}]}`)) + if err != nil { + t.Fatal(err) + } + if err := Apply(TextBlocks(doc)[0], 0, "Old", "New"); err != nil { + t.Fatal(err) + } + want, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"New","marks":[{"type":"code"}]},` + test.after + `]}]}`)) + if err != nil || !reflect.DeepEqual(doc, want) { + t.Fatalf("rewrote untouched text fields: %+v; %v", doc, err) + } + if test.name == "right-only attrs" { + tail := children(TextBlocks(doc)[0].Node)[2] + if revision, ok := object(tail["attrs"])["revision"].(json.Number); !ok || revision.String() != "9007199254740993" { + t.Fatalf("revision lost precision: %#v", tail["attrs"]) + } + } + }) + } +} + +// WP-6d (g): absent and empty marks both mean no marks for keeps and insertions. +func TestSuggestionKeepTreatsEmptyAndAbsentMarksEqually(t *testing.T) { + for _, test := range []struct { + name, before, after string + at int + del, ins string + }{ + {"replacement", `[{"type":"text","text":"one","marks":[]},{"type":"text","text":" two"}]`, `[{"type":"text","text":"one three","marks":[]}]`, 4, "two", "three"}, + {"insertion", `[{"type":"text","text":"one","marks":[]},{"type":"text","text":" two"}]`, `[{"type":"text","text":"one more two","marks":[]}]`, 3, "", " more"}, + {"reverse insertion", `[{"type":"text","text":"one"},{"type":"text","text":" two","marks":[]}]`, `[{"type":"text","text":"one more two"}]`, 3, "", " more"}, + } { + t.Run(test.name, func(t *testing.T) { + doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":` + test.before + `}]}`)) + if err != nil { + t.Fatal(err) + } + if err := Apply(TextBlocks(doc)[0], test.at, test.del, test.ins); err != nil { + t.Fatal(err) + } + want, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":` + test.after + `}]}`)) + if err != nil || !reflect.DeepEqual(doc, want) { + t.Fatalf("no-mark text did not merge: %+v; %v", doc, err) + } + }) + } +} + +// WP-6d (d): path wins over duplicate text; fallback needs exactly one block, +// and nested list paragraphs retain their full content-array path. +func TestSuggestionLocatePathAndUniqueFallback(t *testing.T) { + doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Same"}]},{"type":"paragraph","content":[{"type":"text","text":"Same"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Unique"}]}]}]}]}`)) + if err != nil { + t.Fatal(err) + } + if block, ok := Locate(doc, []int{1}, "Same"); !ok || !slices.Equal(block.Path, []int{1}) { + t.Fatalf("stored path: %+v %v", block, ok) + } + if _, ok := Locate(doc, []int{9}, "Same"); ok { + t.Fatal("ambiguous fallback applied") + } + if block, ok := Locate(doc, []int{9}, "Unique"); !ok || !slices.Equal(block.Path, []int{2, 0, 0}) { + t.Fatalf("nested fallback: %+v %v", block, ok) + } + if _, ok := Locate(doc, []int{0}, "Changed"); ok { + t.Fatal("stale text applied") + } +} diff --git a/internal/workstream/sources_migration_test.go b/internal/workstream/sources_migration_test.go index 8476de345f..a0c3581f3b 100644 --- a/internal/workstream/sources_migration_test.go +++ b/internal/workstream/sources_migration_test.go @@ -121,6 +121,7 @@ func workstreamSourceMigration(t *testing.T, version int) { } query = "DROP TABLE workstream_sources; PRAGMA user_version=4" } + query = "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions; " + query _, err := tx.Exec(query) return err }); err != nil { @@ -181,7 +182,7 @@ func workstreamSourceMigration(t *testing.T, version int) { t.Fatalf("schema %d reads wrote: %v", version, err) } item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")}) - if err != nil || workstreamSourceSchemaVersion(t, collections) != 5 { + if err != nil || workstreamSourceSchemaVersion(t, collections) != 6 { t.Fatalf("migration: %+v %v", item, err) } after := workstreamSourceStoredRows(t, collections) diff --git a/internal/workstream/suggestions.go b/internal/workstream/suggestions.go new file mode 100644 index 0000000000..2652c8548a --- /dev/null +++ b/internal/workstream/suggestions.go @@ -0,0 +1,532 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +const ( + SuggestionsMax = 5_000 + SuggestionEditsMax = 50 + SuggestionTextMax = 4_000 + SuggestionWhyMax = 200 +) + +var ErrNotPersonPage = errors.New("That page is yours; change it with write_agent_page. Suggest edits only on the person's pages.") + +type Suggestion struct { + ID, WorkstreamID, PageID string + BaseVersion int + Why, By string + Source *Source + CreatedAt time.Time + Edits []SuggestionEdit +} + +type SuggestionEdit struct { + ID string + Block []int + Offset int + Delete, Insert, BlockText, Why, State string + Applies *bool + DecidedAt time.Time +} + +type SuggestionFind struct { + Find string `json:"find"` + Replace string `json:"replace"` + Why string `json:"why"` +} + +type SuggestionCreate struct { + WorkstreamID, PageID string + BaseVersion int + Why string + Edits []SuggestionFind + Attribution +} + +// ValidateSuggestion checks byte bounds independently of any provider schema. +func ValidateSuggestion(pageID string, base int, why string, edits []SuggestionFind) error { + if strings.TrimSpace(pageID) == "" || base < 1 { + return invalid("page_id and a positive base_version are required") + } + if len(why) < 1 || !lineWithin(why, SuggestionWhyMax) { + return invalid("why must be one line of 1–200 bytes") + } + if len(edits) < 1 || len(edits) > SuggestionEditsMax { + return invalid("edits must contain 1–50 edits") + } + for _, edit := range edits { + if len(edit.Find) < 1 || len(edit.Find) > SuggestionTextMax || !utf8.ValidString(edit.Find) || len(edit.Replace) > SuggestionTextMax || !utf8.ValidString(edit.Replace) { + return invalid("find must be 1–4000 bytes and replace 0–4000 bytes") + } + if len(edit.Why) < 1 || !lineWithin(edit.Why, SuggestionWhyMax) { + return invalid("edit why must be one line of 1–200 bytes") + } + } + return nil +} + +// SuggestionTextError is a normal tool refusal, with the caller's edit numbers. +type SuggestionTextError struct{ Message string } + +func (e *SuggestionTextError) Error() string { return e.Message } + +func suggestionTextError(n int, message string) error { + return &SuggestionTextError{Message: fmt.Sprintf("Edit %d: %s", n, message)} +} + +func resolveSuggestionEdit(blocks []pagedoc.TextBlock, ask SuggestionFind, n int) (SuggestionEdit, error) { + if ask.Find == ask.Replace { + return SuggestionEdit{}, suggestionTextError(n, "replace is the same as find.") + } + var match pagedoc.TextBlock + at, count := 0, 0 + for _, block := range blocks { + for from := 0; from <= len(block.Text)-len(ask.Find); { + index := strings.Index(block.Text[from:], ask.Find) + if index < 0 { + break + } + at, match = from+index, block + count++ + // Count overlapping occurrences too; an ambiguous quote is never guessed. + _, width := utf8.DecodeRuneInString(block.Text[at:]) + from = at + width + } + } + if count == 0 { + return SuggestionEdit{}, suggestionTextError(n, "that text is not on the page. Quote plain text from one paragraph, heading, list item or code block exactly as it reads, without markdown marks.") + } + if count != 1 { + return SuggestionEdit{}, suggestionTextError(n, "that text appears more than once. Quote more of it so it is unique.") + } + if match.Kind != "codeBlock" && strings.ContainsAny(ask.Replace, "\r\n") { + return SuggestionEdit{}, suggestionTextError(n, "only a code block's text may contain a line break.") + } + offset, del, ins := pagedoc.TrimEdit(ask.Find, ask.Replace) + return SuggestionEdit{Block: match.Path, Offset: utf8.RuneCountInString(match.Text[:at]) + offset, Delete: del, Insert: ins, BlockText: match.Text, Why: ask.Why, State: "proposed"}, nil +} + +func resolveSuggestion(doc json.RawMessage, asks []SuggestionFind) ([]SuggestionEdit, error) { + root, err := pagedoc.Decode(doc) + if err != nil { + return nil, err + } + blocks := pagedoc.TextBlocks(root) + edits := make([]SuggestionEdit, 0, len(asks)) + for i, ask := range asks { + edit, err := resolveSuggestionEdit(blocks, ask, i+1) + if err != nil { + return nil, err + } + for j, other := range edits { + if slices.Equal(edit.Block, other.Block) && edit.Offset <= other.Offset+utf8.RuneCountInString(other.Delete) && other.Offset <= edit.Offset+utf8.RuneCountInString(edit.Delete) { + return nil, &SuggestionTextError{Message: fmt.Sprintf("Edits %d and %d touch the same text. Merge them into one edit.", j+1, i+1)} + } + } + edits = append(edits, edit) + } + sort.SliceStable(edits, func(i, j int) bool { return suggestionEditLess(edits[i], edits[j]) }) + for i := range edits { + edits[i].ID = fmt.Sprintf("e%d", i+1) + } + return edits, nil +} + +func suggestionEditLess(a, b SuggestionEdit) bool { + if order := slices.Compare(a.Block, b.Block); order != 0 { + return order < 0 + } + return a.Offset < b.Offset +} + +func (s *Store) CreateSuggestion(ctx context.Context, p SuggestionCreate) (Suggestion, Change, error) { + var out Suggestion + if p.By != "agent" { + return out, Change{}, invalid("only the agent suggests edits") + } + if err := ValidateSuggestion(p.PageID, p.BaseVersion, p.Why, p.Edits); err != nil { + return out, Change{}, err + } + source, err := sourceJSON(p.From) + if err != nil { + return out, Change{}, err + } + err = s.workspace.Write(ctx, func(tx *sql.Tx) error { + page, err := suggestionCreatePage(ctx, tx, p) + if err != nil { + return err + } + edits, err := resolveSuggestion(page.Doc, p.Edits) + if err != nil { + return err + } + id, err := s.newID() + if err != nil { + return err + } + out = Suggestion{ID: "sg_" + id, WorkstreamID: page.WorkstreamID, PageID: page.ID, BaseVersion: p.BaseVersion, Why: p.Why, By: "agent", Source: p.From, CreatedAt: instant(s.now().UnixMilli()), Edits: edits} + if err := insertSuggestion(ctx, tx, out, source); err != nil { + return err + } + if err := touch(ctx, tx, out.WorkstreamID, out.CreatedAt.UnixMilli()); err != nil { + return err + } + return locateSuggestion(&out, page.Doc) + }) + if err != nil { + return Suggestion{}, Change{}, err + } + return out, suggestionChanged(out.WorkstreamID), nil +} + +func suggestionCreatePage(ctx context.Context, tx *sql.Tx, p SuggestionCreate) (Page, error) { + page, _, err := livePageIn(ctx, tx, p.PageID) + if err != nil { + return page, err + } + if page.WorkstreamID != p.WorkstreamID { + return page, ErrNotFound + } + if err := workstreamWriteAllowed(ctx, tx, page.WorkstreamID, "agent"); err != nil { + return page, err + } + if page.Owner != "you" { + return page, ErrNotPersonPage + } + if page.Version != p.BaseVersion { + return page, suggestionVersionConflict(page) + } + var count int + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_suggestions WHERE workstream_id=?", page.WorkstreamID).Scan(&count); err != nil { + return page, err + } + if count >= SuggestionsMax { + return page, ErrTooLarge + } + return page, nil +} + +func insertSuggestion(ctx context.Context, tx *sql.Tx, suggestion Suggestion, source any) error { + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,source,created_at) VALUES (?,?,?,?,?,?,?,?)`, suggestion.ID, suggestion.WorkstreamID, suggestion.PageID, suggestion.BaseVersion, suggestion.Why, suggestion.By, source, suggestion.CreatedAt.UnixMilli()); err != nil { + return err + } + for i, edit := range suggestion.Edits { + block, err := json.Marshal(edit.Block) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) VALUES (?,?,?,?,?,?,?,?,?,'proposed')`, suggestion.ID, edit.ID, i, string(block), edit.Offset, edit.Delete, edit.Insert, edit.BlockText, edit.Why); err != nil { + return err + } + } + return nil +} + +func suggestionChanged(id string) Change { return Change{ID: id, Parts: []ChangedPart{PartPages}} } +func suggestionVersionConflict(p Page) error { + return &VersionConflict{ID: p.ID, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} +} + +func suggestionIn(ctx context.Context, tx *sql.Tx, id string) (Suggestion, error) { + var out Suggestion + var source sql.NullString + var at int64 + err := tx.QueryRowContext(ctx, `SELECT id,workstream_id,page_id,base_version,why,actor,source,created_at FROM workstream_suggestions WHERE id=?`, id).Scan(&out.ID, &out.WorkstreamID, &out.PageID, &out.BaseVersion, &out.Why, &out.By, &source, &at) + if err != nil { + return out, found(err) + } + out.CreatedAt = instant(at) + out.Source, err = readSource(source) + if err != nil { + return out, err + } + rows, err := tx.QueryContext(ctx, `SELECT id,block,at_offset,del,ins,block_text,why,state,decided_at FROM workstream_suggestion_edits WHERE suggestion_id=? ORDER BY position`, id) + if err != nil { + return out, err + } + defer rows.Close() + out.Edits = []SuggestionEdit{} + for rows.Next() { + var edit SuggestionEdit + var block string + var decided sql.NullInt64 + if err := rows.Scan(&edit.ID, &block, &edit.Offset, &edit.Delete, &edit.Insert, &edit.BlockText, &edit.Why, &edit.State, &decided); err != nil { + return out, err + } + if err := json.Unmarshal([]byte(block), &edit.Block); err != nil { + return out, err + } + edit.DecidedAt = optionalInstant(decided) + out.Edits = append(out.Edits, edit) + } + return out, rows.Err() +} + +func locateSuggestion(suggestion *Suggestion, doc json.RawMessage) error { + root, err := pagedoc.Decode(doc) + if err != nil { + return err + } + for i := range suggestion.Edits { + edit := &suggestion.Edits[i] + edit.Applies = nil + if edit.State != "proposed" { + continue + } + block, ok := pagedoc.Locate(root, edit.Block, edit.BlockText) + edit.Applies = &ok + if ok { + edit.Block = block.Path + } + } + return nil +} + +func (s *Store) Suggestions(ctx context.Context, pageID string) ([]Suggestion, error) { + out := []Suggestion{} + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return ErrNotFound + } + page, _, err := livePageIn(ctx, tx, pageID) + if err != nil || version < workspace.WorkstreamSuggestionSchemaVersion { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT s.id FROM workstream_suggestions s WHERE s.page_id=? AND EXISTS(SELECT 1 FROM workstream_suggestion_edits e WHERE e.suggestion_id=s.id AND e.state='proposed') ORDER BY s.created_at,s.rowid`, pageID) + if err != nil { + return err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + err = rows.Err() + if closeErr := rows.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + for _, id := range ids { + suggestion, err := suggestionIn(ctx, tx, id) + if err != nil { + return err + } + if err := locateSuggestion(&suggestion, page.Doc); err != nil { + return err + } + out = append(out, suggestion) + } + return nil + }) + return out, err +} + +func suggestionTargets(suggestion Suggestion, editID string) ([]int, error) { + targets := []int{} + found := editID == "" + for i, edit := range suggestion.Edits { + if editID != "" && edit.ID != editID { + continue + } + found = true + if edit.State == "proposed" { + targets = append(targets, i) + } else if editID != "" { + return nil, ErrConflict + } + } + if !found { + return nil, ErrNotFound + } + if len(targets) == 0 { + return nil, ErrConflict + } + return targets, nil +} + +func suggestionDecisionIn(ctx context.Context, tx *sql.Tx, id string) (Suggestion, Page, error) { + suggestion, err := suggestionIn(ctx, tx, id) + if err != nil { + return suggestion, Page{}, err + } + page, _, err := livePageIn(ctx, tx, suggestion.PageID) + if err != nil { + return suggestion, page, err + } + w, err := getIn(ctx, tx, suggestion.WorkstreamID) + if err == nil && !w.DoneAt.IsZero() { + err = ErrConflict + } + return suggestion, page, err +} + +// keepSuggestionDoc checks every selected anchor before applying any edit. +func keepSuggestionDoc(suggestion *Suggestion, page Page, targets []int, at time.Time) (json.RawMessage, error) { + if err := locateSuggestion(suggestion, page.Doc); err != nil { + return nil, err + } + for _, i := range targets { + if !*suggestion.Edits[i].Applies { + return nil, suggestionVersionConflict(page) + } + } + sort.SliceStable(targets, func(i, j int) bool { + return suggestionEditLess(suggestion.Edits[targets[i]], suggestion.Edits[targets[j]]) + }) + root, err := pagedoc.Decode(page.Doc) + if err != nil { + return nil, err + } + for _, i := range targets { + edit := &suggestion.Edits[i] + block, ok := pagedoc.Locate(root, edit.Block, edit.BlockText) + if !ok { + return nil, suggestionVersionConflict(page) + } + if err := pagedoc.Apply(block, edit.Offset, edit.Delete, edit.Insert); err != nil { + return nil, suggestionVersionConflict(page) + } + edit.Block, edit.State, edit.DecidedAt, edit.Applies = block.Path, "kept", at, nil + rebaseSuggestion(suggestion, *edit, block.Path) + } + doc, err := json.Marshal(root) + if err != nil { + return nil, err + } + if err := checkDoc(doc); err != nil { + return nil, err + } + return doc, locateSuggestion(suggestion, doc) +} + +func rebaseSuggestion(suggestion *Suggestion, kept SuggestionEdit, path []int) { + text := []rune(kept.BlockText) + end := kept.Offset + utf8.RuneCountInString(kept.Delete) + newText := string(text[:kept.Offset]) + kept.Insert + string(text[end:]) + delta := utf8.RuneCountInString(kept.Insert) - utf8.RuneCountInString(kept.Delete) + for i := range suggestion.Edits { + edit := &suggestion.Edits[i] + if edit.State != "proposed" || !slices.Equal(edit.Block, path) || edit.BlockText != kept.BlockText { + continue + } + edit.BlockText = newText + if edit.Offset > kept.Offset { + edit.Offset += delta + } + } +} + +func saveSuggestionEdits(ctx context.Context, tx *sql.Tx, suggestion Suggestion) error { + for _, edit := range suggestion.Edits { + block, err := json.Marshal(edit.Block) + if err != nil { + return err + } + var decided any + if !edit.DecidedAt.IsZero() { + decided = edit.DecidedAt.UnixMilli() + } + if _, err := tx.ExecContext(ctx, `UPDATE workstream_suggestion_edits SET block=?,at_offset=?,block_text=?,state=?,decided_at=? WHERE suggestion_id=? AND id=?`, string(block), edit.Offset, edit.BlockText, edit.State, decided, suggestion.ID, edit.ID); err != nil { + return err + } + } + return nil +} + +func (s *Store) KeepSuggestion(ctx context.Context, id, editID string) (Page, Suggestion, Change, error) { + var page Page + var suggestion Suggestion + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + var err error + suggestion, page, err = suggestionDecisionIn(ctx, tx, id) + if err != nil { + return err + } + targets, err := suggestionTargets(suggestion, editID) + if err != nil { + return err + } + label := suggestion.Why + if editID != "" { + label = suggestion.Edits[targets[0]].Why + } + at := instant(s.now().UnixMilli()) + doc, err := keepSuggestionDoc(&suggestion, page, targets, at) + if err != nil { + return err + } + source, err := json.Marshal(Source{Kind: "suggestion", ID: id, Label: label}) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE workstream_pages SET doc=?,version=version+1,updated_at=?,updated_by='you',updated_from=? WHERE id=?`, string(doc), at.UnixMilli(), string(source), page.ID); err != nil { + return err + } + if err := saveVersion(ctx, tx, page.ID); err != nil { + return err + } + if err := saveSuggestionEdits(ctx, tx, suggestion); err != nil { + return err + } + if err := touch(ctx, tx, suggestion.WorkstreamID, at.UnixMilli()); err != nil { + return err + } + page, _, err = livePageIn(ctx, tx, page.ID) + return err + }) + if err != nil { + return Page{}, Suggestion{}, Change{}, err + } + return page, suggestion, suggestionChanged(suggestion.WorkstreamID), nil +} + +func (s *Store) DropSuggestion(ctx context.Context, id, editID string) (Suggestion, Change, error) { + var suggestion Suggestion + err := s.workspace.Write(ctx, func(tx *sql.Tx) error { + var page Page + var err error + suggestion, page, err = suggestionDecisionIn(ctx, tx, id) + if err != nil { + return err + } + targets, err := suggestionTargets(suggestion, editID) + if err != nil { + return err + } + at := instant(s.now().UnixMilli()) + for _, i := range targets { + suggestion.Edits[i].State, suggestion.Edits[i].DecidedAt = "dropped", at + } + if err := locateSuggestion(&suggestion, page.Doc); err != nil { + return err + } + if err := saveSuggestionEdits(ctx, tx, suggestion); err != nil { + return err + } + return touch(ctx, tx, suggestion.WorkstreamID, at.UnixMilli()) + }) + if err != nil { + return Suggestion{}, Change{}, err + } + return suggestion, suggestionChanged(suggestion.WorkstreamID), nil +} diff --git a/internal/workstream/suggestions_test.go b/internal/workstream/suggestions_test.go new file mode 100644 index 0000000000..f5c5ad047f --- /dev/null +++ b/internal/workstream/suggestions_test.go @@ -0,0 +1,554 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/workspace" + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +type workstreamSuggestionFixture struct { + Before json.RawMessage `json:"doc_before"` + After json.RawMessage `json:"doc_after"` + OnlyE3 json.RawMessage `json:"doc_after_e3"` + Edits []SuggestionFind `json:"edits"` +} + +func workstreamSuggestionSeed(t *testing.T, s *Store) (Page, SuggestionCreate, workstreamSuggestionFixture) { + t.Helper() + raw, err := os.ReadFile("pagedoc/testdata/suggestion-keep.json") + if err != nil { + t.Fatal(err) + } + var fixture workstreamSuggestionFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + w := createStream(t, s, "Suggestions") + page, _, err := s.CreatePage(context.Background(), PageCreate{WorkstreamID: w.ID, Title: "Signing", Doc: fixture.Before}) + if err != nil { + t.Fatal(err) + } + ask := SuggestionCreate{WorkstreamID: w.ID, PageID: page.ID, BaseVersion: page.Version, Why: "Explain signing and retries", Edits: fixture.Edits, Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: w.AgentID}}} + return page, ask, fixture +} + +func workstreamSuggestionDocEqual(t *testing.T, got, want json.RawMessage) { + t.Helper() + a, err := pagedoc.Decode(got) + if err != nil { + t.Fatal(err) + } + b, err := pagedoc.Decode(want) + if err != nil || !reflect.DeepEqual(a, b) { + t.Fatalf("document differs: %s; want %s; %v", got, want, err) + } +} + +func workstreamSuggestionCreate(t *testing.T, s *Store, ask SuggestionCreate) Suggestion { + t.Helper() + suggestion, change, err := s.CreateSuggestion(context.Background(), ask) + if err != nil || !reflect.DeepEqual(change, suggestionChanged(ask.WorkstreamID)) { + t.Fatalf("create: %+v %v", change, err) + } + return suggestion +} + +// WP-6d (b): a single keep makes exactly one attributed version; drop leaves +// every page field and history untouched and decides only its selected edit. +func TestWorkstreamSuggestionKeepOneAndDropOne(t *testing.T) { + s, _, _ := testStore(t) + page, ask, fixture := workstreamSuggestionSeed(t, s) + suggestion := workstreamSuggestionCreate(t, s, ask) + ctx := context.Background() + historyBefore, err := s.PageHistory(ctx, page.ID, 50) + if err != nil { + t.Fatal(err) + } + kept, after, change, err := s.KeepSuggestion(ctx, suggestion.ID, "e3") + if err != nil || kept.Version != page.Version+1 || kept.UpdatedBy != "you" || !reflect.DeepEqual(change, suggestionChanged(page.WorkstreamID)) { + t.Fatalf("keep: %+v %+v %v", kept, change, err) + } + workstreamSuggestionDocEqual(t, kept.Doc, fixture.OnlyE3) + history, err := s.PageHistory(ctx, page.ID, 50) + wantSource := &Source{Kind: "suggestion", ID: suggestion.ID, Label: ask.Edits[2].Why} + if err != nil || len(history) != len(historyBefore)+1 || !reflect.DeepEqual(history[1:], historyBefore) || !reflect.DeepEqual(history[0].From, wantSource) || !reflect.DeepEqual(kept.UpdatedFrom, wantSource) || history[0].SavedBy != "you" { + t.Fatalf("history: %+v %v", history, err) + } + for i, edit := range after.Edits { + if i == 2 { + if edit.State != "kept" || edit.DecidedAt.IsZero() || edit.Applies != nil { + t.Fatalf("kept edit: %+v", edit) + } + } else if edit.State != "proposed" || edit.Applies == nil || !*edit.Applies || !edit.DecidedAt.IsZero() { + t.Fatalf("proposed edit: %+v", edit) + } + } + dropped, change, err := s.DropSuggestion(ctx, suggestion.ID, "e1") + if err != nil || dropped.Edits[0].State != "dropped" || dropped.Edits[0].DecidedAt.IsZero() || dropped.Edits[0].Applies != nil || dropped.Edits[1].State != "proposed" || dropped.Edits[2].State != "kept" || !reflect.DeepEqual(change, suggestionChanged(page.WorkstreamID)) { + t.Fatalf("drop: %+v %v", dropped, err) + } + unchanged, err := s.Page(ctx, page.ID, false) + if err != nil || !reflect.DeepEqual(unchanged, kept) { + t.Fatal("drop changed page", err) + } + historyAfter, err := s.PageHistory(ctx, page.ID, 50) + if err != nil || !reflect.DeepEqual(historyAfter, history) { + t.Fatal("drop changed history", err) + } +} + +// WP-6d (c): keep all saves one version and closes the suggestion; drop all +// closes it without touching the page, history or updated_at. +func TestWorkstreamSuggestionKeepAllAndDropAll(t *testing.T) { + for _, keep := range []bool{true, false} { + t.Run(map[bool]string{true: "keep", false: "drop"}[keep], func(t *testing.T) { + s, _, _ := testStore(t) + page, ask, fixture := workstreamSuggestionSeed(t, s) + suggestion := workstreamSuggestionCreate(t, s, ask) + ctx := context.Background() + before, err := s.PageHistory(ctx, page.ID, 50) + if err != nil { + t.Fatal(err) + } + var decided Suggestion + if keep { + var saved Page + saved, decided, _, err = s.KeepSuggestion(ctx, suggestion.ID, "") + if err != nil { + t.Fatal(err) + } + workstreamSuggestionDocEqual(t, saved.Doc, fixture.After) + want := &Source{Kind: "suggestion", ID: suggestion.ID, Label: ask.Why} + if saved.Version != page.Version+1 || saved.UpdatedBy != "you" || !reflect.DeepEqual(saved.UpdatedFrom, want) { + t.Fatalf("keep all: %+v", saved) + } + } else { + decided, _, err = s.DropSuggestion(ctx, suggestion.ID, "") + if err != nil { + t.Fatal(err) + } + saved, err := s.Page(ctx, page.ID, false) + if err != nil || !reflect.DeepEqual(saved, page) { + t.Fatal("drop all changed page", err) + } + } + for _, edit := range decided.Edits { + want := "dropped" + if keep { + want = "kept" + } + if edit.State != want || edit.DecidedAt.IsZero() || edit.Applies != nil { + t.Fatalf("decision: %+v", edit) + } + } + after, err := s.PageHistory(ctx, page.ID, 50) + if err != nil { + t.Fatal(err) + } + if keep { + if len(after) != len(before)+1 || after[0].From.Label != ask.Why { + t.Fatalf("keep all history: %+v", after) + } + } else if !reflect.DeepEqual(before, after) { + t.Fatal("drop all changed history") + } + open, err := s.Suggestions(ctx, page.ID) + if err != nil || open == nil || len(open) != 0 { + t.Fatalf("closed still open: %+v %v", open, err) + } + for _, editID := range []string{"e1", ""} { + if _, _, c, err := s.KeepSuggestion(ctx, suggestion.ID, editID); !errors.Is(err, ErrConflict) || len(c.Parts) != 0 { + t.Fatalf("repeat keep: %v", err) + } + if _, c, err := s.DropSuggestion(ctx, suggestion.ID, editID); !errors.Is(err, ErrConflict) || len(c.Parts) != 0 { + t.Fatalf("repeat drop: %v", err) + } + } + }) + } +} + +// WP-6d (c): keeping either end of one block rebases the remaining anchor. +func TestWorkstreamSuggestionEditsMoveWithText(t *testing.T) { + for _, first := range []string{"e1", "e2", ""} { + t.Run("first="+first, func(t *testing.T) { + s, _, _ := testStore(t) + page, ask, _ := workstreamSuggestionSeed(t, s) + doc := pagedoc.FromMarkdown("Alpha old words and omega old words.") + page, _, err := s.SavePage(context.Background(), page.ID, page.Version, doc, Attribution{}) + if err != nil { + t.Fatal(err) + } + ask.BaseVersion = page.Version + ask.Edits = []SuggestionFind{{Find: "Alpha old", Replace: "Alpha several new", Why: "Earlier"}, {Find: "omega old", Replace: "omega fresh", Why: "Later"}} + suggestion := workstreamSuggestionCreate(t, s, ask) + saved, after, _, err := s.KeepSuggestion(context.Background(), suggestion.ID, first) + if err != nil { + t.Fatal(err) + } + if first != "" { + remaining := "e2" + if first == "e2" { + remaining = "e1" + } + for _, edit := range after.Edits { + if edit.State == "proposed" && (edit.Applies == nil || !*edit.Applies) { + t.Fatalf("lost anchor: %+v", edit) + } + } + saved, _, _, err = s.KeepSuggestion(context.Background(), suggestion.ID, remaining) + if err != nil { + t.Fatal(err) + } + } + workstreamSuggestionDocEqual(t, saved.Doc, pagedoc.FromMarkdown("Alpha several new words and omega fresh words.")) + }) + } +} + +// WP-6d (d): stale single/all keeps are atomic, carry page details, and expose +// applies false. Moving an intact paragraph reports and persists its new path. +func TestWorkstreamSuggestionStaleAndMovedBlocks(t *testing.T) { + for _, moved := range []bool{false, true} { + t.Run(map[bool]string{true: "moved", false: "stale"}[moved], func(t *testing.T) { + s, _, _ := testStore(t) + page, ask, fixture := workstreamSuggestionSeed(t, s) + suggestion := workstreamSuggestionCreate(t, s, ask) + root, err := pagedoc.Decode(fixture.Before) + if err != nil { + t.Fatal(err) + } + content := root["content"].([]any) + if moved { + root["content"] = append([]any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Inserted above"}}}}, content...) + } else { + content[1].(map[string]any)["content"] = []any{map[string]any{"type": "text", "text": "Person changed the paragraph"}} + } + doc, err := json.Marshal(root) + if err != nil { + t.Fatal(err) + } + page, _, err = s.SavePage(context.Background(), page.ID, page.Version, doc, Attribution{}) + if err != nil { + t.Fatal(err) + } + open, err := s.Suggestions(context.Background(), page.ID) + if err != nil || len(open) != 1 { + t.Fatal("list", err) + } + if moved { + if !*open[0].Edits[0].Applies || !reflect.DeepEqual(open[0].Edits[0].Block, []int{1}) { + t.Fatalf("path did not move: %+v", open) + } + _, after, _, err := s.KeepSuggestion(context.Background(), suggestion.ID, "e1") + if err != nil || !reflect.DeepEqual(after.Edits[0].Block, []int{1}) || !reflect.DeepEqual(after.Edits[1].Block, []int{2}) { + t.Fatalf("located paths not saved: %+v %v", after, err) + } + return + } + if !*open[0].Edits[0].Applies || *open[0].Edits[1].Applies { + t.Fatalf("applies: %+v", open) + } + history, err := s.PageHistory(context.Background(), page.ID, 50) + if err != nil { + t.Fatal(err) + } + for _, target := range []string{"e2", ""} { + _, _, change, err := s.KeepSuggestion(context.Background(), suggestion.ID, target) + var stale *VersionConflict + if !errors.As(err, &stale) || stale.ID != page.ID || stale.Version != page.Version || !stale.UpdatedAt.Equal(page.UpdatedAt) || stale.UpdatedBy != page.UpdatedBy || len(change.Parts) != 0 { + t.Fatalf("stale: %+v %v", stale, err) + } + after, _ := s.Page(context.Background(), page.ID, false) + afterHistory, _ := s.PageHistory(context.Background(), page.ID, 50) + afterOpen, _ := s.Suggestions(context.Background(), page.ID) + if !reflect.DeepEqual(after, page) || !reflect.DeepEqual(afterHistory, history) || !reflect.DeepEqual(afterOpen, open) { + t.Fatal("refused keep changed state") + } + } + }) + } +} + +// WP-6d (a), (h), Tool: ownership, actor, version, done, missing targets, +// deletion and the all-states suggestion cap refuse without a receipt. +func TestWorkstreamSuggestionRefusals(t *testing.T) { + s, workspace, _ := testStore(t) + page, ask, _ := workstreamSuggestionSeed(t, s) + ctx := context.Background() + owner := "agent" + if _, _, err := s.UpdatePage(ctx, page.ID, PagePatch{Owner: &owner}); err != nil { + t.Fatal(err) + } + if _, c, err := s.CreateSuggestion(ctx, ask); !errors.Is(err, ErrNotPersonPage) || len(c.Parts) != 0 { + t.Fatalf("own page: %v", err) + } + open, err := s.Suggestions(ctx, page.ID) + if err != nil || len(open) != 0 { + t.Fatal("ownership refusal stored suggestion", err) + } + owner = "you" + if _, _, err := s.UpdatePage(ctx, page.ID, PagePatch{Owner: &owner}); err != nil { + t.Fatal(err) + } + stale := ask + stale.BaseVersion++ + if _, c, err := s.CreateSuggestion(ctx, stale); err == nil || len(c.Parts) != 0 { + t.Fatal("accepted stale create") + } else { + var conflict *VersionConflict + if !errors.As(err, &conflict) || conflict.ID != page.ID || conflict.Version != page.Version { + t.Fatalf("create conflict: %v", err) + } + } + person := ask + person.By = "you" + if _, _, err := s.CreateSuggestion(ctx, person); !errors.Is(err, ErrInvalid) { + t.Fatal("person created suggestion", err) + } + suggestion := workstreamSuggestionCreate(t, s, ask) + for _, ids := range [][2]string{{"missing", "e1"}, {suggestion.ID, "missing"}} { + if _, _, _, err := s.KeepSuggestion(ctx, ids[0], ids[1]); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing keep: %v", err) + } + if _, _, err := s.DropSuggestion(ctx, ids[0], ids[1]); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing drop: %v", err) + } + } + if err := workspace.Write(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec(`WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x Date: Tue, 3 Nov 2026 17:29:34 -0500 Subject: [PATCH 17/20] Client tools: a client attaches tools to a conversation; collection-held conversations are kept (#2334) * A client can attach tools to a conversation over the app wire agents.tools_set attaches, replaces or removes one named set of tools on a conversation; agents.tool_call carries each model call to the client and agents.tool_result answers it. The hello says agents.client_tools. Co-Authored-By: Claude Opus 5.5 * Client tools ride at the tail of a conversation's belt A conversation with a client tool door offers the client's tools after every built-in, with each set's instructions on its own page and nothing added when no set is attached. Tools follow their declared category, a built-in name wins, and child tasks and discussions get neither tools nor instructions. Co-Authored-By: Claude Opus 5.5 * A conversation any collection holds is not blank-chat litter Launch cleanup, the blank-chat sweep and the background places sweep keep a never-spoken conversation that a collection references, reading the collections database read-only and keeping the folder when it cannot be read. Opening such a conversation by id after a restart restores its owned, model, archive and spend facts from its metadata. Co-Authored-By: Claude Opus 5.5 * codeaf app sends client tool calls to the connected app The app backend keeps each conversation's client tool sets for as long as the connection lasts, emits one agents.tool_call per model call and waits for the answer, its deadline, the turn's end or the connection closing. Call ids are stable for a replayed call, and a late or repeated answer is ignored. Co-Authored-By: Claude Opus 5.5 * Change entry for client tools and collection-held conversations Co-Authored-By: Claude Opus 5.5 * The client tools manual page is found by its own name Co-Authored-By: Claude Opus 5.5 * Client tool registry follows the guard and name laws The registry's sender runs under guard.Go, every registry lock is released from a defer, and a test name no longer spells the old product name. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/app.go | 10 +- cmd/codeaf/appclienttools.go | 507 +++++++++++++ cmd/codeaf/appclienttools_test.go | 719 +++++++++++++++++++ cmd/codeaf/appcollectionkeep.go | 39 + cmd/codeaf/appcollectionkeep_test.go | 272 +++++++ cmd/codeaf/apptools.go | 16 +- cmd/codeaf/chatv3_layout.go | 2 +- cmd/codeaf/chatv3_sweep.go | 4 +- cmd/codeaf/chatv3_sweep_test.go | 2 +- cmd/codeaf/engine.go | 1 + docs/changes/unreleased/2334-client-tools.md | 24 + docs/design/app-v3/PROTOCOL.md | 75 ++ internal/appwire/client_tools.go | 160 +++++ internal/appwire/client_tools_test.go | 177 +++++ internal/appwire/events.go | 1 + internal/appwire/server.go | 5 +- internal/manual/chat/client-tools.md | 33 + internal/session/agent.go | 2 + internal/session/bashbelt_worker.go | 1 + internal/session/client_tools.go | 331 +++++++++ internal/session/client_tools_test.go | 380 ++++++++++ internal/session/connect.go | 19 +- internal/session/inherit.go | 1 + internal/session/loop.go | 7 +- internal/session/prompt.go | 1 + internal/session/promptprofile_live.go | 6 + internal/session/questionconversation.go | 2 + internal/session/session.go | 20 +- internal/session/sweep.go | 23 +- internal/session/sweep_test.go | 4 +- internal/session/task_app_steps.go | 2 +- internal/session/task_quick_files.go | 3 + internal/session/team.go | 1 + internal/session/tools.go | 2 +- 34 files changed, 2810 insertions(+), 42 deletions(-) create mode 100644 cmd/codeaf/appclienttools.go create mode 100644 cmd/codeaf/appclienttools_test.go create mode 100644 cmd/codeaf/appcollectionkeep.go create mode 100644 cmd/codeaf/appcollectionkeep_test.go create mode 100644 docs/changes/unreleased/2334-client-tools.md create mode 100644 internal/appwire/client_tools.go create mode 100644 internal/appwire/client_tools_test.go create mode 100644 internal/manual/chat/client-tools.md create mode 100644 internal/session/client_tools.go create mode 100644 internal/session/client_tools_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 87efd28ad7..158db343d5 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -202,6 +202,7 @@ func runApp(args []string) error { services := openAppServices() appServiceDoor.Store(services) appWorkstreamAgents.Store(backend) + appClientToolsBackend.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 @@ -553,6 +554,7 @@ func appConnOf(client *remote.Client, release func(), assembled bool) *appConn { // appBackend is the [appwire.Engine] over this process's conversations. type appBackend struct { + clientTools appClientTools chats appChats pair appPair drives *driveBook @@ -1250,10 +1252,12 @@ func appRowOnDisk(id string) (session.SessionRow, bool) { // A folder minted and never spoken in is still a conversation the app // may have created; its meta names the workspace. meta, err := session.LoadMeta(dir) - if err != nil || strings.TrimSpace(meta.ID) == "" { + if err != nil || meta.ID != id { continue } - return session.SessionRow{ID: id, AgentDID: meta.AgentDID(), Dir: dir, Transcript: transcript, Title: meta.Title, Workspace: meta.Workspace, Created: meta.Created}, true + return session.SessionRow{ID: id, AgentDID: meta.AgentDID(), Dir: dir, Transcript: transcript, + Title: meta.Title, Workspace: meta.Workspace, Owned: meta.Owned, Model: meta.Model, + Archived: meta.Archived, Spend: meta.SpentUSD, At: meta.Created, Created: meta.Created}, true } return session.SessionRow{}, false } @@ -4476,6 +4480,8 @@ func (b *appBackend) Close() error { if b.leaving != nil { b.leaving() } + b.clientTools.disconnect() + appClientToolsBackend.CompareAndSwap(b, nil) already := false var crews *appCrewRun var agents []*appAgent diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go new file mode 100644 index 0000000000..9c52d5a2ac --- /dev/null +++ b/cmd/codeaf/appclienttools.go @@ -0,0 +1,507 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +const clientToolSettledTTL = 10 * time.Minute +const clientToolSettledMax = 1024 + +var appClientToolsBackend atomic.Pointer[appBackend] +var clientToolFreshID atomic.Uint64 + +type clientToolAgentSets struct { + version uint64 + sets []session.ClientToolSet + builtins map[string]bool +} + +type clientToolPending struct { + ctx context.Context + done chan struct{} + call session.ClientToolCall + deadline time.Time + answer session.ClientToolAnswer + event *appwire.AgentsToolCall +} + +type clientToolSettled struct { + answer session.ClientToolAnswer + at time.Time +} + +// The registry belongs to one stdio connection, including its replay memory. +type appClientTools struct { + mu sync.Mutex + agents map[string]clientToolAgentSets + pending map[string]*clientToolPending + settled map[string]clientToolSettled + order []string + closed bool + senderWake chan struct{} + senderDone chan struct{} +} + +type appClientToolDoor struct { + backend *appBackend + agent string +} + +var _ appwire.ClientTooler = (*appBackend)(nil) +var _ session.ClientToolDoor = (*appClientToolDoor)(nil) + +func (b *appBackend) clientToolEventCategory(a *appAgent, event session.Event) session.ActionCategory { + if event.Category != "" { + return event.Category + } + var id string + var conv appConversation + b.locked(func() { + id = a.view.ID + if a.live != nil { + conv = a.live.conn.Conv + } + }) + if local, ok := conv.(interface { + ActionCategoryForTool(string) session.ActionCategory + }); ok { + return local.ActionCategoryForTool(event.Tool) + } + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.agents[id].builtins[event.Tool] { + return session.ActionCategoryForTool(event.Tool) + } + for _, set := range r.agents[id].sets { + for _, tool := range set.Tools { + if tool.Name == event.Tool { + return tool.Category + } + } + } + return session.ActionCategoryForTool(event.Tool) +} + +func appClientToolsDoor(sessionFile string) session.ClientToolDoor { + b := appClientToolsBackend.Load() + if b == nil { + return nil + } + return &appClientToolDoor{backend: b, agent: appAgentID(sessionFile)} +} + +// Root builders share launch settings, but each opening needs its own conversation door. +func appClientToolsOpener(open func(session.Config) (*session.Agent, error)) func(session.Config) (*session.Agent, error) { + return func(cfg session.Config) (*session.Agent, error) { + cfg.ClientTools = appClientToolsDoor(cfg.SessionFile) + return open(cfg) + } +} + +func (b *appBackend) SetClientTools(p appwire.ClientToolsSetParams) (appwire.ClientToolsSetResult, error) { + result := appwire.ClientToolsSetResult{Agent: p.Agent, Set: p.Set, Tools: []string{}} + if err := p.Validate(); err != nil { + return result, err + } + a, err := b.find(p.Agent) + if err != nil { + return result, err + } + a.gate.Lock() + defer a.gate.Unlock() + if err := b.clientToolScope(a); err != nil { + return result, err + } + held := b.clientToolBuiltinNames(a) + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return result, appwire.Errorf(appwire.CodeClosed, "the app connection is closed") + } + state := r.agents[p.Agent] + for name := range state.builtins { + held[name] = true + } + if err := clientToolReplacementValid(p, state.sets, held); err != nil { + return result, err + } + sets := make([]session.ClientToolSet, 0, len(state.sets)+1) + for _, set := range state.sets { + if set.ID != p.Set { + sets = append(sets, set) + } + } + if len(p.Tools) > 0 { + set := clientToolSnapshot(p) + sets = append(sets, set) + for _, tool := range set.Tools { + result.Tools = append(result.Tools, tool.Name) + } + } + sort.Slice(sets, func(i, j int) bool { return sets[i].ID < sets[j].ID }) + if r.agents == nil { + r.agents = make(map[string]clientToolAgentSets) + } + r.agents[p.Agent] = clientToolAgentSets{version: state.version + 1, sets: sets, builtins: state.builtins} + return result, nil +} + +func (b *appBackend) clientToolScope(a *appAgent) error { + remoteHeld := false + b.locked(func() { + if a.live != nil { + _, remoteHeld = a.live.conn.Conv.(*remote.Agent) + remoteHeld = remoteHeld && !a.live.conn.Assembled && !a.live.conn.Lent + } + remoteHeld = remoteHeld || a.view.Location != nil && !a.view.Location.Here + }) + if remoteHeld { + return appwire.Errorf(appwire.CodeBadScope, "client tools need a conversation this engine runs") + } + return nil +} + +// Local app conversations also use a transport proxy. Their session reports its +// built-in inventory here, so validation still sees the actual engine belt. +func (d *appClientToolDoor) ClientToolBuiltins(names []string) { + r := &d.backend.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return + } + state := r.agents[d.agent] + state.builtins = make(map[string]bool, len(names)) + for _, name := range names { + state.builtins[name] = true + } + if r.agents == nil { + r.agents = make(map[string]clientToolAgentSets) + } + r.agents[d.agent] = state +} + +func (b *appBackend) clientToolBuiltinNames(a *appAgent) map[string]bool { + var conv appConversation + b.locked(func() { + if a.live != nil { + conv = a.live.conn.Conv + } + }) + held := make(map[string]bool) + if live, ok := conv.(interface{ ClientToolBuiltinNames() []string }); ok { + for _, name := range live.ClientToolBuiltinNames() { + held[name] = true + } + } + return held +} + +func clientToolReplacementValid(p appwire.ClientToolsSetParams, sets []session.ClientToolSet, builtins map[string]bool) error { + names := make(map[string]bool) + count, setCount := len(p.Tools), 0 + for _, set := range sets { + if set.ID == p.Set { + continue + } + setCount++ + count += len(set.Tools) + for _, tool := range set.Tools { + names[tool.Name] = true + } + } + if len(p.Tools) > 0 { + setCount++ + } + if setCount > session.ClientToolMaxSets || count > session.ClientToolMaxPerAgent { + return appwire.Errorf(appwire.CodeBadParams, "an agent allows at most %d sets and %d client tools", session.ClientToolMaxSets, session.ClientToolMaxPerAgent) + } + for _, tool := range p.Tools { + if builtins[tool.Name] || session.ActionCategoryForTool(tool.Name) != session.ActionWork { + return appwire.Errorf(appwire.CodeBadParams, "%s is a built-in engine tool", tool.Name) + } + if names[tool.Name] { + return appwire.Errorf(appwire.CodeBadParams, "%s is already in another set", tool.Name) + } + } + return nil +} + +func clientToolSnapshot(p appwire.ClientToolsSetParams) session.ClientToolSet { + set := session.ClientToolSet{ID: p.Set, Instructions: p.Instructions, DeadlineMS: p.DeadlineMS, + Tools: append([]session.ClientTool(nil), p.Tools...)} + if set.DeadlineMS == 0 { + set.DeadlineMS = session.ClientToolDefaultDeadlineMS + } + for i := range set.Tools { + set.Tools[i].InputSchema = bytes.Clone(set.Tools[i].InputSchema) + } + return set +} + +func (d *appClientToolDoor) ClientToolSets() (uint64, []session.ClientToolSet) { + r := &d.backend.clientTools + r.mu.Lock() + defer r.mu.Unlock() + state := r.agents[d.agent] + return state.version, state.sets +} + +func clientToolCallID(agent, provider string) string { + if provider == "" { + return fmt.Sprintf("ct_fresh_%x", clientToolFreshID.Add(1)) + } + sum := sha256.Sum256([]byte(agent + "\x00" + provider)) + return "ct_" + hex.EncodeToString(sum[:]) +} + +func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + if err := ctx.Err(); err != nil { + return clientToolTurnEnded(call.Name), nil + } + var object map[string]json.RawMessage + if json.Unmarshal(call.Args, &object) != nil || object == nil { + return session.ClientToolAnswer{Text: "Invalid arguments: expected a JSON object", IsError: true}, nil + } + id := clientToolCallID(d.agent, call.TurnCall) + r := &d.backend.clientTools + pending, answer, emit := r.begin(ctx, d.agent, id, call) + if pending == nil { + return answer, nil + } + if emit { + var turn uint64 + d.backend.locked(func() { + if a := d.backend.agents[d.agent]; a != nil && a.live != nil { + turn = a.live.turn + } + }) + r.queueEmission(d.backend.emit, pending, appwire.AgentsToolCall{Agent: d.agent, Turn: turn, + Call: id, TurnCall: call.TurnCall, Set: call.Set, Name: call.Name, Args: bytes.Clone(call.Args), DeadlineMS: pending.call.DeadlineMS}) + } + return r.wait(ctx, id, pending), nil +} + +// One sender owns all writer waits. Queued events live only in pending calls, +// so calls that end behind a blocked writer leave no stale queue to grow. +func (r *appClientTools) queueEmission(emit appwire.Emitter, pending *clientToolPending, event appwire.AgentsToolCall) { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed || r.pending[event.Call] != pending { + return + } + pending.event = &event + if r.senderWake == nil { + r.senderWake = make(chan struct{}, 1) + r.senderDone = make(chan struct{}) + guard.Go("app/client-tools", func() { r.sendEvents(emit) }) + } + select { + case r.senderWake <- struct{}{}: + default: + } +} + +func (r *appClientTools) sendEvents(emit appwire.Emitter) { + defer close(r.senderDone) + for range r.senderWake { + for { + event := r.nextEmission() + if event == nil { + break + } + emit.Emit(appwire.EventAgentsToolCall, *event) + } + } +} + +func (r *appClientTools) nextEmission() *appwire.AgentsToolCall { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return nil + } + for _, pending := range r.pending { + event := pending.event + pending.event = nil + if event != nil && pending.ctx.Err() == nil && time.Now().Before(pending.deadline) { + return event + } + } + return nil +} + +// begin takes the registry lock for [appClientTools.beginLocked] alone, so a +// panic inside it cannot leave the lock held. +func (r *appClientTools) begin(ctx context.Context, agent, id string, call session.ClientToolCall) (*clientToolPending, session.ClientToolAnswer, bool) { + r.mu.Lock() + defer r.mu.Unlock() + return r.beginLocked(ctx, agent, id, call) +} + +// Register before emitting, so even a synchronous client answer finds its waiter. +func (r *appClientTools) beginLocked(ctx context.Context, agent, id string, call session.ClientToolCall) (*clientToolPending, session.ClientToolAnswer, bool) { + r.pruneLocked(time.Now()) + if settled, ok := r.settled[id]; ok { + return nil, settled.answer, false + } + if pending := r.pending[id]; pending != nil { + return pending, session.ClientToolAnswer{}, false + } + deadline, attached := r.attachedLocked(agent, call.Set, call.Name) + if r.closed || !attached { + return nil, session.ClientToolUnavailable(call.Name), false + } + if call.DeadlineMS >= session.ClientToolMinDeadlineMS && call.DeadlineMS <= session.ClientToolMaxDeadlineMS { + deadline = call.DeadlineMS + } + call.DeadlineMS = deadline + pending := &clientToolPending{ctx: ctx, done: make(chan struct{}), call: call, deadline: time.Now().Add(time.Duration(deadline) * time.Millisecond)} + if r.pending == nil { + r.pending = make(map[string]*clientToolPending) + } + r.pending[id] = pending + return pending, session.ClientToolAnswer{}, true +} + +func (r *appClientTools) attachedLocked(agent, setID, name string) (int, bool) { + for _, set := range r.agents[agent].sets { + if set.ID != setID { + continue + } + for _, tool := range set.Tools { + if tool.Name == name { + return set.DeadlineMS, true + } + } + } + return 0, false +} + +func (r *appClientTools) wait(ctx context.Context, id string, pending *clientToolPending) session.ClientToolAnswer { + timer := time.NewTimer(time.Until(pending.deadline)) + defer timer.Stop() + var failed session.ClientToolAnswer + select { + case <-pending.done: + return pending.answer + case <-timer.C: + failed = clientToolTimeout(pending.call) + case <-ctx.Done(): + failed = clientToolTurnEnded(pending.call.Name) + } + r.mu.Lock() + defer r.mu.Unlock() + r.settleLocked(id, pending, failed) + return pending.answer +} + +func clientToolTimeout(call session.ClientToolCall) session.ClientToolAnswer { + return session.ClientToolAnswer{Text: fmt.Sprintf("client_timeout: the app that provides %s did not answer within %gs; the outcome is unknown, so check before trying again.", call.Name, float64(call.DeadlineMS)/1000), IsError: true} +} + +func clientToolTurnEnded(name string) session.ClientToolAnswer { + return session.ClientToolAnswer{Text: fmt.Sprintf("the turn ended before the app answered %s; the outcome is unknown, so check before trying again.", name), IsError: true} +} + +func (r *appClientTools) settleLocked(id string, pending *clientToolPending, answer session.ClientToolAnswer) bool { + if r.pending[id] != pending { + return false + } + delete(r.pending, id) + pending.answer = answer + if r.settled == nil { + r.settled = make(map[string]clientToolSettled) + } + r.pruneLocked(time.Now()) + if len(r.order) == clientToolSettledMax { + delete(r.settled, r.order[0]) + r.order = r.order[1:] + } + r.settled[id] = clientToolSettled{answer: answer, at: time.Now()} + r.order = append(r.order, id) + close(pending.done) + return true +} + +func (r *appClientTools) pruneLocked(now time.Time) { + for len(r.order) > 0 { + id := r.order[0] + if now.Sub(r.settled[id].at) < clientToolSettledTTL { + break + } + delete(r.settled, id) + r.order = r.order[1:] + } +} + +func (b *appBackend) ClientToolResult(p appwire.ClientToolResultParams) (appwire.ClientToolResultAck, error) { + if err := p.Validate(); err != nil { + return appwire.ClientToolResultAck{}, err + } + var result string + if p.Error == nil { + var compact bytes.Buffer + _ = json.Compact(&compact, p.Result) + result = session.BoundClientToolOutput(compact.String()) + } + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + pending := r.pending[p.Call] + if pending == nil { + return appwire.ClientToolResultAck{}, nil + } + if pending.ctx.Err() != nil { + r.settleLocked(p.Call, pending, clientToolTurnEnded(pending.call.Name)) + return appwire.ClientToolResultAck{}, nil + } + if !time.Now().Before(pending.deadline) { + r.settleLocked(p.Call, pending, clientToolTimeout(pending.call)) + return appwire.ClientToolResultAck{}, nil + } + answer := session.ClientToolAnswer{} + if p.Error != nil { + answer.Text = fmt.Sprintf("%s could not do %s: %s", pending.call.Set, pending.call.Name, *p.Error) + answer.IsError = true + } else { + answer.Text = result + } + return appwire.ClientToolResultAck{Accepted: r.settleLocked(p.Call, pending, answer)}, nil +} + +// Disconnect precedes conversation shutdown, so pending calls receive the owner-loss answer. +func (r *appClientTools) disconnect() { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return + } + r.closed = true + if r.senderWake != nil { + close(r.senderWake) + } + for agent, state := range r.agents { + r.agents[agent] = clientToolAgentSets{version: state.version + 1} + } + for id, pending := range r.pending { + answer := session.ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s disconnected before answering; the outcome is unknown, so check before trying again.", pending.call.Name), IsError: true} + r.settleLocked(id, pending, answer) + } +} diff --git a/cmd/codeaf/appclienttools_test.go b/cmd/codeaf/appclienttools_test.go new file mode 100644 index 0000000000..cb9326c985 --- /dev/null +++ b/cmd/codeaf/appclienttools_test.go @@ -0,0 +1,719 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +type clientToolEmitter struct { + mu sync.Mutex + calls []appwire.AgentsToolCall + seen chan appwire.AgentsToolCall + answer func(appwire.AgentsToolCall) +} + +// Contract (a), (b), (e): a real session provider request crosses the app door, +// then a turn started after disconnect loses the tools and their instructions. +func TestClientToolsModelWireRoundTripAndDisconnectBoundary(t *testing.T) { + b, door, emitter := clientToolBackend(t) + clientToolAttach(t, b, door) + var mu sync.Mutex + var requests []struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Call string `json:"tool_call_id"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Call string `json:"tool_call_id"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mu.Lock() + foreground := len(request.Tools) > 0 + first := foreground && len(requests) == 0 + if foreground { + requests = append(requests, request) + } + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + if first { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-client-1","type":"function","function":{"name":"app_note","arguments":"{\"id\":3}"}}]},"finish_reason":null}]}`+"\n\n") + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n") + } else { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + t.Cleanup(server.Close) + disconnected := make(chan struct{}) + emitter.answer = func(event appwire.AgentsToolCall) { + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`{ "note": "found" }`)}) + if err != nil || !ack.Accepted { + t.Errorf("wire answer: %+v %v", ack, err) + } + b.clientTools.disconnect() + close(disconnected) + } + agent, err := session.New(session.Config{Workspace: t.TempDir(), Model: "test/model", APIKey: "fixture", BaseURL: server.URL, + OneModel: true, DeterministicNames: true, PromptProfile: "full", ClientTools: door}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = agent }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + events, err := agent.Submit(ctx, "read the app note") + if err != nil { + t.Fatal(err) + } + turnCall := "" + for { + select { + case event, open := <-events: + if !open { + goto finished + } + if event.Kind == session.EventToolBegin && event.Tool == "app_note" { + turnCall = event.CallID + } + case <-ctx.Done(): + t.Fatal("model round trip did not finish") + } + } +finished: + clientToolAwait(t, disconnected) + // Start this turn after disconnect has completed, independently of sender scheduling. + next, err := agent.Submit(ctx, "what tools remain?") + if err != nil { + t.Fatal(err) + } + for { + select { + case _, open := <-next: + if !open { + goto nextFinished + } + case <-ctx.Done(): + t.Fatal("post-disconnect turn did not finish") + } + } +nextFinished: + event := clientToolAwait(t, emitter.seen) + if event.TurnCall != turnCall || turnCall != "provider-client-1" { + t.Fatalf("turn.tool correlation: %+v / %q", event, turnCall) + } + mu.Lock() + defer mu.Unlock() + if len(requests) != 3 { + t.Fatalf("provider requests=%d", len(requests)) + } + first, second, afterDisconnect := requests[0], requests[1], requests[2] + if first.Tools[len(first.Tools)-1].Function.Name != "app_note" || !strings.Contains(string(first.Messages[0].Content), "Tools from the app") { + t.Fatal("initial request lost app definitions or instructions") + } + for _, tool := range afterDisconnect.Tools { + if tool.Function.Name == "app_note" { + t.Fatal("disconnected tool remained on the next request") + } + } + if strings.Contains(string(afterDisconnect.Messages[0].Content), "Tools from the app") { + t.Fatal("disconnected instructions remained on the next request") + } + found := false + for _, message := range second.Messages { + if message.Role != "tool" || message.Call != turnCall { + continue + } + if text := clientToolWireText(message.Content); text != `{"note":"found"}` { + t.Fatalf("model result: %s", message.Content) + } + found = true + } + if !found { + t.Fatal("model did not receive the wire result") + } +} + +func clientToolWireText(raw json.RawMessage) string { + var text string + if json.Unmarshal(raw, &text) == nil { + return text + } + var parts []struct { + Text string `json:"text"` + } + _ = json.Unmarshal(raw, &parts) + for _, part := range parts { + text += part.Text + } + return text +} + +func (e *clientToolEmitter) Emit(event string, data any) { + if event != appwire.EventAgentsToolCall { + return + } + call := data.(appwire.AgentsToolCall) + e.mu.Lock() + e.calls = append(e.calls, call) + e.mu.Unlock() + e.seen <- call + if e.answer != nil { + e.answer(call) + } +} + +func clientToolBackend(t *testing.T) (*appBackend, *appClientToolDoor, *clientToolEmitter) { + t.Helper() + t.Setenv(home.EnvVar, t.TempDir()) + e := &clientToolEmitter{seen: make(chan appwire.AgentsToolCall, 20)} + b := newFakeBackend(e, nil) + id := "client-agent" + transcript := filepath.Join(t.TempDir(), id, "transcript.jsonl") + b.locked(func() { + b.agents[id] = &appAgent{view: appwire.Agent{ID: id}, transcript: transcript, + live: &appLive{turn: 7, conn: &appConn{Conv: &fakeConversation{}}}} + }) + t.Cleanup(func() { + b.clientTools.disconnect() + b.locked(func() { b.agents[id].live = nil }) + _ = b.Close() + }) + return b, &appClientToolDoor{backend: b, agent: id}, e +} + +func clientToolParams(agent, set, name string) appwire.ClientToolsSetParams { + return appwire.ClientToolsSetParams{Agent: agent, Set: set, Instructions: "Use this set.", Tools: []session.ClientTool{ + {Name: name, Description: "An app action", InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionEdit}, + }} +} + +func clientToolAttach(t *testing.T, b *appBackend, d *appClientToolDoor) session.ClientToolCall { + t.Helper() + if _, err := b.SetClientTools(clientToolParams(d.agent, "notes", "app_note")); err != nil { + t.Fatal(err) + } + return session.ClientToolCall{Set: "notes", Name: "app_note", TurnCall: "provider-1", Args: json.RawMessage(`{"id":3}`)} +} + +// Contract (a), (h): the Fresh/Open builder binds the final conversation, even when its config carried another door. +func TestClientToolsFreshAndOpenBindTheirConversation(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + previous := appClientToolsBackend.Swap(b) + t.Cleanup(func() { appClientToolsBackend.Store(previous) }) + root := t.TempDir() + dir := writeV3Session(t, root, "client-b", "", time.Time{}) + file := (session.Place{Dir: dir}).Transcript() + b.locked(func() { b.agents["client-b"] = &appAgent{view: appwire.Agent{ID: "client-b"}, transcript: file} }) + p := clientToolParams("client-b", "other", "app_other") + p.Instructions = "Only conversation B's instructions." + p.Tools[0].Category = session.ActionRead + if _, err := b.SetClientTools(p); err != nil { + t.Fatal(err) + } + cfg := session.Config{Workspace: root, ClientTools: door} + b.locked(func() { cfg.SessionFile = b.agents[door.agent].transcript }) + cfg.Place = session.Place{Dir: session.DirOf(cfg.SessionFile), Workspace: root} + for _, path := range []string{"fresh", "open"} { + t.Run(path, func(t *testing.T) { + open := appClientToolsOpener(func(opened session.Config) (*session.Agent, error) { + bound, ok := opened.ClientTools.(*appClientToolDoor) + if !ok || bound.agent != "client-b" { + t.Fatalf("replacement door: %+v", opened.ClientTools) + } + _, sets := bound.ClientToolSets() + if len(sets) != 1 || sets[0].ID != "other" || sets[0].Instructions != p.Instructions || + len(sets[0].Tools) != 1 || sets[0].Tools[0].Name != "app_other" || sets[0].Tools[0].Category != session.ActionRead { + t.Fatalf("replacement inherited the wrong sets: %+v", sets) + } + return nil, nil + }) + var next session.Config + var err error + if path == "fresh" { + next, err = v3PointAt(cfg, session.Place{Dir: dir, Workspace: root}) + } else { + next, err = v3Reopen(cfg, file, root) + } + if err != nil { + t.Fatal(err) + } + if _, err := open(next); err != nil { + t.Fatal(err) + } + }) + } + if cfg.ClientTools != door { + t.Fatal("opening a replacement changed the original config") + } + appClientToolsBackend.Store(nil) + open := appClientToolsOpener(func(opened session.Config) (*session.Agent, error) { + if opened.ClientTools != nil { + t.Fatal("opening without an app retained a stale door") + } + return nil, nil + }) + if _, err := open(cfg); err != nil { + t.Fatal(err) + } +} + +func clientToolStart(ctx context.Context, d *appClientToolDoor, call session.ClientToolCall) <-chan session.ClientToolAnswer { + done := make(chan session.ClientToolAnswer, 1) + go func() { + answer, err := d.CallClientTool(ctx, call) + if err != nil { + answer = session.ClientToolAnswer{Text: err.Error(), IsError: true} + } + done <- answer + }() + return done +} + +func clientToolAwait[T any](t *testing.T, done <-chan T) T { + t.Helper() + select { + case answer := <-done: + return answer + case <-time.After(2 * time.Second): + t.Fatal("client tool did not settle within two seconds") + } + var zero T + return zero +} + +// Contract (b), (d): fast answers, duplicate dispatches and replay share one event. +func TestClientToolsEventResultAndReplay(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + first := clientToolStart(context.Background(), door, call) + event := clientToolAwait(t, emitter.seen) + if event.Agent != door.agent || event.Turn != 7 || event.TurnCall != call.TurnCall || event.Set != call.Set || event.Name != call.Name || + string(event.Args) != string(call.Args) || event.DeadlineMS != session.ClientToolDefaultDeadlineMS || event.Call != clientToolCallID(door.agent, call.TurnCall) { + t.Fatalf("event shape: %+v", event) + } + second := clientToolStart(context.Background(), door, call) + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`{ "ok": true }`)}) + if err != nil || !ack.Accepted { + t.Fatalf("answer: %+v %v", ack, err) + } + for _, done := range []<-chan session.ClientToolAnswer{first, second, clientToolStart(context.Background(), door, call)} { + if answer := clientToolAwait(t, done); answer.IsError || answer.Text != `{"ok":true}` { + t.Fatalf("result: %+v", answer) + } + } + for _, id := range []string{event.Call, "unknown"} { + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: id, Result: json.RawMessage(`null`)}) + if err != nil || ack.Accepted { + t.Fatalf("duplicate/unknown: %+v %v", ack, err) + } + } + emitter.mu.Lock() + defer emitter.mu.Unlock() + if len(emitter.calls) != 1 { + t.Fatalf("%d events for one call", len(emitter.calls)) + } +} + +// Contract (b): the pending entry exists before an emitter can answer synchronously. +func TestClientToolsFastErrorAnswer(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + emitter.answer = func(event appwire.AgentsToolCall) { + text := "permission denied" + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Error: &text}) + if err != nil || !ack.Accepted { + t.Errorf("fast answer: %+v %v", ack, err) + } + } + answer := clientToolAwait(t, clientToolStart(context.Background(), door, call)) + if !answer.IsError || answer.Text != "notes could not do app_note: permission denied" { + t.Fatalf("error result: %+v", answer) + } +} + +// Contract (c), (d), (e): each ending is bounded and rejects every late answer. +func TestClientToolsDisconnectDeadlineAndTurnEnd(t *testing.T) { + for _, ending := range []string{"disconnect", "deadline", "turn end"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + if ending == "deadline" { + // Only this in-process fixture bypasses the wire's one-second minimum. + b.clientTools.mu.Lock() + state := b.clientTools.agents[door.agent] + state.sets[0].DeadlineMS = 15 + b.clientTools.agents[door.agent] = state + b.clientTools.mu.Unlock() + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + want := "client_timeout: the app that provides app_note did not answer within 0.015s; the outcome is unknown, so check before trying again." + switch ending { + case "disconnect": + b.clientTools.disconnect() + b.clientTools.disconnect() + want = "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." + _, sets := door.ClientToolSets() + if len(sets) != 0 { + t.Fatal("disconnect kept sets") + } + case "turn end": + cancel() + want = clientToolTurnEnded(call.Name).Text + } + if answer := clientToolAwait(t, done); !answer.IsError || answer.Text != want { + t.Fatalf("ending: %+v", answer) + } + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`1`)}) + if err != nil || ack.Accepted { + t.Fatalf("late answer: %+v %v", ack, err) + } + }) + } +} + +// Contract (c), (d): a full writer cannot hold the deadline, turn end or disconnect hostage. +func TestClientToolsBlockedEmitterDoesNotBlockEndings(t *testing.T) { + for _, ending := range []string{"deadline", "turn end", "disconnect"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + if ending == "deadline" { + // Only this fixture bypasses the wire's one-second minimum. + b.clientTools.mu.Lock() + b.clientTools.agents[door.agent].sets[0].DeadlineMS = 100 + b.clientTools.mu.Unlock() + } + entered, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + emitter.answer = func(appwire.AgentsToolCall) { + close(entered) + <-release + close(returned) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + clientToolAwait(t, entered) + want := "client_timeout: the app that provides app_note did not answer within 0.1s; the outcome is unknown, so check before trying again." + switch ending { + case "turn end": + cancel() + want = clientToolTurnEnded(call.Name).Text + case "disconnect": + b.clientTools.disconnect() + want = "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." + } + if answer := clientToolAwait(t, done); !answer.IsError || answer.Text != want { + t.Fatalf("blocked %s: %+v", ending, answer) + } + select { + case <-returned: + t.Fatal("emitter returned before the call ended") + default: + } + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`null`)}) + if err != nil || ack.Accepted { + t.Fatalf("late answer: %+v %v", ack, err) + } + unblock() + clientToolAwait(t, returned) + b.clientTools.disconnect() + clientToolAwait(t, b.clientTools.senderDone) + }) + } +} + +// Contract (c), (d): calls ending behind a stalled writer are dropped before delivery. +func TestClientToolsEndedQueuedCallsAreNotEmitted(t *testing.T) { + for _, ending := range []string{"deadline", "turn end", "disconnect", "result"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + release := make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + emitter.answer = func(event appwire.AgentsToolCall) { + if event.TurnCall == "provider-1" { + <-release + } + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + first := clientToolStart(ctx, door, call) + clientToolAwait(t, emitter.seen) + cancel() + clientToolAwait(t, first) + + queuedCtx, cancelQueued := context.WithCancel(context.Background()) + defer cancelQueued() + call.TurnCall = "queued-call" + id := clientToolCallID(door.agent, call.TurnCall) + r := &b.clientTools + r.mu.Lock() + pending, _, emit := r.beginLocked(queuedCtx, door.agent, id, call) + r.mu.Unlock() + if pending == nil || !emit { + t.Fatal("queued call was not registered") + } + r.queueEmission(b.emit, pending, appwire.AgentsToolCall{Agent: door.agent, Call: id, TurnCall: call.TurnCall}) + switch ending { + case "deadline": + r.mu.Lock() + pending.deadline = time.Now().Add(-time.Second) + r.mu.Unlock() + case "turn end": + cancelQueued() + case "disconnect": + r.disconnect() + case "result": + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: id, Result: json.RawMessage(`true`)}) + if err != nil || !ack.Accepted { + t.Fatalf("queued answer: %+v %v", ack, err) + } + } + done := make(chan session.ClientToolAnswer, 1) + go func() { done <- r.wait(queuedCtx, id, pending) }() + answer := clientToolAwait(t, done) + if answer.IsError != (ending != "result") { + t.Fatalf("queued ending: %+v", answer) + } + unblock() + if ending != "disconnect" { + call.TurnCall = "after-queue" + after := clientToolStart(context.Background(), door, call) + event := clientToolAwait(t, emitter.seen) + if event.TurnCall != "after-queue" { + t.Fatalf("ended call left the queue: %+v", event) + } + r.disconnect() + clientToolAwait(t, after) + } + clientToolAwait(t, r.senderDone) + select { + case event := <-emitter.seen: + t.Fatalf("extra event after queue drain: %+v", event) + default: + } + }) + } +} + +// Contract (e), (h): replacement is atomic and removal affects only its own set. +func TestClientToolsSetReplacementRemovalAndValidation(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + if _, err := b.SetClientTools(clientToolParams(door.agent, "second", "app_other")); err != nil { + t.Fatal(err) + } + beforeVersion, before := door.ClientToolSets() + for _, p := range []appwire.ClientToolsSetParams{ + clientToolParams(door.agent, "notes", "factory_add"), + clientToolParams(door.agent, "notes", "app_other"), + clientToolParams(door.agent, "notes", "change_setting"), + } { + if _, err := b.SetClientTools(p); err == nil || !strings.Contains(err.Error(), p.Tools[0].Name) { + t.Fatalf("collision accepted: %v", err) + } + version, sets := door.ClientToolSets() + if version != beforeVersion || !reflect.DeepEqual(sets, before) { + t.Fatal("refusal changed the registry") + } + } + replacement := clientToolParams(door.agent, "notes", "app_replaced") + if _, err := b.SetClientTools(replacement); err != nil { + t.Fatal(err) + } + replacement.Tools[0].InputSchema[0] = '[' + _, snapshots := door.ClientToolSets() + if !json.Valid(snapshots[0].Tools[0].InputSchema) { + t.Fatal("registry kept the caller's mutable schema") + } + removed, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "notes"}) + if err != nil || len(removed.Tools) != 0 { + t.Fatalf("removal: %+v %v", removed, err) + } + _, sets := door.ClientToolSets() + if len(sets) != 1 || sets[0].ID != "second" { + t.Fatal("removal changed another set") + } + answer := clientToolAwait(t, clientToolStart(context.Background(), door, session.ClientToolCall{Set: "notes", Name: "app_replaced", Args: json.RawMessage(`{}`)})) + if !answer.IsError || answer.Text != "client_unavailable: the app that provides app_replaced is not connected; nothing was done." { + t.Fatalf("retired closure: %+v", answer) + } +} + +// Contract (h): aggregate limits and remote scope do not open a conversation. +func TestClientToolsAgentLimitsAndScope(t *testing.T) { + b, door, _ := clientToolBackend(t) + if _, err := b.SetClientTools(clientToolParams("missing", "notes", "app_note")); err == nil { + t.Fatal("unknown agent accepted") + } + for i := 0; i < session.ClientToolMaxSets; i++ { + if _, err := b.SetClientTools(clientToolParams(door.agent, fmt.Sprintf("set%d", i), fmt.Sprintf("app_%d", i))); err != nil { + t.Fatal(err) + } + } + if _, err := b.SetClientTools(clientToolParams(door.agent, "fifth", "app_fifth")); err == nil { + t.Fatal("fifth set accepted") + } + b.clientTools.mu.Lock() + b.clientTools.agents[door.agent] = clientToolAgentSets{} + b.clientTools.mu.Unlock() + for i := 0; i < 2; i++ { + p := clientToolParams(door.agent, fmt.Sprintf("set%d", i), "unused") + p.Tools = nil + for j := 0; j < session.ClientToolMaxPerSet; j++ { + p.Tools = append(p.Tools, clientToolParams(door.agent, p.Set, fmt.Sprintf("app_%d_%d", i, j)).Tools[0]) + } + if _, err := b.SetClientTools(p); err != nil { + t.Fatal(err) + } + } + if _, err := b.SetClientTools(clientToolParams(door.agent, "overflow", "app_extra")); err == nil { + t.Fatal("65th tool accepted") + } + b.locked(func() { b.agents[door.agent].live.conn.Conv = &remote.Agent{} }) + if _, err := b.SetClientTools(clientToolParams(door.agent, "remote", "app_remote")); err == nil || !strings.Contains(err.Error(), "client tools need a conversation this engine runs") { + t.Fatal(err) + } + b.locked(func() { b.agents[door.agent].live.conn.Assembled = true }) + if _, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "set0"}); err != nil { + t.Fatalf("local transport proxy refused: %v", err) + } + b.locked(func() { b.agents[door.agent].live = nil }) + if _, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "set0"}); err != nil { + t.Fatalf("cold attachment opened a conversation: %v", err) + } +} + +// Contract (d): a concurrent dispatch joins the actual pending object without emitting. +func TestClientToolsPendingJoinAndCancelledAnswer(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + ctx, cancel := context.WithCancel(context.Background()) + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + r := &b.clientTools + r.mu.Lock() + first := r.pending[event.Call] + joined, _, emit := r.beginLocked(ctx, door.agent, event.Call, call) + r.mu.Unlock() + if first != joined || emit { + t.Fatal("duplicate dispatch did not join") + } + cancel() + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || ack.Accepted { + t.Fatalf("cancelled turn accepted an answer: %+v %v", ack, err) + } + if answer := clientToolAwait(t, done); !answer.IsError || !strings.Contains(answer.Text, "turn ended") { + t.Fatal(answer) + } +} + +// Contract (h): local proxy inventories include dynamically armed engine names. +func TestClientToolsBuiltinInventoryFromLocalEngine(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + door.ClientToolBuiltins([]string{"app_reserved"}) + if _, err := b.SetClientTools(clientToolParams(door.agent, "other", "app_reserved")); err == nil || !strings.Contains(err.Error(), "app_reserved") { + t.Fatal(err) + } +} + +// Contract (d): replay memory expires and never exceeds its entry budget. +func TestClientToolsSettledMemoryBoundAndExpiry(t *testing.T) { + r := &appClientTools{pending: make(map[string]*clientToolPending)} + for i := 0; i < clientToolSettledMax+5; i++ { + id := fmt.Sprint(i) + pending := &clientToolPending{done: make(chan struct{})} + r.pending[id] = pending + r.settleLocked(id, pending, session.ClientToolAnswer{Text: id}) + } + if len(r.settled) != clientToolSettledMax || len(r.order) != clientToolSettledMax { + t.Fatal("unbounded replay memory") + } + r.pruneLocked(time.Now().Add(clientToolSettledTTL)) + if len(r.settled) != 0 { + t.Fatal("expired answers remain") + } + if clientToolCallID("a", "p") == clientToolCallID("b", "p") || clientToolCallID("a", "") == clientToolCallID("a", "") { + t.Fatal("call identity collision") + } +} + +// Contract (b): compact JSON is bounded with the exact number of dropped bytes. +func TestClientToolsBoundedOutputAndKinds(t *testing.T) { + data := []byte(`"` + strings.Repeat("é", session.ClientToolOutputMax) + `"`) + text := session.BoundClientToolOutput(string(data)) + if !strings.HasSuffix(text, fmt.Sprintf("[cut: %d bytes dropped]", len(data)-(session.ClientToolOutputMax-1))) { + t.Fatalf("cut marker: %.80s", text[len(text)-80:]) + } + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + if category := b.clientToolEventCategory(b.agents[door.agent], session.Event{Tool: "app_note"}); category != session.ActionEdit { + t.Fatal(category) + } + if kind := appToolKind("app_note", "", session.ActionEdit); kind != appwire.ToolEdit { + t.Fatal(kind) + } +} + +// Contract (i): the feature remains usable by any app, with no domain vocabulary. +func TestClientToolsSourcesStayGeneric(t *testing.T) { + for _, path := range []string{"../../internal/appwire/client_tools.go", "../../internal/appwire/client_tools_test.go", + "../../internal/session/client_tools.go", "../../internal/session/client_tools_test.go", "appclienttools.go", "appclienttools_test.go", + "../../internal/manual/chat/client-tools.md"} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(raw)), "work"+"stream") { + t.Errorf("%s contains domain vocabulary", path) + } + } +} diff --git a/cmd/codeaf/appcollectionkeep.go b/cmd/codeaf/appcollectionkeep.go new file mode 100644 index 0000000000..b64f4bfb65 --- /dev/null +++ b/cmd/codeaf/appcollectionkeep.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/sqlitefile" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// A collection's reference makes a bare conversation intentional. Read-only mode +// also protects against the store disappearing between Stat and the SQLite open. +func collectionKeepsConversation(dir string) bool { + path := home.Join("v3", "collections.db") + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return false + } else if err != nil { + return true + } + db, err := sql.Open("sqlite", sqlitefile.URI(path, "mode=ro&_pragma=busy_timeout(100)")) + if err != nil { + return true + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var kept bool + // These membership columns exist in every collections schema. EXISTS avoids + // loading collection metadata or interpreting any newer collection features. + err = db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships m + JOIN collections c ON c.id=m.collection_id WHERE m.kind=? AND m.ref_id=? AND m.session_id='')`, + workspace.ConversationKind, filepath.Base(filepath.Clean(dir))).Scan(&kept) + return kept || err != nil +} diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go new file mode 100644 index 0000000000..019c60feef --- /dev/null +++ b/cmd/codeaf/appcollectionkeep_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/agentdid" + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +func collectionKeepEnvironment(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + t.Setenv(config.ProfileDirEnv, filepath.Join(root, "profile")) + bucket, err := v3ProjectDir(root) + if err != nil { + t.Fatal(err) + } + return root, bucket +} + +// Contract (g): collection-held blanks survive launch and the app's cleanup. +func TestCollectionHeldConversationSurvivesEveryBlankCleanup(t *testing.T) { + _, bucket := collectionKeepEnvironment(t) + held := writeV3Session(t, bucket, "collection-held", "", time.Time{}) + unheld := writeV3Session(t, bucket, "unheld", "", time.Time{}) + path := home.Join("v3", "collections.db") + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(held)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if v3EmptyUnassignedSession(held) || v3EmptySession(held) { + t.Fatal("collection-held conversation is litter") + } + if !v3EmptyUnassignedSession(unheld) { + t.Fatal("unheld empty conversation was retained") + } + v3ReapEmpty([]v3Folder{{dir: held}, {dir: unheld}}, "") + if _, err := os.Stat(held); err != nil { + t.Fatal("launch reap removed collection-held conversation") + } + if _, err := os.Stat(unheld); !os.IsNotExist(err) { + t.Fatalf("launch reap kept unheld conversation: %v", err) + } + b := newFakeBackend(newAppRecorder(), nil) + b.profileDir = config.ProfileDir() + row, ok := appRowOnDisk(filepath.Base(held)) + if !ok { + t.Fatal("bare row missing") + } + b.remember(row) + a := b.agent(row.ID) + b.locked(func() { a.minted = true }) + if b.reapBlank(a) { + t.Fatal("app cleanup removed collection-held conversation") + } + if err := b.Close(); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || !beforeInfo.ModTime().Equal(afterInfo.ModTime()) { + t.Fatal("blank cleanup wrote collections.db") + } +} + +// Contract (g): the real launch-time sweep keeps collection-held litter and unreadable stores. +func TestCollectionHeldConversationSurvivesBackgroundSweep(t *testing.T) { + for _, unreadable := range []bool{false, true} { + name := "held and unheld" + if unreadable { + name = "unreadable store" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + held := writeV3Session(t, bucket, "collection-held", "", time.Time{}) + unheld := writeV3Session(t, bucket, "unheld", "", time.Time{}) + old := time.Now().Add(-8 * 24 * time.Hour) + for _, dir := range []string{held, unheld} { + meta := session.Meta{ID: filepath.Base(dir), Workspace: root, LaunchDir: root, Created: old} + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(dir, old, old); err != nil { + t.Fatal(err) + } + } + path := home.Join("v3", "collections.db") + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(held)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + if unreadable { + if err := os.WriteFile(path, []byte("unreadable database"), 0o600); err != nil { + t.Fatal(err) + } + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + proc := &v3Process{} + proc.startPlaceSweep() + t.Cleanup(proc.stopPlaceSweep) + select { + case <-proc.sweepDone: + case <-time.After(3 * time.Second): + t.Fatal("background sweep did not finish") + } + if _, err := os.Stat(held); err != nil { + t.Fatalf("background sweep removed held conversation: %v", err) + } + _, err = os.Stat(unheld) + if unreadable && err != nil { + t.Fatalf("background sweep discarded an uncertain conversation: %v", err) + } + if !unreadable && !os.IsNotExist(err) { + t.Fatalf("background sweep kept unheld control: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || !info.ModTime().Equal(afterInfo.ModTime()) { + t.Fatal("background sweep wrote collections.db") + } + }) + } +} + +// Contract (g): missing means unheld; every unreadable database means keep. +func TestCollectionKeepMissingUnreadableAndNoWrites(t *testing.T) { + _, bucket := collectionKeepEnvironment(t) + dir := writeV3Session(t, bucket, "empty-chat", "", time.Time{}) + path := home.Join("v3", "collections.db") + if collectionKeepsConversation(dir) { + t.Fatal("missing store retained conversation") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("read created a store") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + bad := []byte("not a SQLite database") + if err := os.WriteFile(path, bad, 0o600); err != nil { + t.Fatal(err) + } + if !collectionKeepsConversation(dir) || v3EmptyUnassignedSession(dir) { + t.Fatal("uncertainty discarded a conversation") + } + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, bad) { + t.Fatal("read repaired or migrated a corrupt store") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if !collectionKeepsConversation(dir) { + t.Fatal("unopenable store discarded a conversation") + } +} + +// Contract (g): a restart recovers every identity field from a never-spoken meta. +func TestCollectionHeldConversationIsFoundAndOpenedAfterRestart(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + did, err := agentdid.New() + if err != nil { + t.Fatal(err) + } + created := time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC) + meta := session.Meta{ID: id, Workspace: root, Owned: true, Title: "Reserved notes", Model: "vendor/model", Archived: true, SpentUSD: 1.25, Created: created, + AgentIdentity: &session.AgentIdentity{DID: did, Session: id}} + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: id}); err != nil { + t.Fatal(err) + } + _ = store.Close() + want := session.SessionRow{ID: id, AgentDID: did, Dir: dir, Transcript: (session.Place{Dir: dir}).Transcript(), Workspace: root, Owned: true, + Title: meta.Title, Model: meta.Model, Archived: true, Spend: meta.SpentUSD, At: created, Created: created} + row, ok := appRowOnDisk(id) + if !ok || !reflect.DeepEqual(row, want) { + t.Fatalf("identity after restart: %+v want %+v", row, want) + } + opened := 0 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened++ + if ask.Transcript != want.Transcript || ask.Workspace != root || ask.Model != meta.Model { + t.Errorf("open identity: %+v", ask) + } + return &appConn{Conv: &fakeConversation{model: ask.Model}, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() {}}, nil + }) + t.Cleanup(func() { _ = b.Close() }) + found, err := b.find(id) + if err != nil || found.view.DID != did || found.view.Title != meta.Title || opened != 0 { + t.Fatalf("find: %+v %v", found, err) + } + view, err := b.OpenAgent(id) + if err != nil || view.ID != id || view.DID != did || view.Title != meta.Title || view.Model != meta.Model || opened != 1 { + t.Fatalf("agents.open: %+v %v", view, err) + } + if view.State == appwire.StateWorking { + t.Fatal("opening started a turn") + } +} diff --git a/cmd/codeaf/apptools.go b/cmd/codeaf/apptools.go index 430092b0f4..9ef7f9dd25 100644 --- a/cmd/codeaf/apptools.go +++ b/cmd/codeaf/apptools.go @@ -133,11 +133,15 @@ func appFileName(id string) string { // appToolKind is a call's kind, read off the engine's own family for the // tool and the account it acts through, never a list kept here. -func appToolKind(tool, service string) string { +func appToolKind(tool, service string, declared ...session.ActionCategory) string { if service != "" { return appwire.ToolConnector } - switch session.ActionCategoryForTool(tool) { + category := session.ActionCategoryForTool(tool) + if len(declared) > 0 && declared[0] != "" { + category = declared[0] + } + switch category { case session.ActionSearch: if strings.HasPrefix(tool, "web_") { return appwire.ToolWeb @@ -198,6 +202,7 @@ func (b *appBackend) newTurnCalls(a *appAgent) { // callBegan records a call that began and answers its row. func (b *appBackend) callBegan(a *appAgent, agent string, turn uint64, event session.Event, title string) appwire.TurnTool { + category := b.clientToolEventCategory(a, event) log := b.callLog(a) var workspace string b.locked(func() { workspace = a.view.Workspace }) @@ -227,7 +232,7 @@ func (b *appBackend) callBegan(a *appAgent, agent string, turn uint64, event ses } c.run = event.RunID c.tool, c.args, c.hint, c.title, c.turn = event.Tool, event.Args, event.Hint, title, turn - kind := appToolKind(event.Tool, event.Service) + kind := appToolKind(event.Tool, event.Service, category) if c.facts.Ask != "" { kind = appwire.ToolAsksFirst } @@ -389,6 +394,7 @@ func (b *appBackend) callFinished(a *appAgent, event session.Event) { // callEnded records a call's end and answers its row. func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event session.Event, title, summary, detail string, ok bool) appwire.TurnTool { + category := b.clientToolEventCategory(a, event) log := b.callLog(a) process := "" if ok && !event.HarnessMade && !event.Refused && !event.RejectedBeforeRun { @@ -399,7 +405,7 @@ func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event ses c := log.calls[event.CallID] if c == nil { c = &appCall{id: event.CallID, tool: event.Tool, args: event.Args, turn: turn} - c.facts.Kind = appToolKind(event.Tool, "") + c.facts.Kind = appToolKind(event.Tool, "", category) if event.CallID != "" { log.calls[event.CallID] = c log.order = append(log.order, event.CallID) @@ -627,7 +633,7 @@ func (b *appBackend) appEntryFacts(a *appAgent, item session.DisplayEntry) appwi facts.MediaAttempt = item.ToolResult.MediaAttempt() var workspace string b.locked(func() { workspace = a.view.Workspace }) - facts.Kind = appToolKind(item.Tool, "") + facts.Kind = appToolKind(item.Tool, "", b.clientToolEventCategory(a, session.Event{Tool: item.Tool})) facts.Input = appToolInput(facts.Kind, item.Tool, item.Args, item.Hint, workspace) if path, adds, dels, ok := tui3.ToolFileChange(item.Tool, item.Args); ok { facts.Files = []appwire.FileChange{{Path: path, Add: adds, Del: dels}} diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index ca38a344a6..d5eab92341 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -733,7 +733,7 @@ func v3EmptyUnassignedSession(dir string) bool { return false } } - return !v3WorkstreamKeeps(dir) + return !v3WorkstreamKeeps(dir) && !collectionKeepsConversation(dir) } // v3ReapEmpty removes the empty session folders a launch left behind, except diff --git a/cmd/codeaf/chatv3_sweep.go b/cmd/codeaf/chatv3_sweep.go index 0c38d8c1d6..f204396515 100644 --- a/cmd/codeaf/chatv3_sweep.go +++ b/cmd/codeaf/chatv3_sweep.go @@ -42,7 +42,7 @@ const sweepLogName = "sweep.log" // directory to walk so that the rule can be pointed at a temp directory and // proved. [v3ErrandsRoot] is the one answer to where that is, and // [errandHeldByAutomation] keeps the errands an automation was saved from. -var sweepHome func(context.Context, string, func(string) bool, func(string)) = session.SweepHomeContext +var sweepHome func(context.Context, string, func(string) bool, func(string) bool, func(string)) = session.SweepHomeContext func (p *v3Process) startPlaceSweep() { ctx, cancel := context.WithCancel(context.Background()) @@ -51,7 +51,7 @@ func (p *v3Process) startPlaceSweep() { p.sweepDone = done guard.Go("chatv3/sweep-home", func() { defer close(done) - sweepHome(ctx, v3ErrandsRoot(), errandHeldByAutomation(), func(line string) { + sweepHome(ctx, v3ErrandsRoot(), errandHeldByAutomation(), collectionKeepsConversation, func(line string) { if ctx.Err() == nil { noteSweep(line) } diff --git a/cmd/codeaf/chatv3_sweep_test.go b/cmd/codeaf/chatv3_sweep_test.go index d4b6f6ee8f..e84a0e580b 100644 --- a/cmd/codeaf/chatv3_sweep_test.go +++ b/cmd/codeaf/chatv3_sweep_test.go @@ -28,7 +28,7 @@ func TestCloseJoinsPlaceSweepAndNextProcessStillSweeps(t *testing.T) { t.Cleanup(func() { sweepHome = previousSweep }) var starts atomic.Int32 var destructive atomic.Int32 - sweepHome = func(ctx context.Context, _ string, _ func(string) bool, _ func(string)) { + sweepHome = func(ctx context.Context, _ string, _, _ func(string) bool, _ func(string)) { i := int(starts.Add(1)) - 1 if i >= len(passes) { t.Errorf("unexpected sweep start %d", i+1) diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index b4865d8725..829b3bd308 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1170,6 +1170,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // Harnesses and RunHarness, properties of the machine that the shared // assembly fills for every door. cfg, open := v3Shape(cfg, v3LanesOverWire()) + open = appClientToolsOpener(open) // AND AUTOMATIONS ARE ON, which is the one capability on this list that a // connection does not take away. The store arrives already filled, from the diff --git a/docs/changes/unreleased/2334-client-tools.md b/docs/changes/unreleased/2334-client-tools.md new file mode 100644 index 0000000000..b08d04aa68 --- /dev/null +++ b/docs/changes/unreleased/2334-client-tools.md @@ -0,0 +1,24 @@ +--- +kind: added +title: Apps can attach their own tools to a conversation, and collections keep blank conversations +pr: 2334 +surface: [app, chat] +invalidates: + - A never-spoken conversation was removed at launch unless a team referenced it; a conversation any collection references is now kept as well. +--- + +`codeaf app` clients can attach named sets of tools to a conversation with +`agents.tools_set`. The model sees them after every built-in tool, for as long +as that client stays connected. Each call reaches the client as one +`agents.tool_call` event, and the client answers it with `agents.tool_result`. +A disconnect, the deadline (30 seconds by default, at most 120) or the end of the +turn answers the model with an error that says the outcome is unknown. Late or +repeated answers are ignored. A conversation holds at most 4 sets and 64 tools; +built-in tool names are refused, and child tasks never receive client tools. The +hello carries `agents.client_tools`. Conversations held by a remote engine are +refused for now. + +Launch cleanup, the blank-chat sweep and the background sweep now keep a +never-spoken conversation that any collection references. An unreadable +collections database keeps it too. Opening such a conversation by id after a +restart restores its saved details. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 68723d0488..bdd16a7a14 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -79,6 +79,81 @@ decides which combinations are accepted. | `agents.stop` | `in.IDParams` | `Empty` | | `agents.suggest_reply` | `in.ReplySuggestionParams` | `ReplySuggestion` | +## Client tools + +The optional feature `agents.client_tools` appears in `hello.features` exactly +when the engine implements the client-tools interface. Sets belong to this +stdio connection and one conversation. They are memory-only: register again +after reconnecting. Attachment does not open the conversation or start a turn. +Children, workers and clarification conversations inherit neither tools nor +instructions. Remote forwarding is unavailable. + +| method | params | result | +|---|---|---| +| `agents.tools_set` | `{agent, set, instructions?, deadline_ms?, tools?: [{name, description, input_schema, category}]}` | `{agent, set, tools: [name, ...]}` | +| `agents.tool_result` | `{call, result: }` or `{call, error: }` | `{accepted: true}` or `{accepted: false}` | + +`agents.tools_set` replaces that `agent` + `set` whole; other sets remain. +Omitted `tools` or `tools: []` removes only that set and returns `tools: []`. +An agent holds at most 4 sets, 32 tools per set and 64 tools total. All validation +is atomic: a refusal leaves every set unchanged. + +- `set` matches `^[a-z][a-z0-9_.-]{0,63}$`. +- `name` matches `^[a-z][a-z0-9_]{0,63}$`, unique within and across the agent's sets. +- `description` is 1–2048 bytes. `input_schema` is a JSON object whose `type` + is `"object"`, at most 16 KiB. Schema validation of call arguments is the app's job. +- `category` is `read`, `edit` or `create` and supplies this conversation's tool + category. Reading-only contexts refuse edits and creations. Ordinary tool + approval rules apply; attachment is not approval. +- `instructions` is at most 4096 bytes per set and may be empty. It appears in a + section headed `Tools from the app: `. A caller-supplied system page is + preserved and receives no section. Ordinary conversations gain no text. +- `deadline_ms` is 0 (the default 30000) or 1000–120000. + +Invalid fields, aggregate limits, duplicates and built-in tool names answer +`bad_params`; a collision message names the tool. Collision checks include the +live belt and all classified engine tools. A built-in arriving later wins and +the colliding client tool leaves the belt. No model request has duplicate names. +Unknown agents answer `not_found`. A remote-held conversation answers +`bad_scope`: `client tools need a conversation this engine runs`. +Tools and instructions update at the next step boundary, with client tools at +the tail after all built-ins. + +| event | data | +|---|---| +| `agents.tool_call` | `{agent, turn, call, turn_call, set, name, args: , deadline_ms}` | + +One model call emits exactly one `agents.tool_call`. `turn` is the engine's +current conversation turn; `turn_call` matches `turn.tool.call`. `call` is unique +among pending calls and deterministic for the agent + provider call id. Without +a provider id the engine mints a fresh id. A concurrent redispatch joins the +pending wait and emits no second event. A settled redispatch returns the saved +answer without an event for 10 minutes, subject to a maximum of 1024 saved +answers per connection. Non-object model arguments become an invalid-arguments +error result without an event. + +`agents.tool_result` requires exactly one of `result` and `error`. `result` +accepts any JSON, including `null`, and reaches the model as compact JSON text, +cut at 64 KiB with a plain dropped-byte marker. `error` must be a nonempty string +of at most 4096 bytes and becomes an error tool result: +` could not do : `. Invalid answers are `bad_params`. +A pending answer returns `accepted: true`; unknown, late or duplicate ids return +`accepted: false` without an error or any other effect. + +Connection end (EOF, shutdown or context cancellation) disconnects the registry +before conversations close: all sets are dropped and all pending calls settle +immediately. These failures reach the model as error tool results, with no +engine retry: + +- A stale call after removal or disconnection: + `client_unavailable: the app that provides is not connected; nothing was done.` +- Disconnection during a call: + `client_unavailable: the app that provides disconnected before answering; the outcome is unknown, so check before trying again.` +- Deadline: + `client_timeout: the app that provides did not answer within s; the outcome is unknown, so check before trying again.` +- Turn cancellation: the turn ended before the app answered. The pending entry + is dropped immediately and later answers return `accepted: false`. + ## asks methods | method | params | result | diff --git a/internal/appwire/client_tools.go b/internal/appwire/client_tools.go new file mode 100644 index 0000000000..589f22fd07 --- /dev/null +++ b/internal/appwire/client_tools.go @@ -0,0 +1,160 @@ +package appwire + +import ( + "encoding/json" + "regexp" + + "github.com/Agent-Field/codeaf/internal/session" +) + +const FeatureClientTools = "agents.client_tools" +const EventAgentsToolCall = "agents.tool_call" + +// ClientTooler is optional because only a connected app can answer these tools. +type ClientTooler interface { + SetClientTools(ClientToolsSetParams) (ClientToolsSetResult, error) + ClientToolResult(ClientToolResultParams) (ClientToolResultAck, error) +} + +type ClientToolsSetParams struct { + Agent string `json:"agent"` + Set string `json:"set"` + Instructions string `json:"instructions"` + DeadlineMS int `json:"deadline_ms"` + Tools []session.ClientTool `json:"tools"` +} + +type ClientToolsSetResult struct { + Agent string `json:"agent"` + Set string `json:"set"` + Tools []string `json:"tools"` +} + +type AgentsToolCall struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Call string `json:"call"` + TurnCall string `json:"turn_call"` + Set string `json:"set"` + Name string `json:"name"` + Args json.RawMessage `json:"args"` + DeadlineMS int `json:"deadline_ms"` +} + +type ClientToolResultParams struct { + Call string `json:"call"` + // RawMessage distinguishes an explicit JSON null result from an omitted one. + Result json.RawMessage `json:"result,omitempty"` + Error *string `json:"error,omitempty"` +} + +type ClientToolResultAck struct { + Accepted bool `json:"accepted"` +} + +func (p *ClientToolResultParams) UnmarshalJSON(data []byte) error { + type plain ClientToolResultParams + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + if _, present := fields["error"]; present && decoded.Error == nil { + return Errorf(CodeBadParams, "error must be a nonempty string") + } + *p = ClientToolResultParams(decoded) + return nil +} + +var clientToolSetName = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`) +var clientToolName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// Validate rejects a whole replacement before the backend can change any set. +func (p ClientToolsSetParams) Validate() error { + if p.Agent == "" || !clientToolSetName.MatchString(p.Set) { + return Errorf(CodeBadParams, "agent and a valid set id are required") + } + if len(p.Tools) > session.ClientToolMaxPerSet || len(p.Instructions) > session.ClientToolInstructionsMax { + return Errorf(CodeBadParams, "a set allows at most %d tools and %d instruction bytes", session.ClientToolMaxPerSet, session.ClientToolInstructionsMax) + } + if p.DeadlineMS != 0 && (p.DeadlineMS < session.ClientToolMinDeadlineMS || p.DeadlineMS > session.ClientToolMaxDeadlineMS) { + return Errorf(CodeBadParams, "deadline_ms must be 0 or %d–%d", session.ClientToolMinDeadlineMS, session.ClientToolMaxDeadlineMS) + } + held := make(map[string]bool, len(p.Tools)) + for _, tool := range p.Tools { + if !clientToolName.MatchString(tool.Name) || held[tool.Name] { + return Errorf(CodeBadParams, "invalid or duplicate tool name %q", tool.Name) + } + held[tool.Name] = true + if err := validateClientTool(tool); err != nil { + return err + } + } + return nil +} + +func validateClientTool(tool session.ClientTool) error { + if len(tool.Description) == 0 || len(tool.Description) > session.ClientToolDescriptionMax { + return Errorf(CodeBadParams, "%s description must be 1–%d bytes", tool.Name, session.ClientToolDescriptionMax) + } + var object map[string]json.RawMessage + if len(tool.InputSchema) > session.ClientToolSchemaMax || json.Unmarshal(tool.InputSchema, &object) != nil || object == nil { + return Errorf(CodeBadParams, "%s input_schema must be an object of at most %d bytes", tool.Name, session.ClientToolSchemaMax) + } + var kind string + if json.Unmarshal(object["type"], &kind) != nil || kind != "object" { + return Errorf(CodeBadParams, "%s input_schema type must be object", tool.Name) + } + switch tool.Category { + case session.ActionRead, session.ActionEdit, session.ActionCreate: + return nil + } + return Errorf(CodeBadParams, "%s category must be read, edit or create", tool.Name) +} + +func (p ClientToolResultParams) Validate() error { + if (p.Result != nil) == (p.Error != nil) { + return Errorf(CodeBadParams, "exactly one of result or error is required") + } + if p.Error != nil && (len(*p.Error) == 0 || len(*p.Error) > session.ClientToolErrorMax) { + return Errorf(CodeBadParams, "error must be 1–%d bytes", session.ClientToolErrorMax) + } + if p.Result != nil && !json.Valid(p.Result) { + return Errorf(CodeBadParams, "result must be JSON") + } + return nil +} + +func withClientToolsFeature(hello Hello, engine Engine) Hello { + if _, ok := engine.(ClientTooler); ok { + return withFeature(hello, FeatureClientTools) + } + return hello +} + +func setClientTools(e Engine, p ClientToolsSetParams) (*ClientToolsSetResult, error) { + door, ok := e.(ClientTooler) + if !ok { + return nil, Errorf(CodeUnknownMethod, "client tools are unavailable") + } + if err := p.Validate(); err != nil { + return nil, err + } + result, err := door.SetClientTools(p) + return &result, err +} + +func answerClientTool(e Engine, p ClientToolResultParams) (*ClientToolResultAck, error) { + door, ok := e.(ClientTooler) + if !ok { + return nil, Errorf(CodeUnknownMethod, "client tools are unavailable") + } + if err := p.Validate(); err != nil { + return nil, err + } + result, err := door.ClientToolResult(p) + return &result, err +} diff --git a/internal/appwire/client_tools_test.go b/internal/appwire/client_tools_test.go new file mode 100644 index 0000000000..d01bed6a61 --- /dev/null +++ b/internal/appwire/client_tools_test.go @@ -0,0 +1,177 @@ +package appwire + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type clientToolsEngine struct { + sendEngine + sets []ClientToolsSetParams + answers []ClientToolResultParams +} + +func (e *clientToolsEngine) SetClientTools(p ClientToolsSetParams) (ClientToolsSetResult, error) { + e.sets = append(e.sets, p) + names := []string{} + for _, tool := range p.Tools { + names = append(names, tool.Name) + } + return ClientToolsSetResult{Agent: p.Agent, Set: p.Set, Tools: names}, nil +} +func (e *clientToolsEngine) ClientToolResult(p ClientToolResultParams) (ClientToolResultAck, error) { + e.answers = append(e.answers, p) + return ClientToolResultAck{Accepted: true}, nil +} + +func validClientToolsParams() ClientToolsSetParams { + return ClientToolsSetParams{Agent: "agent-1", Set: "app.notes", Tools: []session.ClientTool{{Name: "app_note", Description: "Read a note", + InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionRead}}} +} + +// Contract (f): the actual Serve hello advertises only an implemented door. +func TestClientToolsHelloFeature(t *testing.T) { + for _, test := range []struct { + engine Engine + want bool + }{{&sendEngine{}, false}, {&clientToolsEngine{}, true}} { + var out bytes.Buffer + server := NewServer(&out) + if err := server.Serve(context.Background(), strings.NewReader(""), Hello{}, test.engine); err != nil { + t.Fatal(err) + } + var frame struct { + Data Hello `json:"data"` + } + if err := json.Unmarshal(bytes.Split(out.Bytes(), []byte("\n"))[0], &frame); err != nil { + t.Fatal(err) + } + found := false + for _, feature := range frame.Data.Features { + found = found || feature == FeatureClientTools + } + if found != test.want { + t.Fatalf("features=%v want client tools=%v", frame.Data.Features, test.want) + } + } +} + +// Contract (b), (h): both typed methods preserve their declared shapes. +func TestClientToolsTypedMethods(t *testing.T) { + e := &clientToolsEngine{} + raw, _ := json.Marshal(validClientToolsParams()) + result, err := methods["agents.tools_set"].call(e, raw) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(result) + if string(encoded) != `{"agent":"agent-1","set":"app.notes","tools":["app_note"]}` { + t.Fatal(string(encoded)) + } + for _, raw := range []string{`{"call":"ct_1","result":null}`, `{"call":"ct_1","result":[1,true]}`, `{"call":"ct_1","error":"no access"}`} { + result, err := methods["agents.tool_result"].call(e, json.RawMessage(raw)) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(result) + if string(encoded) != `{"accepted":true}` { + t.Fatal(string(encoded)) + } + } + if len(e.sets) != 1 || len(e.answers) != 3 || string(e.answers[0].Result) != "null" { + t.Fatal("typed arguments were lost") + } + if _, err := methods["agents.tools_set"].call(&sendEngine{}, raw); codeOf(err) != CodeUnknownMethod { + t.Fatal(err) + } +} + +// Contract (h): every scalar and per-set limit refuses before the optional engine. +func TestClientToolsValidationRefusals(t *testing.T) { + changes := map[string]func(*ClientToolsSetParams){ + "agent": func(p *ClientToolsSetParams) { p.Agent = "" }, + "set": func(p *ClientToolsSetParams) { p.Set = "Bad set" }, + "set length": func(p *ClientToolsSetParams) { p.Set = strings.Repeat("a", 65) }, + "name": func(p *ClientToolsSetParams) { p.Tools[0].Name = "Bad-name" }, + "name length": func(p *ClientToolsSetParams) { p.Tools[0].Name = strings.Repeat("a", 65) }, + "duplicate": func(p *ClientToolsSetParams) { p.Tools = append(p.Tools, p.Tools[0]) }, + "description empty": func(p *ClientToolsSetParams) { p.Tools[0].Description = "" }, + "description bytes": func(p *ClientToolsSetParams) { + p.Tools[0].Description = strings.Repeat("é", session.ClientToolDescriptionMax) + }, + "schema array": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`[]`) }, + "schema null": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`null`) }, + "schema type": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`{"type":"array"}`) }, + "schema bytes": func(p *ClientToolsSetParams) { + p.Tools[0].InputSchema = json.RawMessage(`{"type":"object","padding":"` + strings.Repeat("x", session.ClientToolSchemaMax) + `"}`) + }, + "category": func(p *ClientToolsSetParams) { p.Tools[0].Category = session.ActionRun }, + "tools": func(p *ClientToolsSetParams) { p.Tools = make([]session.ClientTool, session.ClientToolMaxPerSet+1) }, + "instructions": func(p *ClientToolsSetParams) { + p.Instructions = strings.Repeat("x", session.ClientToolInstructionsMax+1) + }, + "negative deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = -1 }, + "short deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = session.ClientToolMinDeadlineMS - 1 }, + "long deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = session.ClientToolMaxDeadlineMS + 1 }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + p := validClientToolsParams() + change(&p) + raw, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + e := &clientToolsEngine{} + if _, err := methods["agents.tools_set"].call(e, raw); codeOf(err) != CodeBadParams { + t.Fatalf("accepted %s: %v", name, err) + } + if len(e.sets) != 0 { + t.Fatal("invalid set reached the engine") + } + }) + } +} + +// Contract (h): null is a result, while an error must be a nonempty string. +func TestClientToolResultValidation(t *testing.T) { + for _, raw := range []string{`{}`, `{"call":"c","result":1,"error":"bad"}`, `{"call":"c","error":""}`, `{"call":"c","error":null}`, + `{"call":"c","result":1,"error":null}`, `{"call":"c","error":7}`, `{"call":"c","error":"` + strings.Repeat("x", session.ClientToolErrorMax+1) + `"}`} { + e := &clientToolsEngine{} + if _, err := methods["agents.tool_result"].call(e, json.RawMessage(raw)); codeOf(err) != CodeBadParams { + t.Fatalf("accepted %.90s: %v", raw, err) + } + if len(e.answers) > 0 { + t.Fatal("invalid answer reached the engine") + } + } +} + +// Contract (h): the shipped section stays bounded and names the actual limits. +func TestClientToolsManualLimits(t *testing.T) { + raw, err := os.ReadFile("../manual/chat/client-tools.md") + if err != nil { + t.Fatal(err) + } + page := string(raw) + if strings.Count(page, "\n## ") != 1 || len(raw) >= 2000 { + t.Fatal("client tools manual section is not self-contained and bounded") + } + for _, phrase := range []string{ + fmt.Sprintf("%d sets", session.ClientToolMaxSets), fmt.Sprintf("%d tools per set", session.ClientToolMaxPerSet), + fmt.Sprintf("%d tools total", session.ClientToolMaxPerAgent), fmt.Sprintf("%d seconds", session.ClientToolDefaultDeadlineMS/1000), + fmt.Sprintf("%d seconds is the maximum", session.ClientToolMaxDeadlineMS/1000), fmt.Sprintf("%d KiB", session.ClientToolOutputMax/1024), + "outcome unknown", "Child tasks", "connected", + } { + if !strings.Contains(page, phrase) { + t.Errorf("manual omits %q", phrase) + } + } +} diff --git a/internal/appwire/events.go b/internal/appwire/events.go index 84ebe55db1..84388ab23c 100644 --- a/internal/appwire/events.go +++ b/internal/appwire/events.go @@ -6,6 +6,7 @@ import "reflect" // other type is a contract break; the test recorders check every event they // see against this table, and the schema is generated from it. var eventShapes = map[string]reflect.Type{ + EventAgentsToolCall: reflect.TypeFor[AgentsToolCall](), EventPairChanged: reflect.TypeFor[PairResult](), EventChatChanged: reflect.TypeFor[ChatChanged](), EventChatRemoved: reflect.TypeFor[ChatRemoved](), diff --git a/internal/appwire/server.go b/internal/appwire/server.go index cab10176be..32669f483d 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -214,6 +214,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello = withReplySuggestionFeature(hello, engine) hello = withUnreadFeature(hello, engine) hello = withErrandFeature(hello, engine) + hello = withClientToolsFeature(hello, engine) hello = withAsksDrawnFeature(hello, engine) hello = withTaskModelsAuxiliaryFeature(hello, engine) hello = withMemorySettingsFeature(hello, engine) @@ -396,7 +397,9 @@ func (s *Server) handle(ctx context.Context, line []byte, engine Engine, infligh // methods is the dispatch table. shutdown is not in it: it is answered by the // loop itself, because it ends the loop. var methods = map[string]method{ - "ping": typed(func(Engine, struct{}) (*Empty, error) { return &Empty{}, nil }), + "agents.tools_set": typed(setClientTools), + "agents.tool_result": typed(answerClientTool), + "ping": typed(func(Engine, struct{}) (*Empty, error) { return &Empty{}, nil }), "agents.list": typed(func(e Engine, p AgentsListParams) (*AgentsResult, error) { // A STATE AND A QUERY are answered by an engine that keeps them. if t, ok := e.(Tidier); ok { diff --git a/internal/manual/chat/client-tools.md b/internal/manual/chat/client-tools.md new file mode 100644 index 0000000000..944eb3eaca --- /dev/null +++ b/internal/manual/chat/client-tools.md @@ -0,0 +1,33 @@ +# Client tools: tools an app adds to a conversation + +## Client tools: connection, limits and waiting + +An app connected through `codeaf app` can attach its own tools and instructions +to one conversation. The model sees each tool's name, description and input +schema, and codeaf sends its calls to that app for an answer. The app checks the +arguments against its schema. Child tasks, workers and clarification +conversations do not receive these tools or instructions. + +Tools appear only while the app is connected. Attaching them does not open the +conversation or start a turn. Changes take effect at the next model step. +Each conversation can hold 4 sets, with 32 tools per set and 64 tools total. +Replacing a set replaces all its tools and instructions; an empty set removes +only that set. Tool names must be unique within the conversation and cannot +reuse an engine tool's name. + +A conversation held by a collection is kept even before its first message; +it can be reopened by id after a restart. + +Set ids start with a lowercase letter, followed by lowercase letters, digits, +underscores, dots or hyphens, up to 64 characters. Tool names follow the same +rule without dots or hyphens. Descriptions take 1–2048 bytes; each input schema +is a JSON object with `type: "object"`, up to 16 KiB. Instructions take up to +4096 bytes per set. Each tool declares `read`, `edit` or `create`; reading-only +contexts refuse edits and creations. The usual tool approval rules still apply. + +The default wait is 30 seconds. An app can choose 1–120 seconds per set; +120 seconds is the maximum. Stopping the turn ends the wait immediately. +A timeout or a disconnect during a call leaves its outcome unknown. Check what +the app did before trying again: codeaf does not retry that call automatically. +Results are compact JSON, cut at 64 KiB with the dropped-byte count. Error +answers take 1–4096 bytes. diff --git a/internal/session/agent.go b/internal/session/agent.go index d9ea313935..2acfd69468 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -90,6 +90,8 @@ func New(config Config) (*Agent, error) { // parent's account-keyed pool and resolve their own model before their first // request, rather than inheriting whichever adapter the parent last used. func (a *Agent) newChildAgent(config Config) (*Agent, error) { + config.ClientTools = nil + config.System = clientToolsWithoutSections(config.System) if pair, ok := a.ConversationModelPair(); ok && config.ConversationModels == nil { pair.Main = config.Model if config.OneModel || pair.Fallback == pair.Main { diff --git a/internal/session/bashbelt_worker.go b/internal/session/bashbelt_worker.go index e10c177e40..fa469f45f1 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -351,6 +351,7 @@ func (a *Agent) TaskReport() string { return taskReport(a) } // The shared event hub stays asynchronous; only this producer waits, outside // every agent and hub lock. Cancellation also releases a failed event reader. func (a *Agent) sendBeltStep(ctx context.Context, hub *eventHub, event Event) { + event = a.clientToolEvent(event) if !a.config.WaitForBeltSteps { hub.send(event) return diff --git a/internal/session/client_tools.go b/internal/session/client_tools.go new file mode 100644 index 0000000000..55d4c2afbe --- /dev/null +++ b/internal/session/client_tools.go @@ -0,0 +1,331 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +// Client tool limits are shared by the wire validator and the conversation door. +const ( + ClientToolMaxSets = 4 + ClientToolMaxPerSet = 32 + ClientToolMaxPerAgent = 64 + ClientToolDescriptionMax = 2048 + ClientToolSchemaMax = 16 << 10 + ClientToolInstructionsMax = 4096 + ClientToolErrorMax = 4096 + ClientToolOutputMax = 64 << 10 + ClientToolDefaultDeadlineMS = 30000 + ClientToolMinDeadlineMS = 1000 + ClientToolMaxDeadlineMS = 120000 +) + +// ClientToolDoor belongs to one conversation and one connected app. +type ClientToolDoor interface { + // Snapshots are immutable, so an unchanged version costs no copying. + ClientToolSets() (version uint64, sets []ClientToolSet) + CallClientTool(context.Context, ClientToolCall) (ClientToolAnswer, error) +} + +type ClientTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` + Category ActionCategory `json:"category"` +} + +type ClientToolSet struct { + ID string + Instructions string + DeadlineMS int + Tools []ClientTool +} + +type ClientToolCall struct { + Set, Name, TurnCall string + Args json.RawMessage + DeadlineMS int +} + +type ClientToolAnswer struct { + Text string + IsError bool +} + +// clientToolState is protected by armMu, along with the belt it describes. +type clientToolState struct { + version uint64 + seen bool + sets []ClientToolSet + names map[string]ActionCategory + retired map[string]bool + section string +} + +func (c Config) clientToolSets() (uint64, []ClientToolSet) { + if c.ClientTools == nil || c.InTask || c.Errand || c.promptProfile().chat() { + return 0, nil + } + return c.ClientTools.ClientToolSets() +} + +const clientToolHeading = "\n\n## Tools from the app: " +const clientToolSectionEnd = "\n" + +func clientToolsSection(config Config) string { + _, sets := config.clientToolSets() + return clientToolsSectionOf(sets) +} + +func clientToolsSectionOf(sets []ClientToolSet) string { + var out strings.Builder + for _, set := range sets { + out.WriteString(clientToolHeading) + out.WriteString(set.ID) + out.WriteByte('\n') + out.WriteString(set.Instructions) + out.WriteString(clientToolSectionEnd) + } + return out.String() +} + +// Child pages lose the app's instructions as well as the execution door. +func clientToolsWithoutSections(page string) string { + start := strings.Index(page, clientToolHeading) + if start < 0 { + return page + } + // All app sections form one tail. The last renderer-owned end also covers + // delimiter text that an app may have included inside its instructions. + end := strings.LastIndex(page, clientToolSectionEnd) + if end < start { + return page[:start] + } + return page[:start] + page[end+len(clientToolSectionEnd):] +} + +// appendClientTools runs during construction and complete belt rebuilds. +func (a *Agent) appendClientTools(builtins []bare.Tool) []bare.Tool { + a.armMu.Lock() + defer a.armMu.Unlock() + version, sets := a.config.clientToolSets() + a.clientTools.version, a.clientTools.seen = version, true + a.clientTools.sets = sets + a.clientTools.section = clientToolsSectionOf(sets) + return a.clientToolTailLocked(builtins, sets) +} + +func (a *Agent) clientToolTailLocked(builtins []bare.Tool, sets []ClientToolSet) []bare.Tool { + a.noteClientToolBuiltinsLocked(builtins) + for name := range a.clientTools.names { + if a.clientTools.retired == nil { + a.clientTools.retired = make(map[string]bool) + } + a.clientTools.retired[name] = true + } + a.clientTools.names = nil + if len(sets) == 0 { + return builtins + } + tools := make([]bare.Tool, len(builtins), len(builtins)+ClientToolMaxPerAgent) + copy(tools, builtins) + held := make(map[string]bool, len(builtins)) + for _, tool := range builtins { + held[tool.Name] = true + } + a.clientTools.names = make(map[string]ActionCategory) + for _, set := range sets { + for _, spec := range set.Tools { + if held[spec.Name] { + continue + } + held[spec.Name] = true + a.clientTools.names[spec.Name] = spec.Category + delete(a.clientTools.retired, spec.Name) + tools = append(tools, a.clientTool(set, spec)) + } + } + return tools +} + +func (a *Agent) noteClientToolBuiltinsLocked(builtins []bare.Tool) { + observer, ok := a.config.ClientTools.(interface{ ClientToolBuiltins([]string) }) + if !ok { + return + } + names := make([]string, 0, len(builtins)) + for _, tool := range builtins { + names = append(names, tool.Name) + } + for _, tools := range a.shelf { + for _, tool := range tools { + names = append(names, tool.Name) + } + } + observer.ClientToolBuiltins(names) +} + +func (a *Agent) clientToolRetiredNotice(name string) (string, bool) { + a.armMu.Lock() + defer a.armMu.Unlock() + if !a.clientTools.retired[name] { + return "", false + } + return ClientToolUnavailable(name).Text, true +} + +func ClientToolUnavailable(name string) ClientToolAnswer { + return ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s is not connected; nothing was done.", name), IsError: true} +} + +// BoundClientToolOutput reuses the engine's rune-safe cut and spells the loss plainly. +func BoundClientToolOutput(text string) string { + bounded := capBytes(text, ClientToolOutputMax) + if len(text) <= ClientToolOutputMax { + return bounded + } + cut := strings.LastIndex(bounded, "… (") + return bounded[:cut] + fmt.Sprintf("\n[cut: %d bytes dropped]", len(text)-cut) +} + +func (a *Agent) clientTool(set ClientToolSet, spec ClientTool) bare.Tool { + return bare.Tool{Name: spec.Name, Description: spec.Description, Schema: spec.InputSchema, + Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { + var object map[string]json.RawMessage + data := bytes.TrimSpace(args) + if len(data) == 0 || data[0] != '{' || !json.Valid(data) || decodeToolArguments(data, &object) != nil || object == nil { + return rejectToolArguments(ctx, invalidArgumentsPrefix+"expected a JSON object"), true, nil + } + answer, err := a.config.ClientTools.CallClientTool(ctx, ClientToolCall{ + Set: set.ID, Name: spec.Name, Args: args, TurnCall: callIDFrom(ctx), DeadlineMS: set.DeadlineMS, + }) + if err != nil { + return err.Error(), true, nil + } + return answer.Text, answer.IsError, nil + }, + } +} + +// A boundary swaps fresh arrays; existing requests can keep walking their snapshot. +func (a *Agent) clientToolsBoundary() { + if a.config.ClientTools == nil { + return + } + version, sets := a.config.clientToolSets() + a.armMu.Lock() + if a.clientTools.seen && version == a.clientTools.version { + a.armMu.Unlock() + return + } + section := clientToolsSectionOf(sets) + changed := section != a.clientTools.section + builtins, definitions, err := a.clientToolBuiltinsLocked(0) + if err != nil { + a.armMu.Unlock() + return + } + a.tools = a.clientToolTailLocked(builtins, sets) + tail, _ := toolDefinitions(a.tools[len(builtins):]) + a.definitions = append(definitions, tail...) + a.clientTools.version, a.clientTools.seen = version, true + a.clientTools.sets, a.clientTools.section = sets, section + a.armMu.Unlock() + if changed { + a.mu.Lock() + a.rerenderSystemLocked(time.Now()) + a.mu.Unlock() + } +} + +func (a *Agent) clientToolBuiltinsLocked(room int) ([]bare.Tool, []ai.ToolDefinition, error) { + cache := a.definitions + if len(cache) < len(a.tools) { + // Lightweight callers may have no cache, or only a previously armed family's definitions. + var err error + cache, err = toolDefinitions(a.tools) + if err != nil { + return nil, nil, err + } + } + tools := make([]bare.Tool, 0, len(a.tools)+room) + definitions := make([]ai.ToolDefinition, 0, len(a.tools)+room) + for i, tool := range a.tools { + if _, client := a.clientTools.names[tool.Name]; client { + continue + } + tools = append(tools, tool) + definitions = append(definitions, cache[i]) + } + return tools, definitions, nil +} + +func (a *Agent) withoutClientToolsLocked(tools []bare.Tool) []bare.Tool { + if len(a.clientTools.names) == 0 { + return tools + } + builtins := make([]bare.Tool, 0, len(tools)) + for _, tool := range tools { + if _, client := a.clientTools.names[tool.Name]; !client { + builtins = append(builtins, tool) + } + } + return builtins +} + +// ActionCategoryForTool resolves this conversation's declaration before the static floor. +func (a *Agent) ActionCategoryForTool(name string) ActionCategory { + a.armMu.Lock() + category, ok := a.clientTools.names[name] + a.armMu.Unlock() + if ok { + return category + } + return ActionCategoryForTool(name) +} + +func (a *Agent) clientToolCategory(name string) (ActionCategory, bool) { + a.armMu.Lock() + defer a.armMu.Unlock() + category, ok := a.clientTools.names[name] + return category, ok +} + +func (a *Agent) clientToolEvent(event Event) Event { + if category, ok := a.clientToolCategory(event.Tool); ok { + event.Category = category + } + return event +} + +func toolEventCategory(event Event) ActionCategory { + if event.Category != "" { + return event.Category + } + return ActionCategoryForTool(event.Tool) +} + +// ClientToolBuiltinNames includes the live belt and deferred tools for collision checks. +func (a *Agent) ClientToolBuiltinNames() []string { + a.armMu.Lock() + defer a.armMu.Unlock() + var names []string + for _, tool := range a.tools { + if _, client := a.clientTools.names[tool.Name]; !client { + names = append(names, tool.Name) + } + } + for _, tools := range a.shelf { + for _, tool := range tools { + names = append(names, tool.Name) + } + } + return names +} diff --git a/internal/session/client_tools_test.go b/internal/session/client_tools_test.go new file mode 100644 index 0000000000..6b4f7fdb52 --- /dev/null +++ b/internal/session/client_tools_test.go @@ -0,0 +1,380 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +type clientToolsFakeDoor struct { + version uint64 + sets []ClientToolSet + calls []ClientToolCall + answer ClientToolAnswer +} + +func (d *clientToolsFakeDoor) ClientToolSets() (uint64, []ClientToolSet) { return d.version, d.sets } +func (d *clientToolsFakeDoor) CallClientTool(_ context.Context, call ClientToolCall) (ClientToolAnswer, error) { + d.calls = append(d.calls, call) + return d.answer, nil +} + +func clientToolsFixture() *clientToolsFakeDoor { + return &clientToolsFakeDoor{version: 1, sets: []ClientToolSet{{ID: "notes", Instructions: "Use the app's notes.", DeadlineMS: ClientToolDefaultDeadlineMS, + Tools: []ClientTool{{Name: "app_note", Description: "Read a note", InputSchema: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`), Category: ActionRead}}}}} +} + +// Contract (a), (e): definitions and instructions follow only this conversation's sets. +func TestClientToolsTailInstructionsAndRemoval(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door, profile: profileFull}} + builtins := []bare.Tool{{Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}} + a.tools = a.appendClientTools(builtins) + a.definitions, _ = toolDefinitions(a.tools) + defs := a.beltDefinitions() + if len(defs) != 2 || a.tools[1].Name != "app_note" { + t.Fatalf("client tool is not the tail: %+v", a.tools) + } + encoded, _ := json.Marshal(defs[1]) + for _, word := range []string{"app_note", "Read a note", "properties", "id"} { + if !strings.Contains(string(encoded), word) { + t.Errorf("definition lost %q: %s", word, encoded) + } + } + page := renderSystem(a.config) + if !strings.Contains(page, "## Tools from the app: notes\nUse the app's notes.") { + t.Fatal("instructions absent") + } + if strings.Contains(renderSystem(Config{}), "Tools from the app") { + t.Fatal("ordinary prompt gained client bytes") + } + if got := testing.AllocsPerRun(10, a.clientToolsBoundary); got != 0 { + t.Fatalf("unchanged boundary allocated %v", got) + } + door.version++ + door.sets = nil + a.clientToolsBoundary() + if len(a.beltTools()) != 1 || len(defs) != 2 { + t.Fatal("removal changed an old reader or kept the tool") + } + if strings.Contains(renderSystem(a.config), "Tools from the app") { + t.Fatal("removed instructions remain") + } +} + +// Contract (b), (h): object arguments only, errors and provider ids cross the door. +func TestClientToolsExecuteArgumentsAndError(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + tool := a.appendClientTools(nil)[0] + for _, args := range []string{"null", "[]", "1", `"x"`, `"{\"id\":3}"`, "{"} { + text, failed, err := tool.Execute(context.Background(), json.RawMessage(args)) + if !failed || err != nil || !strings.Contains(text, "Invalid arguments") { + t.Fatalf("%s: %q %v %v", args, text, failed, err) + } + } + if len(door.calls) != 0 { + t.Fatal("non-object reached the client") + } + door.answer = ClientToolAnswer{Text: "notes could not do app_note: unavailable", IsError: true} + text, failed, err := tool.Execute(withCallID(context.Background(), "provider-7"), json.RawMessage(`{}`)) + if err != nil || !failed || text != door.answer.Text || door.calls[0].TurnCall != "provider-7" { + t.Fatalf("answer/id lost: %q %+v", text, door.calls) + } +} + +// Contract (h): classification is per conversation and client tools never start early. +func TestClientToolsCategoriesAndBuiltinWins(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + a.tools = a.appendClientTools(nil) + a.definitions, _ = toolDefinitions(a.tools) + if a.ActionCategoryForTool("app_note") != ActionRead || (&Agent{}).ActionCategoryForTool("app_note") != ActionWork { + t.Fatal("category escaped the conversation") + } + otherDoor := clientToolsFixture() + otherDoor.sets[0].Tools[0].Category = ActionCreate + other := &Agent{config: Config{ClientTools: otherDoor}} + other.tools = other.appendClientTools(nil) + if other.ActionCategoryForTool("app_note") != ActionCreate || a.ActionCategoryForTool("app_note") != ActionRead { + t.Fatal("two conversations shared a category declaration") + } + if earlyTools["app_note"] { + t.Fatal("client tool can start speculatively") + } + old := a.beltTools() + if _, err := a.armFamily([]bare.Tool{{Name: "app_note", Description: "Built-in", Schema: json.RawMessage(`{"type":"object"}`)}}); err != nil { + t.Fatal(err) + } + if len(a.tools) != 1 || a.tools[0].Description != "Built-in" || old[0].Description == "Built-in" { + t.Fatal("late built-in did not win with fresh arrays") + } +} + +// Contract (a), (h): newly armed built-ins keep their prefix ahead of the app tail. +func TestClientToolsLateBuiltinPrecedesTail(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + a.tools = a.appendClientTools([]bare.Tool{{Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}}) + a.definitions, _ = toolDefinitions(a.tools) + oldTools, oldDefinitions := a.beltTools(), a.beltDefinitions() + if _, err := a.armFamily([]bare.Tool{{Name: "new_builtin", Schema: json.RawMessage(`{"type":"object"}`)}}); err != nil { + t.Fatal(err) + } + tools := a.beltTools() + if len(tools) != 3 || tools[0].Name != "read" || tools[1].Name != "new_builtin" || tools[2].Name != "app_note" { + t.Fatalf("belt order: %+v", tools) + } + if len(oldTools) != 2 || oldTools[1].Name != "app_note" || len(oldDefinitions) != 2 { + t.Fatal("arming rewrote an old request") + } +} + +// Contract (a), (h): automation looks arm a family without a complete definition cache. +func TestClientToolsArmFamilyWithMissingDefinitions(t *testing.T) { + for _, withClient := range []bool{false, true} { + for _, cached := range []string{"nil", "short prefix", "short tail"} { + t.Run(fmt.Sprintf("client=%t/cache=%s", withClient, cached), func(t *testing.T) { + a := &Agent{} + a.tools = []bare.Tool{ + {Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}, + {Name: "bash", Schema: json.RawMessage(`{"type":"object"}`)}, + } + if withClient { + a.config.ClientTools = clientToolsFixture() + a.tools = a.appendClientTools(a.tools) + } + if cached != "nil" { + from := a.tools[:1] + if cached == "short tail" { + from = a.tools[len(a.tools)-1:] + } + var err error + a.definitions, err = toolDefinitions(from) + if err != nil { + t.Fatal(err) + } + } + cachedCount := len(a.definitions) + oldTools, oldDefinitions := a.beltTools(), a.beltDefinitions() + names, err := a.armFamily([]bare.Tool{{Name: "account_read", Schema: json.RawMessage(`{"type":"object"}`)}}) + if err != nil || len(names) != 1 || names[0] != "account_read" { + t.Fatalf("arming: %v, %v", names, err) + } + want := []string{"read", "bash", "account_read"} + if withClient { + want = append(want, "app_note") + } + tools, definitions := a.beltTools(), a.beltDefinitions() + if len(tools) != len(want) || len(definitions) != len(want) { + t.Fatalf("belt/cache lengths: %d/%d, want %d", len(tools), len(definitions), len(want)) + } + for i, name := range want { + if tools[i].Name != name || definitions[i].Function.Name != name { + t.Fatalf("belt/cache at %d: %s/%s, want %s", i, tools[i].Name, definitions[i].Function.Name, name) + } + } + if len(oldDefinitions) != cachedCount || oldTools[1].Name != "bash" || (withClient && oldTools[2].Name != "app_note") { + t.Fatal("arming changed an earlier snapshot") + } + }) + } + } +} + +// Contract (a), (b): the ordinary provider loop offers definitions and consumes the answer. +func TestClientToolsModelRequestAndToolResult(t *testing.T) { + for _, failed := range []bool{false, true} { + t.Run(map[bool]string{false: "result", true: "error"}[failed], func(t *testing.T) { + door := clientToolsFixture() + door.answer = ClientToolAnswer{Text: `{"note":"found"}`, IsError: failed} + if failed { + door.answer.Text = "notes could not do app_note: unavailable" + } + recorder := &blockRecorder{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("provider-7", "app_note", `{"id":"n"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + a, _ := newTestAgent(t, recorder, func(c *Config) { c.System = ""; c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + events, err := a.Submit(ctx, "read the app note") + if err != nil { + t.Fatal(err) + } + collected := collect(t, events) + if len(door.calls) != 1 || door.calls[0].TurnCall != "provider-7" { + t.Fatalf("dispatches: %+v", door.calls) + } + if got := recorder.resultAt(t, 1, "provider-7"); got != door.answer.Text { + t.Fatalf("model result: %q", got) + } + recorder.mu.Lock() + block := recorder.blocks[0] + page := messageText(recorder.sent[0][0]) + recorder.mu.Unlock() + definition, _ := json.Marshal(block[len(block)-1]) + if !strings.Contains(string(definition), "app_note") || !strings.Contains(page, "## Tools from the app: notes") { + t.Fatalf("request lost its tail/page: %s", definition) + } + ended := false + for _, event := range collected { + if event.CallID != "provider-7" { + continue + } + if event.Kind == EventToolBegin && toolEventCategory(event) != ActionRead { + t.Fatal("step lost the declared category") + } + if event.Kind == EventToolEnd || event.Kind == EventToolFailed { + ended = true + if (event.Kind == EventToolFailed) != failed { + t.Fatalf("error flag lost: %+v", event) + } + } + } + if !ended { + t.Fatal("tool result lifecycle absent") + } + }) + } +} + +// Contract (e): instructions rerender only on this agent's own page at a boundary. +func TestClientToolsBoundaryRefreshesOwnedPageAndPreservesCallerPage(t *testing.T) { + for _, supplied := range []string{"", "The caller's page"} { + door := clientToolsFixture() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.System = supplied; c.ClientTools = door }) + door.version++ + door.sets = []ClientToolSet{{ID: "notes", Instructions: "Read the updated notes.", Tools: door.sets[0].Tools}} + a.clientToolsBoundary() + if supplied != "" && a.system != supplied { + t.Fatal("the caller's page was rewritten") + } + if supplied == "" && !strings.Contains(a.system, "Read the updated notes.") { + t.Fatal("instruction change did not rerender") + } + door.version++ + door.sets = nil + a.clientToolsBoundary() + if strings.Contains(a.system, "Tools from the app") { + t.Fatal("disconnected page kept instructions") + } + if notice, ok := a.clientToolRetiredNotice("app_note"); !ok || notice != ClientToolUnavailable("app_note").Text { + t.Fatal("stale call lost the unavailable answer") + } + } +} + +// Contract (h): declared reads alone can cross a reading-only context. +func TestClientToolsReadingCategoriesAndApprovalPolicy(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door, taskControl: &TaskNode{spec: taskSpec{quick: &quickTaskSpec{reading: true}}}}} + a.tools = a.appendClientTools(nil) + if a.readingToolRefusal("app_note") != "" { + t.Fatal("declared read refused") + } + for _, category := range []ActionCategory{ActionEdit, ActionCreate} { + door.sets[0].Tools[0].Category = category + a.tools = a.appendClientTools(nil) + if a.readingToolRefusal("app_note") == "" { + t.Fatalf("reading allowed %s", category) + } + } + for _, action := range []approval.Action{approval.ActionAllow, approval.ActionPrompt, approval.ActionDeny} { + a.config.ApprovalPolicy = &approval.Policy{Default: action} + for _, name := range []string{"app_note", "factory_add", "change_setting"} { + decision, _ := a.decide(ai.ToolCall{Function: ai.ToolCallFunction{Name: name, Arguments: `{}`}}) + if decision.Action != action { + t.Fatalf("%s changed the ordinary policy: %+v", name, decision) + } + } + } +} + +// Contract (a), (h): real children and clarification conversations carry no client capability. +func TestClientToolsDoNotTravelToChildren(t *testing.T) { + door := clientToolsFixture() + door.sets[0].Instructions += clientToolSectionEnd + "\nThese are still the app's instructions." + page := "The caller's page" + clientToolsSection(Config{ClientTools: door}) + "\nFooter" + if got := clientToolsWithoutSections(page); got != "The caller's page\nFooter" { + t.Fatalf("child page: %q", got) + } + for _, config := range []Config{{ClientTools: door, InTask: true}, {ClientTools: door, Errand: true}} { + _, sets := config.clientToolSets() + if len(sets) != 0 || clientToolsSection(config) != "" { + t.Fatal("child inherited a set") + } + } + started := make(chan struct{}) + parent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("original", "write", `{"path":"note.txt","content":"note"}`), nil + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }, + }}, func(c *Config) { + c.System, c.PromptProfile = "", "full" + c.ClientTools = door + c.AskConsent, c.Interactive = true, true + c.ApprovalPolicy = promptAll() + }) + if parent.config.ClientTools == nil || !strings.Contains(parent.system, "Tools from the app: notes") { + t.Fatal("parent lacks the capability this test must exclude") + } + cfg := parent.config + cfg.System = parent.system + cfg.SessionFile, cfg.Place = "", Place{} + cfg.InTask = true + child, err := parent.newChildAgent(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = child.Close() }) + clientToolsAssertExcluded(t, child) + + watch, stop := parent.WatchQuestions() + defer stop() + mustSubmit(t, parent, "write note.txt") + q := discussionWait(t, watch, func(e Event) bool { return e.Kind == EventQuestion }).Question + if err := parent.ResolveQuestion(Answer{Kind: q.Kind, ID: q.ID, Clarify: true, AskedBack: []Exchange{{Asked: "why this note?"}}}); err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("clarification did not start") + } + parent.mu.Lock() + clarification := parent.discussions["clarify-1"].child + parent.mu.Unlock() + clientToolsAssertExcluded(t, clarification) +} + +func clientToolsAssertExcluded(t *testing.T, child *Agent) { + t.Helper() + if child.config.ClientTools != nil { + t.Fatal("child inherited the client door") + } + for _, page := range []string{child.config.System, child.system} { + if strings.Contains(page, "Tools from the app") || strings.Contains(page, "These are still the app's instructions.") { + t.Fatal("child inherited app instructions") + } + } + for _, definition := range child.beltDefinitions() { + if definition.Function.Name == "app_note" { + t.Fatal("child offers a client tool") + } + } +} diff --git a/internal/session/connect.go b/internal/session/connect.go index a900a1f1ab..58b814e1d6 100644 --- a/internal/session/connect.go +++ b/internal/session/connect.go @@ -563,7 +563,9 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { held := make(map[string]bool, len(a.tools)) for _, tool := range a.tools { - held[tool.Name] = true + if _, client := a.clientTools.names[tool.Name]; !client { + held[tool.Name] = true + } } var arriving []bare.Tool var names []string @@ -573,6 +575,7 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { } arriving = append(arriving, tool) names = append(names, tool.Name) + held[tool.Name] = true } if len(arriving) == 0 { return nil, nil @@ -584,12 +587,14 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { // Fresh arrays, exact length: the old ones are still being walked by // whatever took a snapshot a moment ago, and appending into spare capacity // would write into the array they are reading. - grownTools := make([]bare.Tool, 0, len(a.tools)+len(arriving)) - grownTools = append(append(grownTools, a.tools...), arriving...) - grownDefinitions := make([]ai.ToolDefinition, 0, len(a.definitions)+len(definitions)) - grownDefinitions = append(append(grownDefinitions, a.definitions...), definitions...) - a.tools = grownTools - a.definitions = grownDefinitions + builtins, builtinDefinitions, err := a.clientToolBuiltinsLocked(len(arriving)) + if err != nil { + return nil, err + } + builtins = append(builtins, arriving...) + a.tools = a.clientToolTailLocked(builtins, a.clientTools.sets) + tail, _ := toolDefinitions(a.tools[len(builtins):]) + a.definitions = append(append(builtinDefinitions, definitions...), tail...) for _, tool := range arriving { known := false for _, kept := range a.profileArmed { diff --git a/internal/session/inherit.go b/internal/session/inherit.go index b4fa6f16ca..4128971588 100644 --- a/internal/session/inherit.go +++ b/internal/session/inherit.go @@ -387,6 +387,7 @@ func adoptSeed(child *Agent, quick *quickTaskSpec) { // carries a System gets at construction (agent.go), said here because this // page arrives after the agent was built. child.system = promotedSystem(workstreamWithoutPage(quick.system)) + child.system = clientToolsWithoutSections(child.system) child.systemOwn = false messages[0] = textMessage("system", child.system) child.messages = messages diff --git a/internal/session/loop.go b/internal/session/loop.go index e59816da6e..e29cc2a8bd 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -3547,7 +3547,7 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa if service != "" { serviceName = a.serviceName(service) } - hub.send(Event{ + hub.send(a.clientToolEvent(Event{ Kind: EventToolBegin, Tool: call.Function.Name, Hint: a.gloss(call), @@ -3556,7 +3556,7 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa Batch: batch, Service: service, ServiceName: serviceName, - }) + })) } // THE NARRATOR ARMS BESIDE THE WORK, not after a long silence. A half-second @@ -3948,6 +3948,9 @@ func (a *Agent) dispatchTool(ctx context.Context, ep *episode, hub *eventHub, ca if notice, retired := a.teamRetiredNotice(call.Function.Name); retired { return toolResult{text: notice, isError: true, harness: true} } + if notice, retired := a.clientToolRetiredNotice(call.Function.Name); retired { + return toolResult{text: notice, isError: true, harness: true} + } // A name nobody ever had keeps the old answer, and keeps it word for word: // that one IS a sentence about the model. return toolResult{text: "Unknown tool: " + call.Function.Name, isError: true} diff --git a/internal/session/prompt.go b/internal/session/prompt.go index ef40bedc69..0295e27912 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -401,6 +401,7 @@ func renderSystemAt(config Config, now time.Time) string { out.WriteString(workstreamSection(config)) out.WriteString(workerFooter(config, now)) + out.WriteString(clientToolsSection(config)) return out.String() } diff --git a/internal/session/promptprofile_live.go b/internal/session/promptprofile_live.go index 4a1d55e675..a2abd43f3b 100644 --- a/internal/session/promptprofile_live.go +++ b/internal/session/promptprofile_live.go @@ -52,6 +52,7 @@ func (a *Agent) preparePromptProfile(model string) error { state.current.Store(next) tools := a.belt() a.armMu.Lock() + tools = a.withoutClientToolsLocked(tools) // Explicitly loaded capabilities and connected services remain usable. // Only the default belt is repartitioned when the window changes. held := make(map[string]bool, len(tools)) @@ -80,6 +81,11 @@ func (a *Agent) preparePromptProfile(model string) error { held[tool.Name] = true } } + if !next.chat() { + tools = a.clientToolTailLocked(tools, a.clientTools.sets) + } else { + a.clientTools.names = nil + } definitions, err := toolDefinitions(tools) if err != nil { a.shelf, a.shelfOrder, a.prearm = oldShelf, oldOrder, oldPrearm diff --git a/internal/session/questionconversation.go b/internal/session/questionconversation.go index a5aa7305d6..0533a7e9ef 100644 --- a/internal/session/questionconversation.go +++ b/internal/session/questionconversation.go @@ -97,6 +97,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { a.mu.Lock() cfg := a.config cfg.Workstream = nil + cfg.ClientTools = nil cfg.Model = a.model cfg.SessionFile, cfg.Place = "", Place{} cfg.rootSession = a.id @@ -109,6 +110,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { cfg.ApprovalPolicy = a.config.ApprovalPolicy } cfg.System = workstreamWithoutPage(a.system) + cfg.System = clientToolsWithoutSections(cfg.System) if a.guardianOverride != nil { cfg.Guardian = *a.guardianOverride } diff --git a/internal/session/session.go b/internal/session/session.go index 7222ef353f..393e328ff0 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -852,6 +852,8 @@ type Event struct { // a surface reads as "unknown" and never as "nothing happened". Compaction *CompactionFact `json:"Compaction,omitempty"` + // Tool lifecycle events can also carry the conversation's declared client-tool + // category, so surfaces need no name-only guess for those dynamic tools. // Category is the FAMILY OF WORK an EventCaption's sentence is about — one // word from the closed list in actioncategory.go — and it is zero on every // other kind. @@ -2526,6 +2528,9 @@ type Config struct { // Workstream is set by the app only on a workstream's own agent. Nil is no door. Workstream WorkstreamDoor + // ClientTools is the door onto tools an app attached to this conversation. Nil is no door. + ClientTools ClientToolDoor + // 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 @@ -2908,21 +2913,20 @@ type Agent struct { systemAt time.Time systemOwn bool // tools is the belt and definitions is its wire form, built once at - // construction — rebuilding them per step would re-marshal every schema on - // the hot path — and thereafter grown only at the tail under armMu - // (connect.go), with one exception: the team verbs leave when the role that - // gave them does (team.go's [Agent.retireTeamTools]). + // construction. Built-ins grow under armMu (connect.go), before the replaceable + // client-tool tail. Team verbs leave when their role ends (team.go). // // Both are COPY-ON-WRITE: arming allocates a new array and swaps the header, // so a reader that took a snapshot under armMu may walk it without the lock // and can never see a half-written slice. Nothing already in either is ever - // moved or rewritten, and nothing but a team verb is removed, because the - // definition block rides at the front of every request and a definition that - // shifts re-bills the whole prompt behind it (internal/exec's tools.go states - // the law). + // moved or rewritten in place. Built-ins keep their order while client sets + // can be replaced. The definition block rides at the front of every request, + // so a definition that shifts re-bills the whole prompt behind it + // (internal/exec's tools.go states the law). presentation *presentationIndex tools []bare.Tool definitions []ai.ToolDefinition + clientTools clientToolState // served is what the belt cannot say about the tools an ACCOUNT named // rather than this build (served.go): whose account each one is, what the // account calls it, and which capability governs it. Keyed by the name the diff --git a/internal/session/sweep.go b/internal/session/sweep.go index 6ffd199a76..eaea595da6 100644 --- a/internal/session/sweep.go +++ b/internal/session/sweep.go @@ -92,18 +92,19 @@ const ( // an errand's folder is still somebody's — an automation was saved from it — // and may be nil. func SweepHome(errandsRoot string, held func(dir string) bool, note func(string)) { - SweepHomeContext(context.Background(), errandsRoot, held, note) + SweepHomeContext(context.Background(), errandsRoot, held, nil, note) } // SweepHomeContext is SweepHome with cancellation. Cancellation is checked // before resolving the home so a stopped launch cannot target a home selected -// after it started. -func SweepHomeContext(ctx context.Context, errandsRoot string, held func(dir string) bool, note func(string)) { +// after it started. A non-nil keep preserves project conversations retained by +// the caller; held independently preserves errands saved by automations. +func SweepHomeContext(ctx context.Context, errandsRoot string, held, keep func(dir string) bool, note func(string)) { if contextDone(ctx) { return } now := time.Now() - SweepPlacesContext(ctx, home.Join("v3", placesDirName), now, note) + SweepPlacesContext(ctx, home.Join("v3", placesDirName), now, keep, note) if contextDone(ctx) { return } @@ -115,12 +116,13 @@ func SweepHomeContext(ctx context.Context, errandsRoot string, held func(dir str // A root that is not there is not a failure — it is a machine that has not held // a conversation yet — and answers silently. func SweepPlaces(root string, now time.Time, note func(string)) { - SweepPlacesContext(context.Background(), root, now, note) + SweepPlacesContext(context.Background(), root, now, nil, note) } // SweepPlacesContext is SweepPlaces with cancellation between entries and -// immediately before each destructive operation. -func SweepPlacesContext(ctx context.Context, root string, now time.Time, note func(string)) { +// immediately before each destructive operation. A non-nil keep can retain a +// conversation that would otherwise be litter, without preventing log expiry. +func SweepPlacesContext(ctx context.Context, root string, now time.Time, keep func(dir string) bool, note func(string)) { if contextDone(ctx) { return } @@ -155,7 +157,7 @@ func SweepPlacesContext(ctx context.Context, root string, now time.Time, note fu if !entry.IsDir() { continue } - sweepSession(ctx, filepath.Join(path, entry.Name()), now, note) + sweepSession(ctx, filepath.Join(path, entry.Name()), now, keep, note) } } } @@ -163,7 +165,7 @@ func SweepPlacesContext(ctx context.Context, root string, now time.Time, note fu // sweepSession is the three rules over one session folder, in the order that // makes rule 3 unconditional: the live check first, and nothing at all happens // to a folder that fails it. -func sweepSession(ctx context.Context, dir string, now time.Time, note func(string)) { +func sweepSession(ctx context.Context, dir string, now time.Time, keep func(dir string) bool, note func(string)) { if contextDone(ctx) || sessionIsOpen(dir) { // Somebody is talking to it. Not its logs, not its folder, nothing. return @@ -185,6 +187,9 @@ func sweepSession(ctx context.Context, dir string, now time.Time, note func(stri retireCheckpointForks(ctx, dir, true, note) return } + if keep != nil && keep(dir) { + return + } reapSession(ctx, dir, meta, note) } diff --git a/internal/session/sweep_test.go b/internal/session/sweep_test.go index a5287ee018..14b150e8cc 100644 --- a/internal/session/sweep_test.go +++ b/internal/session/sweep_test.go @@ -543,7 +543,7 @@ func TestSweepHomeContextCancelledBeforeStartTouchesNothing(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - SweepHomeContext(ctx, errandsRoot, nil, func(line string) { t.Fatalf("cancelled sweep said %q", line) }) + SweepHomeContext(ctx, errandsRoot, nil, nil, func(line string) { t.Fatalf("cancelled sweep said %q", line) }) for _, kept := range []string{place, staleLog, exchange} { if _, err := os.Stat(kept); err != nil { @@ -564,7 +564,7 @@ func TestSweepPlacesContextCancellationStopsLaterDestructiveOperations(t *testin first, firstTree := newSweptUniverseSession(t, root, "1111111111111111", repo, now) second, secondTree := newSweptUniverseSession(t, root, "2222222222222222", repo, now) ctx, cancel := context.WithCancel(context.Background()) - SweepPlacesContext(ctx, root, now, func(line string) { + SweepPlacesContext(ctx, root, now, nil, func(line string) { if strings.Contains(line, firstTree.universe) { cancel() } diff --git a/internal/session/task_app_steps.go b/internal/session/task_app_steps.go index d1fbf05064..80e1fc2aa9 100644 --- a/internal/session/task_app_steps.go +++ b/internal/session/task_app_steps.go @@ -248,7 +248,7 @@ func (l *taskLive) openStep(event Event, at time.Time) { ID: appLiveStepID(event, len(l.calls)), Call: event.CallID, Tool: strings.TrimSpace(event.Tool), - Category: ActionCategoryForTool(event.Tool), + Category: toolEventCategory(event), Target: appStepText(event.Hint), Args: event.Args, Running: true, diff --git a/internal/session/task_quick_files.go b/internal/session/task_quick_files.go index b7174a0bb1..fc56495bf8 100644 --- a/internal/session/task_quick_files.go +++ b/internal/session/task_quick_files.go @@ -108,6 +108,9 @@ func (a *Agent) executeTaskTool(ctx context.Context, tool bare.Tool, call ai.Too // readingToolRefusal also guards calls named off the belt. The names come from // the same set that arms the hand-off, so its promise cannot drift from it. func (a *Agent) readingToolRefusal(name string) string { + if category, client := a.clientToolCategory(name); client && category == ActionRead { + return "" + } if !a.config.readingTask() || earlyTools[name] { return "" } diff --git a/internal/session/team.go b/internal/session/team.go index 35ce4313b0..027fbc340d 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -488,6 +488,7 @@ func memberOf(team teams.Team, keys []string) (teams.Member, bool) { // and a demotion is rare enough that paying once for it is cheap. A role // gained again appends its verbs at the tail as before. func (a *Agent) teamBoundary() string { + a.clientToolsBoundary() profile := a.config.teamProfile() if profile == "" { return "" diff --git a/internal/session/tools.go b/internal/session/tools.go index e6820913ab..d9958b616e 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -335,7 +335,7 @@ func (a *Agent) belt() []bare.Tool { // (tools_capabilities.go). It removes no capability and adds none: a machine // with no media models still has no media group, and a shelf with nothing on // it puts no verb on this belt at all. - return a.shelveDeferred(tools) + return a.appendClientTools(a.shelveDeferred(tools)) } // toolDefinitions builds the wire form of the belt, carrying each tool's From fe68c7d5c80aee87d60468dc81bf897b8a203c69 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 18:47:21 -0500 Subject: [PATCH 18/20] app: the engine keeps only the generic pieces of the Workstreams plan (schema 7) (#2336) * collections: schema 7 keeps every collection and Factory job ref, drops development tables The collections store now writes schema 7: collections and memberships in the generic shape (factory_job refs with their factory, five-column UNIQUE), and nothing else. Versions 1-7 are read as they stand; the first write migrates inside its own transaction. Schemas 1 and 2 are copied into the generic tables; schemas 3-6, written only by development builds, lose their extra tables, indexes and triggers while every collection, membership and ref stays. A failed first write leaves the file as it was; 8+ is refused. Co-Authored-By: Claude Opus 5.5 * app: the engine keeps only the generic pieces of the Workstreams plan Workstreams move into the app, so the engine drops everything that existed only for them: the workstream store and page renderer, the wire methods and hello features, the agent's built-in tools and prompt section, the morning wake scheduler and its replay purpose, the conformance fixtures, and the old launch-cleanup hook. Removing the agent door also removes the copy of it that Fresh and Open carried. What stays is generic: collections hold Factory jobs (the resolver moves to appcollections_factory.go under generic names, with its own tests), client tools, and collection-held conversations that survive cleanup. Co-Authored-By: Claude Opus 5.5 * docs: protocol and manual keep Factory refs and client tools; workstream entries go The protocol and the collections manual lose the workstream methods, events, features and schemas, and keep the factory_job and client-tools sections. The unreleased workstream entries described features that will not ship from the engine; one entry for the generic pieces replaces them and the client-tools entry. The CI-timeout entry no longer mentions the deleted conformance case. Co-Authored-By: Claude Opus 5.5 * docs: one change entry for the generic collections, client tools and kept conversations Co-Authored-By: Claude Opus 5.5 * docs: the generic-pieces entry is written fresh, so the gate sees it as added The first version reused the client-tools entry's wording, so git paired the two files as a rename and the change-entry gate found no added entry. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf-replay/request.go | 3 - .../workstream_wake_fix2_test.go | 13 - cmd/codeaf/app.go | 9 +- cmd/codeaf/appcollections.go | 28 +- cmd/codeaf/appcollections_factory.go | 246 +++ cmd/codeaf/appcollections_factory_test.go | 319 +++ cmd/codeaf/appworkstreams.go | 1117 ---------- cmd/codeaf/appworkstreams_agent.go | 374 ---- cmd/codeaf/appworkstreams_agent_brief_test.go | 122 -- cmd/codeaf/appworkstreams_agent_done_test.go | 59 - cmd/codeaf/appworkstreams_agent_sources.go | 188 -- .../appworkstreams_agent_sources_test.go | 311 --- cmd/codeaf/appworkstreams_agent_test.go | 583 ------ cmd/codeaf/appworkstreams_conformance_test.go | 1562 -------------- cmd/codeaf/appworkstreams_fields_v02_test.go | 308 --- cmd/codeaf/appworkstreams_pages_test.go | 192 -- .../appworkstreams_source_open_other.go | 30 - cmd/codeaf/appworkstreams_source_open_unix.go | 21 - .../appworkstreams_source_open_unix_test.go | 52 - cmd/codeaf/appworkstreams_sources.go | 51 - cmd/codeaf/appworkstreams_sources_test.go | 514 ----- cmd/codeaf/appworkstreams_suggestions_test.go | 323 --- cmd/codeaf/appworkstreams_test.go | 1338 ------------ cmd/codeaf/appworkstreams_wake.go | 369 ---- cmd/codeaf/appworkstreams_wake_test.go | 452 ---- cmd/codeaf/chatv3_layout.go | 4 +- cmd/codeaf/chatv3_layout_stab_test.go | 85 - cmd/codeaf/collections.go | 6 +- cmd/codeaf/engine.go | 1 - cmd/codeaf/testdata/model_spend_owners.tsv | 1 - .../testdata/workstreams-wire/README.md | 10 - .../workstreams-wire/collections.add.json | 42 - .../testdata/workstreams-wire/contract.json | 1865 ----------------- .../testdata/workstreams-wire/extract.py | 103 - .../workstreams-wire/goahead.grant.json | 39 - .../workstreams-wire/goahead.list.json | 36 - .../workstreams-wire/goahead.revoke.json | 35 - .../workstreams-wire/page.create.json | 59 - .../workstreams-wire/page.delete.json | 25 - .../testdata/workstreams-wire/page.get.json | 60 - .../workstreams-wire/page.history.json | 45 - .../testdata/workstreams-wire/page.list.json | 69 - .../testdata/workstreams-wire/page.move.json | 55 - .../workstreams-wire/page.restore.json | 62 - .../testdata/workstreams-wire/page.save.json | 93 - .../workstreams-wire/page.update.json | 37 - .../workstreams-wire/record.drop.json | 39 - .../workstreams-wire/record.keep.json | 67 - .../workstreams-wire/record.list.json | 70 - .../workstreams-wire/record.propose.json | 88 - .../workstreams-wire/record.supersede.json | 66 - .../testdata/workstreams-wire/source.add.json | 59 - .../workstreams-wire/source.list.json | 73 - .../workstreams-wire/source.remove.json | 34 - .../workstreams-wire/source.update.json | 37 - .../workstreams-wire/suggestion.drop.json | 111 - .../workstreams-wire/suggestion.keep.json | 203 -- .../workstreams-wire/suggestion.list.json | 65 - .../testdata/workstreams-wire/wake.apply.json | 50 - .../testdata/workstreams-wire/wake.list.json | 67 - .../workstreams-wire/wake.not_now.json | 46 - .../testdata/workstreams-wire/wake.undo.json | 51 - .../workstreams-wire/workstream.changed.json | 24 - .../workstreams-wire/workstream.create.json | 63 - .../workstreams-wire/workstream.get.json | 69 - .../workstreams-wire/workstream.list.json | 51 - .../workstream.mark_done.json | 47 - .../workstreams-wire/workstream.plan_get.json | 28 - .../workstreams-wire/workstream.plan_set.json | 103 - .../workstreams-wire/workstream.reopen.json | 43 - .../workstreams-wire/workstream.update.json | 52 - .../2263-app-workstreams-contract.md | 10 - .../unreleased/2270-app-workstreams-store.md | 27 - .../2277-app-workstream-page-markdown.md | 9 - .../2288-workstreams-wire-conformance.md | 7 - .../2289-app-workstream-agent-tools.md | 25 - ...90-app-workstream-page-versions-restore.md | 24 - .../2296-codeaf-leg-finishes-in-ci.md | 2 +- .../2297-app-workstreams-wire-v02.md | 24 - .../2303-workstreams-stability-fixes.md | 13 - .../2310-workstreams-size-limits.md | 13 - .../2318-app-workstream-suggestions.md | 23 - .../2319-workstreams-watching-agent.md | 23 - .../unreleased/2320-app-workstream-sources.md | 28 - docs/changes/unreleased/2334-client-tools.md | 24 - ...pp-generic-collections-and-client-tools.md | 29 + docs/design/app-v3/PROTOCOL.md | 128 +- internal/appwire/collections.go | 7 +- .../appwire/collections_factory_job_test.go | 6 - internal/appwire/server.go | 1 - internal/appwire/workstreams.go | 748 ------- internal/appwire/workstreams_check.go | 450 ---- internal/appwire/workstreams_check_test.go | 213 -- .../appwire/workstreams_fields_v02_test.go | 84 - internal/appwire/workstreams_sources.go | 146 -- internal/appwire/workstreams_sources_test.go | 63 - internal/appwire/workstreams_test.go | 296 --- internal/manual/chat/collections.md | 236 +-- internal/session/actioncategory.go | 5 +- internal/session/clientdoor.go | 4 +- internal/session/inherit.go | 2 +- internal/session/manual_test.go | 18 - internal/session/programhold_test.go | 3 +- internal/session/prompt.go | 1 - internal/session/questionconversation.go | 3 +- internal/session/session.go | 3 - internal/session/toolargs_test.go | 2 - internal/session/tools.go | 1 - internal/session/workstream_contract.go | 104 - internal/session/workstream_factory_test.go | 108 - .../session/workstream_fields_v02_test.go | 90 - internal/session/workstream_limits.go | 22 - internal/session/workstream_schema.go | 181 -- internal/session/workstream_sources.go | 53 - internal/session/workstream_sources_test.go | 105 - .../session/workstream_suggestions_test.go | 126 -- internal/session/workstream_tools.go | 545 ----- internal/session/workstream_tools_test.go | 458 ---- .../session/workstream_validation_test.go | 158 -- internal/session/workstream_wake.go | 89 - internal/session/workstream_wake_test.go | 87 - internal/workspace/schema3_test.go | 247 --- internal/workspace/schema4_test.go | 111 - internal/workspace/schema6_test.go | 228 -- internal/workspace/schema7.go | 32 + internal/workspace/schema7_test.go | 574 +++++ internal/workspace/store.go | 242 +-- internal/workspace/views.go | 6 +- internal/workspace/workspace.go | 5 +- internal/workspace/workstream_sources.go | 36 - internal/workspace/workstream_sources_test.go | 155 -- internal/workspace/workstreams.go | 298 --- .../workspace/workstreams_fields_v02_test.go | 44 - internal/workspace/workstreams_test.go | 162 -- internal/workstream/goaheads.go | 232 -- internal/workstream/legacy_test.go | 76 - internal/workstream/limits_test.go | 134 -- internal/workstream/pagedoc/frommarkdown.go | 359 ---- .../workstream/pagedoc/frommarkdown_test.go | 136 -- internal/workstream/pagedoc/pagedoc.go | 395 ---- internal/workstream/pagedoc/pagedoc_test.go | 414 ---- .../workstream/pagedoc/testdata/example.json | 25 - .../workstream/pagedoc/testdata/example.md | 20 - .../pagedoc/testdata/suggestion-keep.json | 306 --- internal/workstream/pagedoc/textblocks.go | 275 --- .../workstream/pagedoc/textblocks_test.go | 235 --- internal/workstream/pages.go | 711 ------- internal/workstream/pages_test.go | 517 ----- internal/workstream/plan.go | 197 -- internal/workstream/records.go | 310 --- internal/workstream/records_goaheads_test.go | 240 --- internal/workstream/sources.go | 391 ---- internal/workstream/sources_migration_test.go | 210 -- internal/workstream/sources_test.go | 556 ----- internal/workstream/sources_validation.go | 297 --- internal/workstream/store.go | 565 ----- internal/workstream/store_test.go | 215 -- internal/workstream/suggestions.go | 532 ----- internal/workstream/suggestions_test.go | 554 ----- internal/workstream/wake_actions.go | 454 ---- internal/workstream/wake_answer.go | 132 -- internal/workstream/wake_answer_test.go | 44 - internal/workstream/wakes.go | 500 ----- internal/workstream/wakes_test.go | 434 ---- internal/workstream/workstream.go | 264 --- internal/workstream/workstream_agent.go | 57 - .../workstream/workstream_agent_done_test.go | 155 -- internal/workstream/workstream_agent_test.go | 106 - .../workstream/workstream_fields_v02_test.go | 398 ---- .../workstream/workstream_wake_fix1_test.go | 320 --- .../workstream/workstream_wake_fix2_test.go | 185 -- 171 files changed, 1318 insertions(+), 29522 deletions(-) delete mode 100644 cmd/codeaf-replay/workstream_wake_fix2_test.go create mode 100644 cmd/codeaf/appcollections_factory.go create mode 100644 cmd/codeaf/appcollections_factory_test.go delete mode 100644 cmd/codeaf/appworkstreams.go delete mode 100644 cmd/codeaf/appworkstreams_agent.go delete mode 100644 cmd/codeaf/appworkstreams_agent_brief_test.go delete mode 100644 cmd/codeaf/appworkstreams_agent_done_test.go delete mode 100644 cmd/codeaf/appworkstreams_agent_sources.go delete mode 100644 cmd/codeaf/appworkstreams_agent_sources_test.go delete mode 100644 cmd/codeaf/appworkstreams_agent_test.go delete mode 100644 cmd/codeaf/appworkstreams_conformance_test.go delete mode 100644 cmd/codeaf/appworkstreams_fields_v02_test.go delete mode 100644 cmd/codeaf/appworkstreams_pages_test.go delete mode 100644 cmd/codeaf/appworkstreams_source_open_other.go delete mode 100644 cmd/codeaf/appworkstreams_source_open_unix.go delete mode 100644 cmd/codeaf/appworkstreams_source_open_unix_test.go delete mode 100644 cmd/codeaf/appworkstreams_sources.go delete mode 100644 cmd/codeaf/appworkstreams_sources_test.go delete mode 100644 cmd/codeaf/appworkstreams_suggestions_test.go delete mode 100644 cmd/codeaf/appworkstreams_test.go delete mode 100644 cmd/codeaf/appworkstreams_wake.go delete mode 100644 cmd/codeaf/appworkstreams_wake_test.go delete mode 100644 cmd/codeaf/chatv3_layout_stab_test.go delete mode 100644 cmd/codeaf/testdata/workstreams-wire/README.md delete mode 100644 cmd/codeaf/testdata/workstreams-wire/collections.add.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/contract.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/extract.py delete mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.grant.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.create.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.delete.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.get.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.history.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.move.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.restore.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.save.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.update.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.drop.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.keep.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.propose.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.supersede.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/source.add.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/source.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/source.remove.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/source.update.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.apply.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.not_now.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.undo.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.changed.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.create.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.get.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.update.json delete mode 100644 docs/changes/unreleased/2263-app-workstreams-contract.md delete mode 100644 docs/changes/unreleased/2270-app-workstreams-store.md delete mode 100644 docs/changes/unreleased/2277-app-workstream-page-markdown.md delete mode 100644 docs/changes/unreleased/2288-workstreams-wire-conformance.md delete mode 100644 docs/changes/unreleased/2289-app-workstream-agent-tools.md delete mode 100644 docs/changes/unreleased/2290-app-workstream-page-versions-restore.md delete mode 100644 docs/changes/unreleased/2297-app-workstreams-wire-v02.md delete mode 100644 docs/changes/unreleased/2303-workstreams-stability-fixes.md delete mode 100644 docs/changes/unreleased/2310-workstreams-size-limits.md delete mode 100644 docs/changes/unreleased/2318-app-workstream-suggestions.md delete mode 100644 docs/changes/unreleased/2319-workstreams-watching-agent.md delete mode 100644 docs/changes/unreleased/2320-app-workstream-sources.md delete mode 100644 docs/changes/unreleased/2334-client-tools.md create mode 100644 docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md delete mode 100644 internal/appwire/workstreams.go delete mode 100644 internal/appwire/workstreams_check.go delete mode 100644 internal/appwire/workstreams_check_test.go delete mode 100644 internal/appwire/workstreams_fields_v02_test.go delete mode 100644 internal/appwire/workstreams_sources.go delete mode 100644 internal/appwire/workstreams_sources_test.go delete mode 100644 internal/appwire/workstreams_test.go delete mode 100644 internal/session/workstream_contract.go delete mode 100644 internal/session/workstream_factory_test.go delete mode 100644 internal/session/workstream_fields_v02_test.go delete mode 100644 internal/session/workstream_limits.go delete mode 100644 internal/session/workstream_schema.go delete mode 100644 internal/session/workstream_sources.go delete mode 100644 internal/session/workstream_sources_test.go delete mode 100644 internal/session/workstream_suggestions_test.go delete mode 100644 internal/session/workstream_tools.go delete mode 100644 internal/session/workstream_tools_test.go delete mode 100644 internal/session/workstream_validation_test.go delete mode 100644 internal/session/workstream_wake.go delete mode 100644 internal/session/workstream_wake_test.go delete mode 100644 internal/workspace/schema4_test.go delete mode 100644 internal/workspace/schema6_test.go create mode 100644 internal/workspace/schema7.go create mode 100644 internal/workspace/schema7_test.go delete mode 100644 internal/workspace/workstream_sources.go delete mode 100644 internal/workspace/workstream_sources_test.go delete mode 100644 internal/workspace/workstreams.go delete mode 100644 internal/workspace/workstreams_fields_v02_test.go delete mode 100644 internal/workspace/workstreams_test.go delete mode 100644 internal/workstream/goaheads.go delete mode 100644 internal/workstream/legacy_test.go delete mode 100644 internal/workstream/limits_test.go delete mode 100644 internal/workstream/pagedoc/frommarkdown.go delete mode 100644 internal/workstream/pagedoc/frommarkdown_test.go delete mode 100644 internal/workstream/pagedoc/pagedoc.go delete mode 100644 internal/workstream/pagedoc/pagedoc_test.go delete mode 100644 internal/workstream/pagedoc/testdata/example.json delete mode 100644 internal/workstream/pagedoc/testdata/example.md delete mode 100644 internal/workstream/pagedoc/testdata/suggestion-keep.json delete mode 100644 internal/workstream/pagedoc/textblocks.go delete mode 100644 internal/workstream/pagedoc/textblocks_test.go delete mode 100644 internal/workstream/pages.go delete mode 100644 internal/workstream/pages_test.go delete mode 100644 internal/workstream/plan.go delete mode 100644 internal/workstream/records.go delete mode 100644 internal/workstream/records_goaheads_test.go delete mode 100644 internal/workstream/sources.go delete mode 100644 internal/workstream/sources_migration_test.go delete mode 100644 internal/workstream/sources_test.go delete mode 100644 internal/workstream/sources_validation.go delete mode 100644 internal/workstream/store.go delete mode 100644 internal/workstream/store_test.go delete mode 100644 internal/workstream/suggestions.go delete mode 100644 internal/workstream/suggestions_test.go delete mode 100644 internal/workstream/wake_actions.go delete mode 100644 internal/workstream/wake_answer.go delete mode 100644 internal/workstream/wake_answer_test.go delete mode 100644 internal/workstream/wakes.go delete mode 100644 internal/workstream/wakes_test.go delete mode 100644 internal/workstream/workstream.go delete mode 100644 internal/workstream/workstream_agent.go delete mode 100644 internal/workstream/workstream_agent_done_test.go delete mode 100644 internal/workstream/workstream_agent_test.go delete mode 100644 internal/workstream/workstream_fields_v02_test.go delete mode 100644 internal/workstream/workstream_wake_fix1_test.go delete mode 100644 internal/workstream/workstream_wake_fix2_test.go diff --git a/cmd/codeaf-replay/request.go b/cmd/codeaf-replay/request.go index f6dc4866b9..1dd449ccb1 100644 --- a/cmd/codeaf-replay/request.go +++ b/cmd/codeaf-replay/request.go @@ -299,9 +299,6 @@ var callSiteRoles = map[string]lane.Role{ // it sets lane.RoleStanding on its own context. The older tag stays so a // log written before the change still reads. "automation-check": lane.RoleStanding, - // internal/session/workstream_wake.go: one workstream's event turn sets - // lane.RoleStanding on its context; nobody is waiting on its stream. - "workstream-wake": lane.RoleStanding, // internal/session/tools_doc.go: the model's own eyes on a document the // `read` tool cannot open as text. A TOOL CALL INSIDE A TURN, so somebody IS // waiting — the one of the three whose seconds are a person's. diff --git a/cmd/codeaf-replay/workstream_wake_fix2_test.go b/cmd/codeaf-replay/workstream_wake_fix2_test.go deleted file mode 100644 index 962a818460..0000000000 --- a/cmd/codeaf-replay/workstream_wake_fix2_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "testing" - - "github.com/Agent-Field/codeaf/internal/lane" -) - -func TestR2_M1_WorkstreamWakeResolvesToStandingRole(t *testing.T) { - if got := roleOf("workstream-wake"); got != lane.RoleStanding { - t.Fatalf("workstream-wake resolved to %q, want %q", got, lane.RoleStanding) - } -} diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 158db343d5..3b68246d0a 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -201,7 +201,6 @@ func runApp(args []string) error { // ONE SUPERVISOR FOR EVERY CONVERSATION THE APP BUILDS (appservices.go). services := openAppServices() appServiceDoor.Store(services) - appWorkstreamAgents.Store(backend) appClientToolsBackend.Store(backend) backend.attachServices(services) guard.Go("app/crews-start", func() { backend.crews() }) @@ -215,7 +214,6 @@ func runApp(args []string) error { defer keepAutomationsWindow("app")() } guard.Go("app/orders", func() { backend.watchOrders(backend.sweepStop) }) - guard.Go("app/workstream-wakes", func() { backend.watchWorkstreamWakes(backend.sweepStop) }) // And what the last quit stopped in the middle of a reply carries on. guard.Go("app/resume-after-quit", backend.resumeAfterQuit) backend.startCloseWatch() @@ -746,7 +744,7 @@ type appBackend struct { collections *workspace.Store collectionsClosed bool collectionsPath string - workstreams appWorkstreams + collectionFactory collectionFactory // services is the app's one supervisor of kept-running processes // (appservices.go); nil is the capability absent. services *appServices @@ -2998,10 +2996,7 @@ 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.workstreamAgentTurnSettled(id) - }) + b.spendMoved(func(spend appwire.Spend) { b.moneyAfter(a, live, spend.TodayUSD) }) } // ── entry ids ─────────────────────────────────────────────────────────────── diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 370454a14e..8355d5d926 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -35,7 +35,6 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream" ) // appCollectionsPath is where the collections store is, the CLI's own default. @@ -98,16 +97,11 @@ func (b *appBackend) closeCollections() { // appCollectionError is a store refusal in the wire's codes. func appCollectionError(err error) error { var wire *appwire.Error - var taken *workspace.WorkstreamTaken switch { case err == nil: return nil case errors.As(err, &wire): return wire - case errors.As(err, &taken): - return &appwire.Error{Code: appwire.CodeInOtherWorkstream, Message: taken.Error(), Detail: appwire.WorkstreamTaken{Factory: taken.Factory, Job: taken.Job, WorkstreamID: taken.WorkstreamID, WorkstreamName: taken.WorkstreamName}} - case errors.Is(err, workspace.ErrWorkstreamDelete): - return appwire.Errorf(appwire.CodeBadParams, "%s", err) case errors.Is(err, workspace.ErrNameTaken): return appwire.Errorf(appwire.CodeConflict, "%s", workspace.ErrNameTaken.Error()) case errors.Is(err, workspace.ErrCycle): @@ -150,7 +144,7 @@ func appWireMember(m workspace.Member) appwire.Member { } func appWireCollection(s workspace.Summary) appwire.Collection { - return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds, Workstream: s.Workstream} + return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds} } // describe is one collection as the wire says it. @@ -208,20 +202,10 @@ func (b *appBackend) RenameCollection(id, name string) (appwire.Collection, erro if err != nil { return appwire.Collection{}, err } - before, err := s.Describe(context.Background(), id) - if err != nil { - return appwire.Collection{}, appCollectionError(err) - } - if before.Workstream && before.Name == name { - return appWireCollection(before), nil - } if err := s.Rename(context.Background(), id, name); err != nil { return appwire.Collection{}, appCollectionError(err) } b.collectionChanged(id, false) - if before.Workstream && before.Name != name { - b.workstreamCollectionChanged(id, workstream.PartWorkstream) - } return describeCollection(s, id) } @@ -253,7 +237,6 @@ func (b *appBackend) DeleteCollection(id string) error { b.collectionChanged(id, true) for _, parent := range parents { b.collectionChanged(parent, false) - b.workstreamCollectionChanged(parent, workstream.PartJobs) } return nil } @@ -285,7 +268,6 @@ func (b *appBackend) AddToCollection(id string, refs []appwire.Member) (appwire. } if len(added) > 0 { b.collectionChanged(id, false) - b.workstreamCollectionChanged(id, workstream.PartJobs) } return out, nil } @@ -310,7 +292,6 @@ func (b *appBackend) RemoveFromCollection(id string, refs []appwire.Ref) (appwir } if len(removed) > 0 { b.collectionChanged(id, false) - b.workstreamCollectionChanged(id, workstream.PartJobs) } return out, nil } @@ -327,7 +308,7 @@ func (b *appBackend) OpenCollection(id string, depth, limit int, after string) ( return appwire.CollectionOpenResult{}, appCollectionError(err) } r := newCollectionResolver(b) - r.prepareWorkstreamTree(tree.Level) + r.prepareFactoryTree(tree.Level) var nodes func(level workspace.Level) []appwire.Node nodes = func(level workspace.Level) []appwire.Node { out := make([]appwire.Node, 0, len(level.Members)) @@ -379,7 +360,7 @@ func (b *appBackend) ExpandCollection(id string, depth, limit int) (appwire.Coll Missing: e.Deleted, Unread: e.Unread, Collections: e.Collections, More: e.More, } r := newCollectionResolver(b) - r.prepareWorkstreamRefs(e.Refs) + r.prepareFactoryRefs(e.Refs) for _, m := range e.Refs { target := r.target(m.Ref) switch target.State { @@ -421,7 +402,6 @@ func (b *appBackend) RelinkCollection(id string, from, to appwire.Ref) (appwire. // Relinking a member to itself changed nothing, so it says nothing. if from != to { b.collectionChanged(id, false) - b.workstreamCollectionChanged(id, workstream.PartJobs) } return appwire.CollectionRelinkResult{Member: appWireMember(m), Already: already}, nil } @@ -542,7 +522,7 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { switch ref.Kind { case workspace.FactoryJobKind: if _, ok := r.factoryTargets[ref]; !ok { - r.prepareWorkstreamRefs([]workspace.Member{{Ref: ref}}) + r.prepareFactoryRefs([]workspace.Member{{Ref: ref}}) } return r.factoryTargets[ref] case workspace.ConversationKind: diff --git a/cmd/codeaf/appcollections_factory.go b/cmd/codeaf/appcollections_factory.go new file mode 100644 index 0000000000..d5927afd69 --- /dev/null +++ b/cmd/codeaf/appcollections_factory.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "errors" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const ( + factoryReadTimeout = 3 * time.Second + factoryQueueFresh = 10 * time.Second + factoryQueueCacheMax = 32 + factoryMissingJobsMax = 20 + factoryReadParallel = 8 +) + +// collectionFactory belongs to one backend so cached queue reads cannot cross profiles. +type collectionFactory struct { + queue func(context.Context, string) (api.Queue, error) + job func(context.Context, string, int) (api.Job, error) + cacheMu sync.Mutex + cache map[string]factoryQueueRead + flights map[string]chan struct{} +} + +type factoryJob struct { + Factory string + Number int +} + +type factoryQueueRead struct { + at time.Time + jobs map[int]api.Job + err error +} + +// factoryQueue shares successful and failed reads briefly, including an +// in-flight read, so concurrent list/get calls cannot multiply Factory work. +func (b *appBackend) factoryQueue(ctx context.Context, factory string) (entry factoryQueueRead) { + state := &b.collectionFactory + entry, flight, readQueue := state.factoryQueueStart(factory, b.now()) + if flight == nil { + return entry + } + if !readQueue { + select { + case <-flight: + return b.factoryQueue(ctx, factory) + case <-ctx.Done(): + return factoryQueueRead{err: ctx.Err()} + } + } + // Complete ownership even when the guarded reader panics. Only a completed + // read replaces the unavailable entry, so a fault cannot publish success. + entry = factoryQueueRead{err: errors.New("the Factory queue read did not complete")} + defer func() { + entry.at = b.now() + state.factoryQueueFinish(factory, flight, entry) + }() + read := state.queue + if read == nil { + read = b.FactoryQueue + } + queue, err := read(ctx, factory) + jobs := map[int]api.Job{} + for _, j := range queue.Jobs { + jobs[j.ID] = j + } + entry = factoryQueueRead{err: err, jobs: jobs} + return entry +} + +func (state *collectionFactory) factoryQueueStart(factory string, now time.Time) (factoryQueueRead, chan struct{}, bool) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if entry, ok := state.cache[factory]; ok && now.Sub(entry.at) < factoryQueueFresh { + return entry, nil, false + } + if flight := state.flights[factory]; flight != nil { + return factoryQueueRead{}, flight, false + } + if state.flights == nil { + state.flights = map[string]chan struct{}{} + } + flight := make(chan struct{}) + state.flights[factory] = flight + return factoryQueueRead{}, flight, true +} + +func (state *collectionFactory) factoryQueueFinish(factory string, flight chan struct{}, entry factoryQueueRead) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if state.cache == nil { + state.cache = map[string]factoryQueueRead{} + } + if len(state.cache) >= factoryQueueCacheMax { + oldest := "" + var at time.Time + for id, v := range state.cache { + if oldest == "" || v.at.Before(at) { + oldest, at = id, v.at + } + } + delete(state.cache, oldest) + } + state.cache[factory] = entry + delete(state.flights, factory) + close(flight) +} + +type factoryJobReading struct { + job api.Job + err error + notFound bool +} + +// factoryJobReads gives each factory one concurrent queue read. Missing +// jobs share a global fallback allowance and the same overall deadline. +func (b *appBackend) factoryJobReads(jobs []factoryJob) map[factoryJob]factoryJobReading { + out := map[factoryJob]factoryJobReading{} + grouped := map[string][]factoryJob{} + for _, j := range jobs { + if _, seen := out[j]; !seen { + out[j] = factoryJobReading{err: context.DeadlineExceeded} + grouped[j.Factory] = append(grouped[j.Factory], j) + } + } + if len(grouped) == 0 { + return out + } + ctx, cancel := context.WithTimeout(context.Background(), factoryReadTimeout) + defer cancel() + var wg sync.WaitGroup + var mu sync.Mutex + missing := 0 + factoryMissingAllowed := func() bool { + mu.Lock() + defer mu.Unlock() + if missing >= factoryMissingJobsMax { + return false + } + missing++ + return true + } + factoryRememberRead := func(j factoryJob, read factoryJobReading) { + mu.Lock() + defer mu.Unlock() + out[j] = read + } + slots := make(chan struct{}, factoryReadParallel) + for factory, members := range grouped { + wg.Add(1) + guard.Go("app/collection-factory", func() { + defer wg.Done() + select { + case slots <- struct{}{}: + defer func() { <-slots }() + case <-ctx.Done(): + return + } + queue := b.factoryQueue(ctx, factory) + for _, j := range members { + job, ok := queue.jobs[j.Number] + err := queue.err + if err == nil && !ok { + allowed := factoryMissingAllowed() + switch { + case !allowed: + err = errors.New("the missing-job read allowance was reached") + case ctx.Err() != nil: + err = ctx.Err() + default: + read := b.collectionFactory.job + if read == nil { + read = b.FactoryJob + } + job, err = read(ctx, j.Factory, j.Number) + if err == nil && job.ID != j.Number { + err = errors.New("the Factory returned another job") + } + } + } + var wire *appwire.Error + notFound := queue.err == nil && errors.As(err, &wire) && wire.Code == appwire.CodeNotFound + factoryRememberRead(j, factoryJobReading{job: job, err: err, notFound: notFound}) + } + }) + } + wg.Wait() + return out +} + +// Collection resolution uses the same bounded batch reader, with each job +// asked once even when it appears through several nested collections. +func (r *collectionResolver) prepareFactoryRefs(members []workspace.Member) { + jobs := []factoryJob{} + refs := map[factoryJob]workspace.Ref{} + for _, member := range members { + if member.Kind != workspace.FactoryJobKind { + continue + } + number, _ := strconv.Atoi(member.ID) + job := factoryJob{Factory: member.Factory, Number: number} + jobs = append(jobs, job) + refs[job] = member.Ref + } + r.factoryTargets = map[workspace.Ref]appwire.Target{} + for job, read := range r.b.factoryJobReads(jobs) { + var target appwire.Target + if read.err != nil { + if read.notFound { + target = appwire.Target{State: appwire.TargetDeleted} + } else { + target = appUnknown("the Factory", read.err) + target.Status = "unknown" + } + } else { + target = appwire.Target{State: appwire.TargetLive, Title: read.job.Title, Status: read.job.State, Repo: read.job.Repo} + } + r.factoryTargets[refs[job]] = target + } +} + +func (r *collectionResolver) prepareFactoryTree(level workspace.Level) { + members := []workspace.Member{} + var collect func(workspace.Level) + collect = func(level workspace.Level) { + for _, node := range level.Members { + if node.Kind == workspace.FactoryJobKind { + members = append(members, node.Member) + } + if node.Children != nil { + collect(*node.Children) + } + } + } + collect(level) + r.prepareFactoryRefs(members) +} diff --git a/cmd/codeaf/appcollections_factory_test.go b/cmd/codeaf/appcollections_factory_test.go new file mode 100644 index 0000000000..0530e2c7e7 --- /dev/null +++ b/cmd/codeaf/appcollections_factory_test.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" +) + +func collectionFactoryRef(factory string, job int) appwire.Member { + return appwire.Member{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: fmt.Sprint(job), Factory: factory}} +} + +func collectionFactoryCreate(t *testing.T, b *appBackend, name string, refs ...appwire.Member) string { + t.Helper() + c, err := b.CreateCollection(name) + if err != nil { + t.Fatal(err) + } + if len(refs) > 0 { + if _, err := b.AddToCollection(c.ID, refs); err != nil { + t.Fatal(err) + } + } + return c.ID +} + +// Contract (b), G1: the moved batch reader resolves nested refs once per job, +// reads distinct factories concurrently, and shares cache entries with expand. +func TestCollectionFactoryBatchCacheAndNestedTargets(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + nested := collectionFactoryCreate(t, b, "Nested", collectionFactoryRef("f", 7), collectionFactoryRef("g", 9)) + root := collectionFactoryCreate(t, b, "Root", collectionFactoryRef("f", 7), appwire.Member{Ref: appwire.Ref{Kind: appwire.RefCollection, ID: nested}}) + entered := make(chan string, 2) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var mu sync.Mutex + calls := map[string]int{} + b.collectionFactory.queue = func(ctx context.Context, factory string) (api.Queue, error) { + func() { mu.Lock(); defer mu.Unlock(); calls[factory]++ }() + entered <- factory + select { + case <-release: + case <-ctx.Done(): + return api.Queue{}, ctx.Err() + } + id := 7 + if factory == "g" { + id = 9 + } + return api.Queue{Jobs: []api.Job{{ID: id, Num: 1107, Title: "Recorded title", State: "backlog", Repo: "acme/api"}}}, nil + } + done := make(chan error, 1) + guard.Go("app/collection-factory-open-test", func() { + open, err := b.OpenCollection(root, 3, 200, "") + if err == nil { + var visit func([]appwire.Node) + found := 0 + visit = func(nodes []appwire.Node) { + for _, node := range nodes { + if node.Kind == appwire.RefFactoryJob { + found++ + if node.Target.State != appwire.TargetLive || node.Target.Title != "Recorded title" || node.Target.Status != "backlog" || node.Target.Repo != "acme/api" { + t.Errorf("target: %+v", node.Target) + } + } + if node.Children != nil { + visit(node.Children.Members) + } + } + } + visit(open.Members) + if found != 3 { + t.Errorf("resolved %d job refs, want 3", found) + } + } + done <- err + }) + for range 2 { + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("factories were not read concurrently") + } + } + releaseOnce.Do(func() { close(release) }) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("open did not finish") + } + if _, err := b.ExpandCollection(root, 3, 200); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if calls["f"] != 1 || calls["g"] != 1 { + t.Fatalf("queue calls: %v", calls) + } +} + +// Contract (b), G1: missing-job reads have a shared deadline and global bound; +// duplicate refs are asked once and unavailable queues never trigger fallbacks. +func TestCollectionFactoryMissingJobReadsAreBounded(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + refs := []appwire.Member{} + for i := 1; i <= 25; i++ { + refs = append(refs, collectionFactoryRef("f", i)) + } + id := collectionFactoryCreate(t, b, "Missing", refs...) + queueReads, jobReads := 0, 0 + b.collectionFactory.queue = func(ctx context.Context, _ string) (api.Queue, error) { + queueReads++ + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > factoryReadTimeout { + t.Error("queue read has no bounded deadline") + } + return api.Queue{}, nil + } + b.collectionFactory.job = func(ctx context.Context, _ string, job int) (api.Job, error) { + jobReads++ + if _, ok := ctx.Deadline(); !ok { + t.Error("job read has no deadline") + } + return api.Job{ID: job, Num: 999, State: "backlog"}, nil + } + open, err := b.OpenCollection(id, 1, 200, "") + live, unknown := 0, 0 + for _, node := range open.Members { + switch node.Target.State { + case appwire.TargetLive: + live++ + case appwire.TargetUnknown: + unknown++ + } + } + if err != nil || queueReads != 1 || jobReads != 20 || live != 20 || unknown != 5 { + t.Fatalf("bounded fallback: %+v, %v; queue %d jobs %d", open, err, queueReads, jobReads) + } + b.collectionFactory.cache = nil + jobReads = 0 + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, errors.New("unreachable") } + open, err = b.OpenCollection(id, 1, 200, "") + if err != nil || jobReads != 0 { + t.Fatalf("unavailable queue triggered fallbacks: %v, %d", err, jobReads) + } + for _, node := range open.Members { + if node.Target.State != appwire.TargetUnknown || node.Target.Status != "unknown" { + t.Fatalf("unavailable target: %+v", node.Target) + } + } + b.collectionFactory.cache = nil + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, nil } + jobReads = 0 + reads := b.factoryJobReads([]factoryJob{{Factory: "f", Number: 7}, {Factory: "f", Number: 7}}) + if len(reads) != 1 || jobReads != 1 { + t.Fatalf("duplicate job read %d times: %v", jobReads, reads) + } +} + +// Contract (b), G1: a missing job is deleted, a queue error or a wrong returned +// job is unknown, and a short cache shares both successes and failures. +func TestCollectionFactoryDeletedUnknownAndCacheExpiry(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + id := collectionFactoryCreate(t, b, "Targets", collectionFactoryRef("f", 7)) + clock := time.Now() + b.now = func() time.Time { return clock } + calls := 0 + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { calls++; return api.Queue{}, nil } + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { + return api.Job{}, appwire.Errorf(appwire.CodeNotFound, "gone") + } + assertTarget := func(state string) { + t.Helper() + open, err := b.OpenCollection(id, 1, 200, "") + if err != nil || len(open.Members) != 1 || open.Members[0].Target.State != state { + t.Fatalf("target: %+v, %v", open, err) + } + } + assertTarget(appwire.TargetDeleted) + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { return api.Job{ID: 8}, nil } + assertTarget(appwire.TargetUnknown) + if calls != 1 { + t.Fatalf("queue was not shared: %d", calls) + } + clock = clock.Add(factoryQueueFresh) + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { + calls++ + return api.Queue{}, errors.New("unreachable") + } + assertTarget(appwire.TargetUnknown) + assertTarget(appwire.TargetUnknown) + if calls != 2 { + t.Fatalf("failed queue cache: %d", calls) + } + clock = clock.Add(factoryQueueFresh) + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { + calls++ + return api.Queue{Jobs: []api.Job{{ID: 7, Title: "Back", State: "running"}}}, nil + } + assertTarget(appwire.TargetLive) + if calls != 3 { + t.Fatalf("queue did not retry after expiry: %d", calls) + } +} + +// factoryQueueWaitContext signals when a coalesced reader selects on Done, +// so the panic cannot race ahead of the waiter entering the flight branch. +type factoryQueueWaitContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *factoryQueueWaitContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +// Contract (b), G1: the moved guarded reader releases its flight on panic, +// caches the unavailable result and permits a successful retry after expiry. +func TestCollectionFactoryPanicFinishesFlight(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + id := collectionFactoryCreate(t, b, "Panic", collectionFactoryRef("f", 7)) + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + b.now = func() time.Time { return time.UnixMilli(clock.Load()) } + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var calls atomic.Int32 + b.collectionFactory.queue = func(ctx context.Context, _ string) (api.Queue, error) { + if calls.Add(1) == 1 { + close(entered) + select { + case <-release: + panic("queue reader fault") + case <-ctx.Done(): + return api.Queue{}, ctx.Err() + } + } + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil + } + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { + t.Error("failed queue triggered fallback") + return api.Job{}, errors.New("unexpected fallback") + } + owner := make(chan appwire.CollectionOpenResult, 1) + guard.Go("app/collection-factory-panic-owner-test", func() { + open, err := b.OpenCollection(id, 1, 200, "") + if err != nil { + t.Error(err) + } + owner <- open + }) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("reader did not enter") + } + _, flight, owns := b.collectionFactory.factoryQueueStart("f", b.now()) + if flight == nil || owns { + t.Fatal("reader did not own a shared flight") + } + ctx, cancel := context.WithTimeout(context.Background(), factoryReadTimeout) + defer cancel() + waitCtx := &factoryQueueWaitContext{Context: ctx, waiting: make(chan struct{})} + waiter := make(chan factoryQueueRead, 1) + guard.Go("app/collection-factory-panic-waiter-test", func() { waiter <- b.factoryQueue(waitCtx, "f") }) + select { + case <-waitCtx.waiting: + case <-time.After(time.Second): + t.Fatal("waiter did not enter flight") + } + releaseOnce.Do(func() { close(release) }) + select { + case open := <-owner: + if len(open.Members) != 1 || open.Members[0].Target.State != appwire.TargetUnknown { + t.Fatalf("panic target: %+v", open) + } + case <-time.After(time.Second): + t.Fatal("owner did not finish") + } + select { + case <-flight: + default: + t.Fatal("panic left flight open") + } + select { + case read := <-waiter: + if read.err == nil || errors.Is(read.err, context.DeadlineExceeded) || !read.at.Equal(b.now()) { + t.Fatalf("cached failure: %+v", read) + } + case <-time.After(time.Second): + t.Fatal("panic stranded waiter") + } + entry, flight, owns := b.collectionFactory.factoryQueueStart("f", b.now()) + if entry.err == nil || flight != nil || owns || calls.Load() != 1 { + t.Fatal("panic did not cache failure and release flight") + } + clock.Add((factoryQueueFresh - time.Millisecond).Milliseconds()) + if got := b.factoryQueue(ctx, "f"); got.err == nil || calls.Load() != 1 { + t.Fatalf("failure not cached: %+v", got) + } + clock.Add(1) + if got := b.factoryQueue(ctx, "f"); got.err != nil || got.jobs[7].ID != 7 || calls.Load() != 2 { + t.Fatalf("retry after expiry: %+v", got) + } +} diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go deleted file mode 100644 index 848292dc8a..0000000000 --- a/cmd/codeaf/appworkstreams.go +++ /dev/null @@ -1,1117 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "errors" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/factory/api" - "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/home" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -var _ appwire.Workstreamer = (*appBackend)(nil) - -const ( - workstreamFactoryTimeout = 3 * time.Second - workstreamFactoryFresh = 10 * time.Second - workstreamFactoryCacheMax = 32 - workstreamMissingJobsMax = 20 - workstreamFactoryParallel = 8 -) - -// appWorkstreams belongs to one backend, so cache entries and create retries -// cannot cross profiles. The function seams replace external owners in tests. -type appWorkstreams struct { - createMu sync.Mutex - createAgent func(appwire.CreateAsk) (appwire.Agent, error) - closeAgent func(string) error - archiveChats func([]string) error - queue func(context.Context, string) (api.Queue, error) - job func(context.Context, string, int) (api.Job, error) - spend func(string) *float64 - cacheMu sync.Mutex - cache map[string]workstreamQueueRead - flights map[string]chan struct{} - briefMu sync.Mutex - briefs map[string]session.WorkstreamBrief - wakeTurn func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) - wakeDispatch func(workstreamWakeTask) - wakeMu sync.Mutex - wakeQueue []workstreamWakeTask - wakeSignal chan struct{} - wakeCancel context.CancelFunc -} - -type workstreamQueueRead struct { - at time.Time - jobs map[int]api.Job - err error -} - -func (b *appBackend) ServesWorkstreams() bool { return true } - -func (b *appBackend) workstreamStore(create bool) (*workstream.Store, error) { - s, err := b.collectionStore(create) - if err != nil || s == nil { - return nil, err - } - return workstream.New(s, workstream.Options{Now: b.now}), nil -} - -// workstreamDo emits committed receipts, including a read's first observation. -// Unchanged and refused writes have empty receipts and do not notify watchers. -func workstreamDo[P interface{ Check() error }, R any](b *appBackend, p P, call func(*workstream.Store) (R, workstream.Change, error)) (R, error) { - var zero R - if err := p.Check(); err != nil { - return zero, err - } - s, err := b.workstreamStore(false) - if err != nil { - return zero, err - } - if s == nil { - return zero, appwire.Errorf(appwire.CodeNotFound, "workstream target not found") - } - out, change, err := call(s) - // A refresh can fail after a write commits; its receipt still belongs to watchers. - b.workstreamChanged(change) - if err != nil { - return zero, appWorkstreamError(err) - } - return out, nil -} - -func appWorkstreamError(err error) error { - var stale *workstream.VersionConflict - switch { - case errors.As(err, &stale): - return &appwire.Error{Code: appwire.CodeVersionConflict, Message: stale.Error(), Detail: appwire.WorkstreamVersionConflict{ID: stale.ID, Version: stale.Version, UpdatedAt: stale.UpdatedAt, UpdatedBy: stale.UpdatedBy}} - case errors.Is(err, workstream.ErrNotFound): - return appwire.Errorf(appwire.CodeNotFound, "%s", err) - case errors.Is(err, workstream.ErrConflict): - return appwire.Errorf(appwire.CodeConflict, "%s", err) - case errors.Is(err, workstream.ErrCycle): - return appwire.Errorf(appwire.CodeCycle, "%s", err) - case errors.Is(err, workstream.ErrTooLarge), errors.Is(err, workstream.ErrLimit): - return appwire.Errorf(appwire.CodeTooLarge, "%s", err) - case errors.Is(err, workstream.ErrNotAgentPage): - return appwire.Errorf(appwire.CodeBadParams, "%s", err) - } - return appCollectionError(err) -} - -func (b *appBackend) workstreamChanged(change workstream.Change) { - if change.ID == "" || len(change.Parts) == 0 { - return - } - parts := make([]string, 0, len(change.Parts)) - for _, p := range change.Parts { - parts = append(parts, string(p)) - } - b.emit.Emit(appwire.EventWorkstreamChanged, appwire.WorkstreamChanged{ID: change.ID, Parts: parts}) -} - -// workstreamCollectionChanged is used after collection writes that share the -// workstream's identity. Only workstream collections gain this second event. -func (b *appBackend) workstreamCollectionChanged(id string, part workstream.ChangedPart) { - s, err := b.existingStore() - if err != nil { - return - } - c, err := s.Describe(context.Background(), id) - if err == nil && c.Workstream { - parts := []workstream.ChangedPart{workstream.PartWorkstream} - if part != workstream.PartWorkstream { - parts = append(parts, part) - } - b.workstreamChanged(workstream.Change{ID: id, Parts: parts}) - } -} - -func workstreamValue[T any](p *T) T { - if p != nil { - return *p - } - var zero T - return zero -} -func workstreamOptional(s string) *string { - if s == "" { - return nil - } - return &s -} -func workstreamPositive(n int) *int { - if n == 0 { - return nil - } - return &n -} - -func appWorkstream(w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts, spend *float64) appwire.Workstream { - jobs := w.Jobs() - states := make([]string, 0, w.Total) - for _, j := range jobs { - states = append(states, facts[j].State) - } - numbers := workstream.CountNumbers(jobs, facts, spend, w.Proposed) - return appwire.Workstream{ID: w.ID, Name: w.Name, Why: w.Why, Stage: workstream.Stage(w.DoneAt, w.StartedAt, states), AgentID: w.AgentID, OriginChatID: workstreamOptional(w.OriginChatID), Autonomy: w.Autonomy, Wake: appwire.WorkstreamWake{Morning: w.Wake.Morning, RelatedIssues: w.Wake.RelatedIssues}, BudgetUSD: w.BudgetUSD, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, StartedAt: w.StartedAt, DoneAt: w.DoneAt, HowItWent: workstreamOptional(w.HowItWent), Total: numbers.Total, Merged: numbers.Merged, NeedsYou: numbers.NeedsYou, SpendUSD: numbers.SpendUSD} -} - -func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.WorkstreamListResult, error) { - if err := p.Check(); err != nil { - return appwire.WorkstreamListResult{}, err - } - s, err := b.workstreamStore(false) - out := appwire.WorkstreamListResult{Workstreams: []appwire.WorkstreamRow{}} - if err != nil || s == nil { - return out, err - } - rows, err := s.List(context.Background()) - if err != nil { - return out, appWorkstreamError(err) - } - jobs := []workstream.Job{} - for _, w := range rows { - b.workstreamRememberBrief(w) - jobs = append(jobs, w.Jobs()...) - } - facts := b.workstreamFacts(jobs) - for _, w := range rows { - var c workstream.Change - w, c, err = b.workstreamObserved(s, w, facts, workstream.Change{}) - b.workstreamChanged(c) - if err != nil { - return appwire.WorkstreamListResult{}, err - } - 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, AgentID: v.AgentID, OriginChatID: v.OriginChatID}) - } - return out, nil -} - -func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.WorkstreamGetResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGetResult, workstream.Change, error) { - w, err := s.Get(context.Background(), p.ID) - if err != nil { - return appwire.WorkstreamGetResult{}, workstream.Change{}, err - } - 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 - }) -} - -// 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 { - // 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. -// The final Create transaction repeats ownership and naming checks for peers. -func workstreamPreflight(s *workspace.Store, p appwire.WorkstreamCreateParams) error { - ctx := context.Background() - return s.Read(ctx, func(tx *sql.Tx, version int) error { - if version == 0 { - for _, r := range p.Refs { - if r.Kind == appwire.RefCollection { - return workspace.ErrNotFound - } - } - return nil - } - rows, err := tx.QueryContext(ctx, "SELECT name FROM collections") - if err != nil { - return err - } - taken := false - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - rows.Close() - return err - } - taken = taken || strings.EqualFold(name, strings.TrimSpace(p.Name)) - } - err = rows.Err() - rows.Close() - if err != nil { - return err - } - if taken { - return workspace.ErrNameTaken - } - for _, r := range p.Refs { - if r.Kind == appwire.RefCollection { - var exists bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM collections WHERE id=?)", r.ID).Scan(&exists); err != nil { - return err - } - if !exists { - return workspace.ErrNotFound - } - } - if version >= 3 && r.Kind == appwire.RefFactoryJob { - var held workspace.WorkstreamTaken - held.Factory = workstreamValue(r.Factory) - held.Job, _ = strconv.Atoi(r.ID) - err := tx.QueryRowContext(ctx, `SELECT j.workstream_id,c.name FROM workstream_jobs j JOIN collections c ON c.id=j.workstream_id WHERE j.factory=? AND j.job=?`, held.Factory, held.Job).Scan(&held.WorkstreamID, &held.WorkstreamName) - if err == nil { - return &held - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - } - } - return nil - }) -} - -func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire.WorkstreamCreateResult, error) { - var zero appwire.WorkstreamCreateResult - if err := p.Check(); err != nil { - return zero, err - } - b.workstreams.createMu.Lock() - defer b.workstreams.createMu.Unlock() - // A retry is recognized before origin lookup or agent creation. The domain - // checks client_id again inside its transaction to settle competing peers. - existing, err := b.workstreamStore(false) - if err != nil { - return zero, err - } - if existing != nil && workstreamValue(p.ClientID) != "" { - w, err := existing.ByClientID(context.Background(), *p.ClientID) - if err == nil { - 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 err := b.workstreamCreatePreflight(p); err != nil { - return zero, err - } - create := b.workstreams.createAgent - if create == nil { - create = b.CreateAgent - } - agent, err := create(workstreamAgentAsk(p)) - 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 { - // Source paths can disappear after creation, so check them only for a new client. - sources := make([]workstream.SourceAdd, 0, len(p.Sources)) - for _, source := range p.Sources { - sources = append(sources, source.SourceAdd()) - } - if err := workstream.ValidateCreateSources(sources); err != nil { - return appWorkstreamError(err) - } - 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) - if err == nil { - s := workstream.New(store, workstream.Options{Now: b.now}) - sources := make([]workstream.SourceAdd, 0, len(p.Sources)) - for _, source := range p.Sources { - sources = append(sources, source.SourceAdd()) - } - 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), Sources: sources}) - } - if err != nil || w.AgentID != agent.ID { - if cleanup := b.workstreamCloseUnusedAgent(agent.ID, err); cleanup != nil { - return workstream.Workstream{}, workstream.Change{}, cleanup - } - } - 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}) } - } - 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 nil -} - -func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamUpdateResult, workstream.Change, error) { - patch := workstream.Patch{Name: p.Patch.Name, Why: p.Patch.Why, Autonomy: p.Patch.Autonomy, BudgetUSD: p.Patch.BudgetUSD} - if p.Patch.Wake != nil { - patch.Wake = &workstream.Wake{Morning: p.Patch.Wake.Morning, RelatedIssues: p.Patch.Wake.RelatedIssues} - } - w, c, err := s.Update(context.Background(), p.ID, patch) - if err != nil { - return appwire.WorkstreamUpdateResult{}, c, err - } - b.workstreamRememberBrief(w) - if c.ID != "" && p.Patch.Name != nil { - b.collectionChanged(w.ID, false) - } - facts := b.workstreamFacts(w.Jobs()) - w, c, err = b.workstreamObserved(s, w, facts, c) - return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err - }) -} - -func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (appwire.WorkstreamMarkDoneResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamMarkDoneResult, workstream.Change, error) { - w, c, err := s.MarkDone(context.Background(), p.ID, workstreamValue(p.HowItWent)) - if err != nil { - return appwire.WorkstreamMarkDoneResult{}, c, err - } - 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 - // that receipt first; retrying mark_done can finish the archive without a - // second workstream mutation or notification. - b.workstreamChanged(c) - if err != nil { - return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err - } - if workstreamValue(p.ArchiveChats) { - ids := []string{} - for _, r := range w.Members { - if r.Kind == workspace.ConversationKind && r.ID != w.AgentID { - ids = append(ids, r.ID) - } - } - archive := b.workstreams.archiveChats - if archive == nil { - archive = b.Archive - } - if len(ids) > 0 { - err = archive(ids) - } - } - if err != nil { - return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err - } - return appwire.WorkstreamMarkDoneResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, workstream.Change{}, nil - }) -} - -func (b *appBackend) WorkstreamReopen(p appwire.WorkstreamReopenParams) (appwire.WorkstreamReopenResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamReopenResult, workstream.Change, error) { - w, c, err := s.Reopen(context.Background(), p.ID) - if err != nil { - return appwire.WorkstreamReopenResult{}, c, err - } - 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 - }) -} - -func appWorkstreamPlan(p workstream.Plan) appwire.WorkstreamPlan { - out := appwire.WorkstreamPlan{WorkstreamID: p.WorkstreamID, Version: p.Version, UpdatedAt: p.UpdatedAt, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}} - for _, m := range p.Milestones { - out.Milestones = append(out.Milestones, appwire.WorkstreamMilestone{ID: m.ID, Title: m.Title}) - } - for _, w := range p.Waves { - wave := appwire.WorkstreamWave{N: w.N, Title: workstreamOptional(w.Title), MilestoneID: workstreamOptional(w.MilestoneID), AutoStart: w.AutoStart, Items: []appwire.WorkstreamPlanItem{}} - for _, i := range w.Items { - wave.Items = append(wave.Items, appwire.WorkstreamPlanItem{Kind: i.Kind, Factory: workstreamOptional(i.Factory), Job: workstreamPositive(i.Job), DraftID: workstreamOptional(i.DraftID), Title: workstreamOptional(i.Title), Repo: workstreamOptional(i.Repo), Text: workstreamOptional(i.Text), Size: workstreamOptional(i.Size), BudgetUSD: workstreamDraftBudget(i.BudgetUSD), Source: appWorkstreamSource(i.Source)}) - } - out.Waves = append(out.Waves, wave) - } - return out -} -func (b *appBackend) WorkstreamPlanGet(p appwire.WorkstreamPlanGetParams) (appwire.WorkstreamPlanGetResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPlanGetResult, workstream.Change, error) { - v, err := s.Plan(context.Background(), p.ID) - return appwire.WorkstreamPlanGetResult{Plan: appWorkstreamPlan(v)}, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamPlanSet(p appwire.WorkstreamPlanSetParams) (appwire.WorkstreamPlanSetResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPlanSetResult, workstream.Change, error) { - milestones := make([]workstream.Milestone, 0, len(p.Milestones)) - waves := make([]workstream.Wave, 0, len(p.Waves)) - for _, m := range p.Milestones { - milestones = append(milestones, workstream.Milestone{ID: m.ID, Title: m.Title}) - } - for _, w := range p.Waves { - wave := workstream.Wave{N: w.N, Title: workstreamValue(w.Title), MilestoneID: workstreamValue(w.MilestoneID), AutoStart: w.AutoStart, Items: []workstream.PlanItem{}} - for _, i := range w.Items { - wave.Items = append(wave.Items, workstream.PlanItem{Kind: i.Kind, Factory: workstreamValue(i.Factory), Job: workstreamValue(i.Job), DraftID: workstreamValue(i.DraftID), Title: workstreamValue(i.Title), Repo: workstreamValue(i.Repo), Text: workstreamValue(i.Text), Size: workstreamValue(i.Size), BudgetUSD: workstreamValue(i.BudgetUSD), Source: workstreamSource(i.Source)}) - } - waves = append(waves, wave) - } - v, c, err := s.SetPlan(context.Background(), p.ID, p.BaseVersion, milestones, waves, workstream.Attribution{}) - return appwire.WorkstreamPlanSetResult{Plan: appWorkstreamPlan(v)}, c, err - }) -} - -func workstreamDraftBudget(budget float64) *float64 { - if budget == 0 { - return nil - } - return &budget -} - -func appWorkstreamSource(p *workstream.Source) *appwire.WorkstreamSource { - if p == nil { - return nil - } - return &appwire.WorkstreamSource{Kind: p.Kind, ID: p.ID, ChatID: workstreamOptional(p.ChatID), Factory: workstreamOptional(p.Factory), Label: workstreamOptional(p.Label)} -} - -func appWorkstreamSuggestion(s workstream.Suggestion) appwire.WorkstreamSuggestion { - out := appwire.WorkstreamSuggestion{ID: s.ID, WorkstreamID: s.WorkstreamID, PageID: s.PageID, BaseVersion: s.BaseVersion, Why: s.Why, By: s.By, Source: appWorkstreamSource(s.Source), CreatedAt: s.CreatedAt, Edits: []appwire.WorkstreamSuggestionEdit{}} - for _, e := range s.Edits { - out.Edits = append(out.Edits, appwire.WorkstreamSuggestionEdit{ID: e.ID, Block: e.Block, Offset: e.Offset, Delete: e.Delete, Insert: e.Insert, BlockText: e.BlockText, Why: e.Why, State: e.State, Applies: e.Applies, DecidedAt: e.DecidedAt}) - } - return out -} - -func (b *appBackend) WorkstreamSuggestionList(p appwire.WorkstreamSuggestionListParams) (appwire.WorkstreamSuggestionListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionListResult, workstream.Change, error) { - out := appwire.WorkstreamSuggestionListResult{Suggestions: []appwire.WorkstreamSuggestion{}} - suggestions, err := s.Suggestions(context.Background(), p.PageID) - for _, suggestion := range suggestions { - out.Suggestions = append(out.Suggestions, appWorkstreamSuggestion(suggestion)) - } - return out, workstream.Change{}, err - }) -} - -func (b *appBackend) WorkstreamSuggestionKeep(p appwire.WorkstreamSuggestionKeepParams) (appwire.WorkstreamSuggestionKeepResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionKeepResult, workstream.Change, error) { - page, suggestion, change, err := s.KeepSuggestion(context.Background(), p.ID, workstreamValue(p.EditID)) - if err != nil { - var stale *workstream.VersionConflict - if errors.As(err, &stale) { - return appwire.WorkstreamSuggestionKeepResult{}, change, &appwire.Error{Code: appwire.CodeVersionConflict, Message: "the page changed and this edit no longer applies", Detail: appwire.WorkstreamVersionConflict{ID: stale.ID, Version: stale.Version, UpdatedAt: stale.UpdatedAt, UpdatedBy: stale.UpdatedBy}} - } - } - return appwire.WorkstreamSuggestionKeepResult{Page: appWorkstreamPage(page), Suggestion: appWorkstreamSuggestion(suggestion)}, change, err - }) -} - -func (b *appBackend) WorkstreamSuggestionDrop(p appwire.WorkstreamSuggestionDropParams) (appwire.WorkstreamSuggestionDropResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionDropResult, workstream.Change, error) { - suggestion, change, err := s.DropSuggestion(context.Background(), p.ID, workstreamValue(p.EditID)) - return appwire.WorkstreamSuggestionDropResult{Suggestion: appWorkstreamSuggestion(suggestion)}, change, err - }) -} -func workstreamSource(p *appwire.WorkstreamSource) *workstream.Source { - if p == nil { - return nil - } - return &workstream.Source{Kind: p.Kind, ID: p.ID, ChatID: workstreamValue(p.ChatID), Factory: workstreamValue(p.Factory), Label: workstreamValue(p.Label)} -} -func appWorkstreamPageNode(p workstream.PageNode) appwire.WorkstreamPageNode { - return appwire.WorkstreamPageNode{ID: p.ID, WorkstreamID: p.WorkstreamID, ParentID: workstreamOptional(p.ParentID), Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs, Position: p.Position, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, UpdatedFrom: appWorkstreamSource(p.UpdatedFrom), DeletedAt: p.DeletedAt} -} -func appWorkstreamPage(p workstream.Page) appwire.WorkstreamPage { - n := appWorkstreamPageNode(p.PageNode) - return appwire.WorkstreamPage{ID: n.ID, WorkstreamID: n.WorkstreamID, ParentID: n.ParentID, Title: n.Title, Owner: n.Owner, ReadByJobs: n.ReadByJobs, Position: n.Position, Version: n.Version, UpdatedAt: n.UpdatedAt, UpdatedBy: n.UpdatedBy, UpdatedFrom: n.UpdatedFrom, DeletedAt: n.DeletedAt, Doc: p.Doc, Markdown: p.Markdown} -} -func (b *appBackend) WorkstreamPageList(p appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageListResult, workstream.Change, error) { - pages, err := s.Pages(context.Background(), p.WorkstreamID) - out := appwire.WorkstreamPageListResult{Pages: []appwire.WorkstreamPageNode{}} - for _, page := range pages { - out.Pages = append(out.Pages, appWorkstreamPageNode(page)) - } - if err == nil && workstreamValue(p.IncludeDeleted) { - var deleted []workstream.PageNode - deleted, err = s.DeletedPages(context.Background(), p.WorkstreamID) - out.Deleted = []appwire.WorkstreamPageNode{} - for _, page := range deleted { - out.Deleted = append(out.Deleted, appWorkstreamPageNode(page)) - } - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamPageGet(p appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageGetResult, workstream.Change, error) { - var v workstream.Page - var err error - if p.Version != nil { - v, err = s.PageAtVersion(context.Background(), p.ID, *p.Version, workstreamValue(p.Markdown)) - } else { - v, err = s.Page(context.Background(), p.ID, workstreamValue(p.Markdown)) - } - return appwire.WorkstreamPageGetResult{Page: appWorkstreamPage(v)}, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamPageSave(p appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageSaveResult, workstream.Change, error) { - v, c, err := s.SavePage(context.Background(), p.ID, p.BaseVersion, p.Doc, workstream.Attribution{From: workstreamSource(p.Source)}) - return appwire.WorkstreamPageSaveResult{Page: appWorkstreamPage(v)}, c, err - }) -} -func (b *appBackend) WorkstreamPageCreate(p appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageCreateResult, workstream.Change, error) { - read := workstream.ReadByJobsDefault(p.Title) - if p.ReadByJobs != nil { - read = *p.ReadByJobs - } - v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: read, Doc: p.Doc, Attribution: workstream.Attribution{From: workstreamSource(p.Source)}}) - return appwire.WorkstreamPageCreateResult{Page: appWorkstreamPage(v)}, c, err - }) -} -func (b *appBackend) WorkstreamPageUpdate(p appwire.WorkstreamPageUpdateParams) (appwire.WorkstreamPageUpdateResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageUpdateResult, workstream.Change, error) { - v, c, err := s.UpdatePage(context.Background(), p.ID, workstream.PagePatch{Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs}) - return appwire.WorkstreamPageUpdateResult{Page: appWorkstreamPageNode(v)}, c, err - }) -} -func (b *appBackend) WorkstreamPageMove(p appwire.WorkstreamPageMoveParams) (appwire.WorkstreamPageMoveResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageMoveResult, workstream.Change, error) { - v, c, err := s.MovePage(context.Background(), p.ID, workstreamValue(p.ParentID), workstreamValue(p.AfterID)) - return appwire.WorkstreamPageMoveResult{Page: appWorkstreamPageNode(v)}, c, err - }) -} -func (b *appBackend) WorkstreamPageDelete(p appwire.WorkstreamPageDeleteParams) (appwire.WorkstreamPageDeleteResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageDeleteResult, workstream.Change, error) { - v, c, err := s.DeletePage(context.Background(), p.ID) - return appwire.WorkstreamPageDeleteResult{Deleted: v}, c, err - }) -} -func (b *appBackend) WorkstreamPageHistory(p appwire.WorkstreamPageHistoryParams) (appwire.WorkstreamPageHistoryResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageHistoryResult, workstream.Change, error) { - limit := 50 - if p.Limit != nil { - limit = *p.Limit - } - versions, err := s.PageHistory(context.Background(), p.ID, limit) - out := appwire.WorkstreamPageHistoryResult{Versions: []appwire.WorkstreamPageVersion{}} - for _, v := range versions { - out.Versions = append(out.Versions, appwire.WorkstreamPageVersion{Version: v.Version, SavedAt: v.SavedAt, SavedBy: v.SavedBy, From: appWorkstreamSource(v.From), Title: v.Title, Bytes: v.Bytes}) - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamPageRestore(p appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageRestoreResult, workstream.Change, error) { - var v workstream.Page - var c workstream.Change - var err error - if p.Version == nil { - v, c, err = s.UndeletePage(context.Background(), p.ID) - } else { - v, c, err = s.RestorePage(context.Background(), p.ID, *p.Version, *p.BaseVersion, workstream.Attribution{}) - } - return appwire.WorkstreamPageRestoreResult{Page: appWorkstreamPage(v)}, c, err - }) -} - -func appWorkstreamRecord(r workstream.Record) appwire.WorkstreamRecord { - return appwire.WorkstreamRecord{ID: r.ID, WorkstreamID: r.WorkstreamID, Kind: r.Kind, Text: r.Text, State: r.State, By: r.By, Source: appWorkstreamSource(r.Source), CreatedAt: r.CreatedAt, DecidedAt: r.DecidedAt, Supersedes: workstreamOptional(r.Supersedes), SupersededBy: workstreamOptional(r.SupersededBy), GoAhead: appWorkstreamGoAheadProposal(r.GoAhead), GoAheadID: workstreamOptional(r.GoAheadID)} -} - -func appWorkstreamGoAheadProposal(p *workstream.GoAheadProposal) *appwire.WorkstreamGoAheadProposal { - if p == nil { - return nil - } - return &appwire.WorkstreamGoAheadProposal{Scope: p.Scope, Factory: workstreamOptional(p.Factory), Job: workstreamPositive(p.Job), Wave: workstreamPositive(p.Wave), MilestoneID: workstreamOptional(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt} -} - -func workstreamGoAheadProposal(p *appwire.WorkstreamGoAheadProposal) *workstream.GoAheadProposal { - if p == nil { - return nil - } - return &workstream.GoAheadProposal{Scope: p.Scope, Factory: workstreamValue(p.Factory), Job: workstreamValue(p.Job), Wave: workstreamValue(p.Wave), MilestoneID: workstreamValue(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt} -} -func (b *appBackend) WorkstreamRecordList(p appwire.WorkstreamRecordListParams) (appwire.WorkstreamRecordListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordListResult, workstream.Change, error) { - records, err := s.Records(context.Background(), p.WorkstreamID, workstreamValue(p.Kind), workstreamValue(p.State), workstreamValue(p.IncludeGoAheads)) - out := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} - for _, r := range records { - out.Records = append(out.Records, appWorkstreamRecord(r)) - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamRecordPropose(p appwire.WorkstreamRecordProposeParams) (appwire.WorkstreamRecordProposeResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordProposeResult, workstream.Change, error) { - v, c, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: p.WorkstreamID, Kind: p.Kind, Text: p.Text, Source: workstreamSource(p.Source), Kept: workstreamValue(p.Kept), GoAhead: workstreamGoAheadProposal(p.GoAhead)}) - return appwire.WorkstreamRecordProposeResult{Record: appWorkstreamRecord(v)}, c, err - }) -} -func (b *appBackend) WorkstreamRecordKeep(p appwire.WorkstreamRecordKeepParams) (appwire.WorkstreamRecordKeepResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordKeepResult, workstream.Change, error) { - v, c, err := s.KeepRecord(context.Background(), p.ID) - return appwire.WorkstreamRecordKeepResult{Record: appWorkstreamRecord(v)}, c, err - }) -} -func (b *appBackend) WorkstreamRecordDrop(p appwire.WorkstreamRecordDropParams) (appwire.WorkstreamRecordDropResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordDropResult, workstream.Change, error) { - v, c, err := s.DropRecord(context.Background(), p.ID) - return appwire.WorkstreamRecordDropResult{Record: appWorkstreamRecord(v)}, c, err - }) -} -func (b *appBackend) WorkstreamRecordSupersede(p appwire.WorkstreamRecordSupersedeParams) (appwire.WorkstreamRecordSupersedeResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordSupersedeResult, workstream.Change, error) { - v, old, c, err := s.SupersedeRecord(context.Background(), p.ID, workstream.RecordReplacement{Kind: workstreamValue(p.Kind), Text: p.Text, Source: workstreamSource(p.Source)}) - return appwire.WorkstreamRecordSupersedeResult{Record: appWorkstreamRecord(v), Superseded: appWorkstreamRecord(old)}, c, err - }) -} -func appWorkstreamGoAhead(g workstream.GoAhead) appwire.WorkstreamGoAhead { - return appwire.WorkstreamGoAhead{ID: g.ID, WorkstreamID: g.WorkstreamID, Scope: g.Scope, Factory: workstreamOptional(g.Factory), Job: workstreamPositive(g.Job), Wave: workstreamPositive(g.Wave), MilestoneID: workstreamOptional(g.MilestoneID), CapUSD: g.CapUSD, EndsAt: g.EndsAt, State: g.State, EndReason: workstreamOptional(g.EndReason), GrantedAt: g.GrantedAt, EndedAt: g.EndedAt} -} -func (b *appBackend) WorkstreamGoAheadList(p appwire.WorkstreamGoAheadListParams) (appwire.WorkstreamGoAheadListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadListResult, workstream.Change, error) { - grants, err := s.GoAheads(context.Background(), p.WorkstreamID, workstreamValue(p.Ended)) - out := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} - for _, g := range grants { - out.GoAheads = append(out.GoAheads, appWorkstreamGoAhead(g)) - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamGoAheadGrant(p appwire.WorkstreamGoAheadGrantParams) (appwire.WorkstreamGoAheadGrantResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadGrantResult, workstream.Change, error) { - v, c, err := s.GrantGoAhead(context.Background(), workstream.GoAheadGrant{WorkstreamID: p.WorkstreamID, Scope: p.Scope, Factory: workstreamValue(p.Factory), Job: workstreamValue(p.Job), Wave: workstreamValue(p.Wave), MilestoneID: workstreamValue(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt}) - return appwire.WorkstreamGoAheadGrantResult{GoAhead: appWorkstreamGoAhead(v)}, c, err - }) -} -func (b *appBackend) WorkstreamGoAheadRevoke(p appwire.WorkstreamGoAheadRevokeParams) (appwire.WorkstreamGoAheadRevokeResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadRevokeResult, workstream.Change, error) { - v, c, err := s.RevokeGoAhead(context.Background(), p.ID) - return appwire.WorkstreamGoAheadRevokeResult{GoAhead: appWorkstreamGoAhead(v)}, c, err - }) -} - -// workstreamQueue shares successful and failed reads briefly, including an -// in-flight read, so concurrent list/get calls cannot multiply Factory work. -func (b *appBackend) workstreamQueue(ctx context.Context, factory string) (entry workstreamQueueRead) { - state := &b.workstreams - entry, flight, readQueue := state.workstreamQueueStart(factory, b.now()) - if flight == nil { - return entry - } - if !readQueue { - select { - case <-flight: - return b.workstreamQueue(ctx, factory) - case <-ctx.Done(): - return workstreamQueueRead{err: ctx.Err()} - } - } - // Complete ownership even when the guarded reader panics. Only a completed - // read replaces the unavailable entry, so a fault cannot publish success. - entry = workstreamQueueRead{err: errors.New("the Factory queue read did not complete")} - defer func() { - entry.at = b.now() - state.workstreamQueueFinish(factory, flight, entry) - }() - read := state.queue - if read == nil { - read = b.FactoryQueue - } - queue, err := read(ctx, factory) - jobs := map[int]api.Job{} - for _, j := range queue.Jobs { - jobs[j.ID] = j - } - entry = workstreamQueueRead{err: err, jobs: jobs} - return entry -} - -func (state *appWorkstreams) workstreamQueueStart(factory string, now time.Time) (workstreamQueueRead, chan struct{}, bool) { - state.cacheMu.Lock() - defer state.cacheMu.Unlock() - if entry, ok := state.cache[factory]; ok && now.Sub(entry.at) < workstreamFactoryFresh { - return entry, nil, false - } - if flight := state.flights[factory]; flight != nil { - return workstreamQueueRead{}, flight, false - } - if state.flights == nil { - state.flights = map[string]chan struct{}{} - } - flight := make(chan struct{}) - state.flights[factory] = flight - return workstreamQueueRead{}, flight, true -} - -func (state *appWorkstreams) workstreamQueueFinish(factory string, flight chan struct{}, entry workstreamQueueRead) { - state.cacheMu.Lock() - defer state.cacheMu.Unlock() - if state.cache == nil { - state.cache = map[string]workstreamQueueRead{} - } - if len(state.cache) >= workstreamFactoryCacheMax { - oldest := "" - var at time.Time - for id, v := range state.cache { - if oldest == "" || v.at.Before(at) { - oldest, at = id, v.at - } - } - delete(state.cache, oldest) - } - state.cache[factory] = entry - delete(state.flights, factory) - close(flight) -} - -type workstreamJobRead struct { - job api.Job - err error - notFound bool -} - -// workstreamJobReads gives each factory one concurrent queue read. Missing -// jobs share a global fallback allowance and the same overall deadline. -func (b *appBackend) workstreamJobReads(jobs []workstream.Job) map[workstream.Job]workstreamJobRead { - out := map[workstream.Job]workstreamJobRead{} - grouped := map[string][]workstream.Job{} - for _, j := range jobs { - if _, seen := out[j]; !seen { - out[j] = workstreamJobRead{err: context.DeadlineExceeded} - grouped[j.Factory] = append(grouped[j.Factory], j) - } - } - if len(grouped) == 0 { - return out - } - ctx, cancel := context.WithTimeout(context.Background(), workstreamFactoryTimeout) - defer cancel() - var wg sync.WaitGroup - var mu sync.Mutex - missing := 0 - workstreamMissingAllowed := func() bool { - mu.Lock() - defer mu.Unlock() - if missing >= workstreamMissingJobsMax { - return false - } - missing++ - return true - } - workstreamRememberRead := func(j workstream.Job, read workstreamJobRead) { - mu.Lock() - defer mu.Unlock() - out[j] = read - } - slots := make(chan struct{}, workstreamFactoryParallel) - for factory, members := range grouped { - wg.Add(1) - guard.Go("app/workstream-factory", func() { - defer wg.Done() - select { - case slots <- struct{}{}: - defer func() { <-slots }() - case <-ctx.Done(): - return - } - queue := b.workstreamQueue(ctx, factory) - for _, j := range members { - job, ok := queue.jobs[j.Number] - err := queue.err - if err == nil && !ok { - allowed := workstreamMissingAllowed() - switch { - case !allowed: - err = errors.New("the missing-job read allowance was reached") - case ctx.Err() != nil: - err = ctx.Err() - default: - read := b.workstreams.job - if read == nil { - read = b.FactoryJob - } - job, err = read(ctx, j.Factory, j.Number) - if err == nil && job.ID != j.Number { - err = errors.New("the Factory returned another job") - } - } - } - var wire *appwire.Error - notFound := queue.err == nil && errors.As(err, &wire) && wire.Code == appwire.CodeNotFound - workstreamRememberRead(j, workstreamJobRead{job: job, err: err, notFound: notFound}) - } - }) - } - wg.Wait() - return out -} - -func (b *appBackend) workstreamFacts(jobs []workstream.Job) map[workstream.Job]workstream.JobFacts { - out := map[workstream.Job]workstream.JobFacts{} - for j, read := range b.workstreamJobReads(jobs) { - if read.err != nil { - continue - } - job := read.job - merged := job.PR != nil && job.PR.MergedAt != nil && !job.PR.MergedAt.IsZero() - // A job's money is all of its runs: Cost is only the current run once - // it has run again, LifetimeCost keeps the earlier ones, and an older - // factory that does not send LifetimeCost leaves Cost as the whole. - cost := max(job.Cost, job.LifetimeCost) - out[j] = workstream.JobFacts{State: job.State, Merged: &merged, CostUSD: &cost} - } - return out -} - -// Collection resolution uses the same bounded batch reader, with each job -// asked once even when it appears through several nested collections. -func (r *collectionResolver) prepareWorkstreamRefs(members []workspace.Member) { - jobs := []workstream.Job{} - refs := map[workstream.Job]workspace.Ref{} - for _, member := range members { - if member.Kind != workspace.FactoryJobKind { - continue - } - number, _ := strconv.Atoi(member.ID) - job := workstream.Job{Factory: member.Factory, Number: number} - jobs = append(jobs, job) - refs[job] = member.Ref - } - r.factoryTargets = map[workspace.Ref]appwire.Target{} - for job, read := range r.b.workstreamJobReads(jobs) { - var target appwire.Target - if read.err != nil { - if read.notFound { - target = appwire.Target{State: appwire.TargetDeleted} - } else { - target = appUnknown("the Factory", read.err) - target.Status = "unknown" - } - } else { - target = appwire.Target{State: appwire.TargetLive, Title: read.job.Title, Status: read.job.State, Repo: read.job.Repo} - } - r.factoryTargets[refs[job]] = target - } -} - -func (r *collectionResolver) prepareWorkstreamTree(level workspace.Level) { - members := []workspace.Member{} - var collect func(workspace.Level) - collect = func(level workspace.Level) { - for _, node := range level.Members { - if node.Kind == workspace.FactoryJobKind { - members = append(members, node.Member) - } - if node.Children != nil { - collect(*node.Children) - } - } - } - collect(level) - r.prepareWorkstreamRefs(members) -} - -// workstreamObserved adds the first observed start to the caller's -// receipt, so mutations announce their write and observation in one event. -func (b *appBackend) workstreamObserved(s *workstream.Store, w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts, c workstream.Change) (workstream.Workstream, workstream.Change, error) { - if !w.StartedAt.IsZero() { - return w, c, nil - } - states := map[workstream.Job]string{} - active := false - for _, j := range w.Jobs() { - states[j] = facts[j].State - switch states[j] { - case "queued", "running", "needs_you", "done": - active = true - } - } - if !active { - return w, c, nil - } - observed, err := s.ObserveJobStates(context.Background(), w.ID, states) - if err != nil { - return w, c, appWorkstreamError(err) - } - if c.ID == "" { - c = observed - } else { - for _, part := range observed.Parts { - if !slices.Contains(c.Parts, part) { - c.Parts = append(c.Parts, part) - } - } - } - // Another reader may have won the write, so reread even with an empty receipt. - w, err = s.Get(context.Background(), w.ID) - return w, c, appWorkstreamError(err) -} - -func (b *appBackend) workstreamSpend(id string) *float64 { - spend := b.workstreamAgentSpend(id) - if spend == nil { - return nil - } - s, err := b.workstreamStore(false) - if err != nil { - return nil - } - if s == nil { - return spend - } - wakes, err := s.WakeSpendByAgent(context.Background(), id) - if err != nil { - return nil - } - total := *spend + wakes - return &total -} - -func (b *appBackend) workstreamAgentSpend(id string) *float64 { - if id == "" { - return nil - } - if read := b.workstreams.spend; read != nil { - return read(id) - } - a, err := b.find(id) - if err != nil { - row, ok := workstreamAgentRow(id) - if !ok { - return nil - } - b.remember(row) - a, err = b.find(id) - if err != nil { - return nil - } - } - var transcript string - b.locked(func() { transcript = a.transcript }) - if transcript == "" { - return nil - } - if row, ok := session.ReadRows([]string{transcript})[transcript]; ok { - if meta, err := session.LoadMeta(row.Dir); err != nil || meta.ID == "" { - return nil - } - return &row.Spend - } - // The ordinary chat listing deliberately omits bare conversations. Their - // freshly recorded metadata still establishes known zero spend here. - meta, err := session.LoadMeta(session.DirOf(transcript)) - if err == nil && meta.ID != "" && meta.LastUserAt.IsZero() && meta.SpentUSD == 0 { - zero := 0.0 - return &zero - } - return nil -} - -// workstreamKeepsConversation protects a deliberately bare agent from both -// app and terminal blank-chat cleanup. Its durable owner is the SQLite row; -// an unreadable owner is a reason to retain a conversation, never to delete it. -func workstreamKeepsConversation(dir string) bool { - path := home.Join("v3", "collections.db") - if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - return false - } else if err != nil { - return true - } - s, err := workspace.Open(path) - if err != nil { - return true - } - defer s.Close() - kept := false - err = s.Read(context.Background(), func(tx *sql.Tx, version int) error { - if version < 3 { - return nil - } - return tx.QueryRow("SELECT EXISTS(SELECT 1 FROM workstreams WHERE agent_id=?)", filepath.Base(filepath.Clean(dir))).Scan(&kept) - }) - return kept || err != nil -} - -// workstreamAgentRow restores a bare agent omitted by the ordinary chat -// listing. Workstream reads already obtained its id from the durable owner; -// remembering it lets agents.open use the existing opener after a restart. -func workstreamAgentRow(id string) (session.SessionRow, bool) { - if id == "" || id != filepath.Base(id) || strings.HasPrefix(id, ".") { - return session.SessionRow{}, false - } - root := session.PlacesRoot() - buckets, err := os.ReadDir(root) - if err != nil { - return session.SessionRow{}, false - } - for _, bucket := range buckets { - if !bucket.IsDir() { - continue - } - dir := filepath.Join(root, bucket.Name(), id) - meta, err := session.LoadMeta(dir) - if err != nil || meta.ID != id || !meta.LastUserAt.IsZero() { - continue - } - transcript := (session.Place{Dir: dir}).Transcript() - if _, err := os.Stat(transcript); err != nil { - continue - } - return session.SessionRow{ID: id, Dir: dir, Transcript: transcript, Workspace: meta.Workspace, Owned: meta.Owned, Title: meta.Title, Model: meta.Model, Created: meta.Created, At: meta.Created, Spend: meta.SpentUSD, Archived: meta.Archived}, true - } - return session.SessionRow{}, false -} diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go deleted file mode 100644 index 5fc7e6c842..0000000000 --- a/cmd/codeaf/appworkstreams_agent.go +++ /dev/null @@ -1,374 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "net/url" - "strconv" - "sync/atomic" - "time" - - "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": - return workstreamAgentReadPage(ctx, s, w.ID, ask.PageID) - 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) - case "sources": - return workstreamAgentReadSources(ctx, s, w.ID) - case "source": - return workstreamAgentReadSource(ctx, s, w.ID, ask.SourceID) - } - return nil, errors.New("what must name a workstream part") -} - -func workstreamAgentReadPage(ctx context.Context, s *workstream.Store, id, pageID string) (json.RawMessage, error) { - p, err := workstreamAgentPage(ctx, s, id, pageID, true) - if err != nil { - return nil, err - } - suggestions, err := s.Suggestions(ctx, p.ID) - if err != nil { - return nil, err - } - open := make([]appwire.WorkstreamSuggestion, 0, len(suggestions)) - for _, suggestion := range suggestions { - open = append(open, appWorkstreamSuggestion(suggestion)) - } - return json.Marshal(struct { - appwire.WorkstreamPageNode - Markdown *string `json:"markdown"` - Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` - }{appWorkstreamPageNode(p.PageNode), p.Markdown, open}) -} - -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, true) - 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 - } - var proposal *workstream.GoAheadProposal - if p.GoAhead != nil { - proposal, err = d.workstreamGoAheadProposal(*p.GoAhead) - if err != nil { - return session.WorkstreamProposed{}, err - } - p.Kind, p.Text = "goahead", p.GoAhead.Why - } - 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, GoAhead: proposal}) - d.backend.workstreamChanged(change) - return session.WorkstreamProposed{ID: r.ID}, workstreamAgentError(err) -} - -// Source resolves a draft call once, so its conflict retry keeps that attribution. -func (d *workstreamAgentDoor) Source(ctx context.Context, asked *workstream.Source) (*workstream.Source, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return nil, err - } - return d.workstreamSource(ctx, s, w, asked) -} - -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 - } - if err := appWorkstreamSource(asked).Check(); err != nil { - return nil, err - } - 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) workstreamGoAheadProposal(ask session.WorkstreamGoAheadAsk) (*workstream.GoAheadProposal, error) { - if err := session.WorkstreamValidateGoAhead(ask, d.backend.now()); err != nil { - return nil, err - } - end, _ := time.Parse("2006-01-02", ask.EndsOn) - return &workstream.GoAheadProposal{Scope: ask.Scope, Factory: ask.Factory, Job: ask.Job, Wave: ask.Wave, MilestoneID: ask.MilestoneID, CapUSD: ask.CapUSD, EndsAt: end.Add(24*time.Hour - time.Second)}, nil -} - -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 (d *workstreamAgentDoor) SuggestEdits(ctx context.Context, ask session.WorkstreamSuggestionAsk) (session.WorkstreamSuggested, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return session.WorkstreamSuggested{}, err - } - page, err := workstreamAgentPage(ctx, s, w.ID, ask.PageID, false) - if err != nil { - return session.WorkstreamSuggested{}, err - } - suggestion, change, err := s.CreateSuggestion(ctx, workstream.SuggestionCreate{WorkstreamID: w.ID, PageID: ask.PageID, BaseVersion: ask.BaseVersion, Why: ask.Why, Edits: ask.Edits, Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: d.agentID}}}) - d.backend.workstreamChanged(change) - if errors.Is(err, workstream.ErrTooLarge) { - return session.WorkstreamSuggested{}, errors.New("This workstream holds too many suggestions; nothing was suggested.") - } - return session.WorkstreamSuggested{ID: suggestion.ID, Title: page.Title, Count: len(suggestion.Edits)}, 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: workstream.ReadByJobsDefault(ask.Title), 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 deleted file mode 100644 index 929411bdbf..0000000000 --- a/cmd/codeaf/appworkstreams_agent_brief_test.go +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index 51512fbe34..0000000000 --- a/cmd/codeaf/appworkstreams_agent_done_test.go +++ /dev/null @@ -1,59 +0,0 @@ -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_sources.go b/cmd/codeaf/appworkstreams_agent_sources.go deleted file mode 100644 index a3d715deff..0000000000 --- a/cmd/codeaf/appworkstreams_agent_sources.go +++ /dev/null @@ -1,188 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "sort" - "strings" - "unicode/utf8" - - "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" -) - -var errWorkstreamSourceLink = errors.New("source is a link") - -func (d *workstreamAgentDoor) ProposeSource(ctx context.Context, ask session.WorkstreamSourceProposal) (session.WorkstreamSourceProposed, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return session.WorkstreamSourceProposed{}, err - } - item, already, change, err := s.ProposeSource(ctx, w.ID, ask.SourceAdd(), ask.Why) - if err != nil { - return session.WorkstreamSourceProposed{}, workstreamAgentError(err) - } - d.backend.workstreamChanged(change) - return session.WorkstreamSourceProposed{ID: item.ID, Already: already}, nil -} - -func workstreamAgentReadSources(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { - items, err := s.Sources(ctx, id) - if err != nil { - return nil, err - } - out := appwire.WorkstreamSourceListResult{Sources: []appwire.WorkstreamSourceItem{}} - for _, item := range items { - out.Sources = append(out.Sources, appWorkstreamSourceItem(item)) - } - return json.Marshal(out) -} - -func workstreamAgentReadSource(ctx context.Context, s *workstream.Store, id, sourceID string) (json.RawMessage, error) { - item, err := s.GetSource(ctx, sourceID) - if err != nil { - return nil, err - } - if item.WorkstreamID != id { - return nil, workstream.ErrNotFound - } - content, err := workstreamAgentSourceContent(ctx, s, item) - if err != nil { - return nil, err - } - return workstreamAgentSourceJSON(item, content) -} - -func workstreamAgentSourceContent(ctx context.Context, s *workstream.Store, item workstream.SourceItem) (string, error) { - switch item.Kind { - case workstream.SourceFile: - return workstreamAgentReadPath(item.Path, false), nil - case workstream.SourceRepo: - if filepath.IsAbs(item.Repo) { - return workstreamAgentReadPath(item.Repo, true), nil - } - case workstream.SourceKept: - return workstreamAgentReadKept(ctx, s, item) - } - return "This source is a pointer; its content is not read here.", nil -} - -func workstreamAgentReadKept(ctx context.Context, s *workstream.Store, item workstream.SourceItem) (string, error) { - switch item.Ref.Kind { - case "page": - page, err := workstreamAgentPage(ctx, s, item.WorkstreamID, item.Ref.ID, false) - if err != nil { - return "", err - } - if !page.DeletedAt.IsZero() { - return "The kept page is deleted.", nil - } - return pagedoc.Markdown(page.Doc) - case "record": - records, err := s.Records(ctx, item.WorkstreamID, "", "", true) - if err != nil { - return "", err - } - for _, record := range records { - if record.ID == item.Ref.ID { - return record.Text, nil - } - } - return "", workstream.ErrNotFound - } - return "This kept message or job is a pointer; its content is not read here.", nil -} - -func workstreamAgentReadPath(path string, folderOnly bool) string { - file, err := workstreamAgentOpenSourcePath(path) - if errors.Is(err, errWorkstreamSourceLink) { - return "The source is a link and was not read." - } - if err != nil { - return "The source is unreadable." - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return "The source is unreadable." - } - if folderOnly && !info.IsDir() { - return "The repo folder is unreadable." - } - if info.IsDir() { - return workstreamAgentReadFolder(file) - } - if !info.Mode().IsRegular() { - return "The source is not a regular text file." - } - data, err := io.ReadAll(io.LimitReader(file, session.WorkstreamSourceReadMax+1)) - if err != nil { - return "The file is unreadable." - } - cut := len(data) > session.WorkstreamSourceReadMax - if cut { - data = data[:session.WorkstreamSourceReadMax] - } - if cut { - data = workstreamAgentUTF8Tail(data) - } - if !utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0 { - return "The file is binary, not UTF-8 text." - } - if cut { - return string(data) + "\nThe file was cut." - } - return string(data) -} - -func workstreamAgentUTF8Tail(data []byte) []byte { - end := len(data) - for start := end - 1; start >= 0 && start >= end-utf8.UTFMax; start-- { - if !utf8.RuneStart(data[start]) { - continue - } - if !utf8.FullRune(data[start:]) { - return data[:start] - } - break - } - return data -} - -func workstreamAgentReadFolder(folder *os.File) string { - names, err := folder.Readdirnames(200) - if err != nil && err != io.EOF { - return "The folder is unreadable." - } - sort.Strings(names) - return strings.Join(names, "\n") -} - -func workstreamAgentSourceJSON(item workstream.SourceItem, content string) (json.RawMessage, error) { - out := struct { - Source appwire.WorkstreamSourceItem `json:"source"` - Content string `json:"content"` - }{Source: appWorkstreamSourceItem(item), Content: content} - // Bound the encoded answer too: escapes and metadata count toward the read cap. - for { - raw, err := json.Marshal(out) - if err != nil || len(raw) <= session.WorkstreamSourceReadMax { - return raw, err - } - end := len(out.Content) / 2 - for end > 0 && !utf8.RuneStart(out.Content[end]) { - end-- - } - tail := "\nThe source read was cut." - if item.Kind == workstream.SourceFile { - tail = "\nThe file was cut." - } - out.Content = out.Content[:end] + tail - } -} diff --git a/cmd/codeaf/appworkstreams_agent_sources_test.go b/cmd/codeaf/appworkstreams_agent_sources_test.go deleted file mode 100644 index 00f1ca07e3..0000000000 --- a/cmd/codeaf/appworkstreams_agent_sources_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func sessionWorkstreamSourceProposalForTest() session.WorkstreamSourceProposal { - return session.WorkstreamSourceProposal{Kind: "link", URL: workstreamPtr("https://example.com/guide"), Why: "Read the guide."} -} - -// WP-12 (g): the agent adds only a proposal, lists its exact stored fields and -// reads a text source; duplicate and invalid proposals add nothing else. -func TestWorkstreamAgentSourceProposalsAndReads(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - path := workstreamSourcesFile(t) - ask := session.WorkstreamSourceProposal{Kind: "file", Path: &path, Why: "Read recorded usage."} - before := workstreamLimitSnapshot(t, b, w.ID) - events := len(workstreamEvents(rec)) - proposal, err := door.ProposeSource(ctx, ask) - if err != nil || proposal.ID == "" || proposal.Already { - t.Fatalf("proposal: %+v %v", proposal, err) - } - again, err := door.ProposeSource(ctx, ask) - if err != nil || !again.Already || again.ID != proposal.ID || len(workstreamEvents(rec)) != events+1 { - t.Fatalf("duplicate: %+v %v", again, err) - } - listed, err := door.Read(ctx, session.WorkstreamReadAsk{What: "sources"}) - if err != nil { - t.Fatal(err) - } - var list appwire.WorkstreamSourceListResult - if err := json.Unmarshal(listed, &list); err != nil { - t.Fatal(err) - } - if len(list.Sources) != 1 { - t.Fatalf("list: %s", listed) - } - item := list.Sources[0] - if item.ID != proposal.ID || item.Kind != "file" || item.Path == nil || *item.Path != path || !item.Proposed || item.ReadByJobs || item.AddedBy != "agent" || item.Why == nil || *item.Why != ask.Why { - t.Fatalf("proposal row: %+v", item) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: proposal.ID}) - if err != nil { - t.Fatal(err) - } - var read struct { - Source appwire.WorkstreamSourceItem `json:"source"` - Content string `json:"content"` - } - if err := json.Unmarshal(raw, &read); err != nil { - t.Fatal(err) - } - if read.Content != "Recorded usage.\n" || read.Source.ID != proposal.ID { - t.Fatalf("source read: %s", raw) - } - for _, bad := range []session.WorkstreamSourceProposal{{Kind: "file", Path: workstreamPtr("relative"), Why: "Reason"}, {Kind: "link", URL: workstreamPtr("file:///etc/passwd"), Why: "Reason"}, {Kind: "link", URL: workstreamPtr("https://example.com/other"), Why: ""}} { - if _, err := door.ProposeSource(ctx, bad); err == nil { - t.Fatal("invalid proposal succeeded") - } - } - after := workstreamLimitSnapshot(t, b, w.ID) - if before.Pages != after.Pages || before.Records != after.Records || before.Versions != after.Versions || !reflect.DeepEqual(before.Row.Members, after.Row.Members) || len(workstreamEvents(rec)) != events+1 { - t.Fatal("source proposals changed unrelated state") - } - if _, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: "missing"}); err == nil { - t.Fatal("unknown source read succeeded") - } - other := workstreamCreateForTest(t, b, "Other source owner") - foreign, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: other.ID, Kind: "file", Path: &path}) - if err != nil { - t.Fatal(err) - } - if _, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: foreign.Source.ID}); err == nil { - t.Fatal("foreign source read succeeded") - } -} - -// WP-12 (g): reads use only the stored file/folder path, bound content and -// entry names, and explain binary or missing files in one line. -func TestWorkstreamAgentSourceFileReadBounds(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - path := workstreamSourcesFile(t) - file, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - data string - want string - }{ - {strings.Repeat("é", session.WorkstreamSourceReadMax), "The file was cut."}, - {strings.Repeat("\"", session.WorkstreamSourceReadMax), "The file was cut."}, - {"binary\x00bytes", "The file is binary, not UTF-8 text."}, - {string([]byte{255, 254}), "The file is binary, not UTF-8 text."}, - } { - if err := os.WriteFile(path, []byte(tc.data), 0600); err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: file.Source.ID}) - var answer struct { - Content string `json:"content"` - } - if err != nil || len(raw) > session.WorkstreamSourceReadMax || !utf8.Valid(raw) { - t.Fatalf("bounded source: %d %v", len(raw), err) - } - if err := json.Unmarshal(raw, &answer); err != nil || !strings.Contains(answer.Content, tc.want) { - t.Fatalf("content: %q %v", answer.Content, err) - } - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: file.Source.ID}) - if err != nil || !strings.Contains(string(raw), "unreadable") { - t.Fatalf("missing file: %s %v", raw, err) - } - folder := t.TempDir() - for i := 0; i < 205; i++ { - if err := os.WriteFile(filepath.Join(folder, fmt.Sprintf("entry-%03d", i)), []byte("child text must not be read"), 0600); err != nil { - t.Fatal(err) - } - } - for _, kind := range []string{"file", "repo"} { - ask := appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: kind} - if kind == "file" { - ask.Path = &folder - } else { - ask.Repo = &folder - } - made, err := b.WorkstreamSourceAdd(ask) - if err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: made.Source.ID}) - var answer struct { - Content string `json:"content"` - } - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &answer); err != nil || len(strings.Split(answer.Content, "\n")) != 200 || strings.Contains(answer.Content, "child text") { - t.Fatalf("folder names: %s %v", raw, err) - } - } -} - -// WP-12 Fix 1 Major 1 (g): final-component links never expose another file, -// whether present at admission or planted after an ordinary file was added. -func TestWorkstreamAgentMajor1ReadConfinementG(t *testing.T) { - for _, replacement := range []bool{false, true} { - t.Run(fmt.Sprintf("replacement=%t", replacement), func(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - path := workstreamSourcesFile(t) - target := filepath.Join(t.TempDir(), "other.txt") - const otherText = "Bytes from another readable file must never appear." - if err := os.WriteFile(target, []byte(otherText), 0600); err != nil { - t.Fatal(err) - } - plantLink := func() { - t.Helper() - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Symlink(target, path); err != nil { - t.Skipf("symlink unavailable: %v", err) - } - } - if !replacement { - plantLink() - } - item, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) - if err != nil { - t.Fatal(err) - } - if replacement { - plantLink() - } - raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) - if err != nil { - t.Fatal(err) - } - var answer struct { - Content string `json:"content"` - } - if err := json.Unmarshal(raw, &answer); err != nil { - t.Fatal(err) - } - if answer.Content != "The source is a link and was not read." || strings.Contains(string(raw), otherText) { - t.Fatalf("link read: %s", raw) - } - }) - } -} - -// WP-12 Fix 1 Major 1 (g): intermediate directory links remain usable, and -// folder reads return entry names without following file or directory links. -func TestWorkstreamAgentMajor1ReadConfinementGIntermediateAndEntries(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - folder := t.TempDir() - path := filepath.Join(folder, "text.txt") - if err := os.WriteFile(path, []byte("Stored file text."), 0600); err != nil { - t.Fatal(err) - } - link := filepath.Join(t.TempDir(), "folder") - if err := os.Symlink(folder, link); err != nil { - t.Skipf("symlink unavailable: %v", err) - } - for name, target := range map[string]string{"file-link": path, "folder-link": t.TempDir(), "missing-link": filepath.Join(folder, "missing")} { - if err := os.Symlink(target, filepath.Join(folder, name)); err != nil { - t.Fatal(err) - } - } - for _, tc := range []struct { - kind, path, want string - }{ - {"file", filepath.Join(link, "text.txt"), "Stored file text."}, - {"file", folder, "file-link\nfolder-link\nmissing-link\ntext.txt"}, - {"repo", folder, "file-link\nfolder-link\nmissing-link\ntext.txt"}, - } { - ask := appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: tc.kind} - if tc.kind == "repo" { - ask.Repo = &tc.path - } else { - ask.Path = &tc.path - } - item, err := b.WorkstreamSourceAdd(ask) - if err != nil { - t.Fatal(err) - } - raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) - var answer struct { - Content string `json:"content"` - } - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &answer); err != nil || answer.Content != tc.want { - t.Fatalf("%s read: %s %v", tc.kind, raw, err) - } - } -} - -// WP-12 (g): kept pages render markdown, records return text, and network, -// message and job sources stay pointers with no fetch or lookup. -func TestWorkstreamAgentKeptSourcesAndPointers(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Guide", Doc: json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Kept page words."}]}]}`)}) - if err != nil { - t.Fatal(err) - } - record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Kept record text."}) - if err != nil { - t.Fatal(err) - } - cases := []struct { - ask appwire.WorkstreamSourceAddParams - want string - }{ - {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "page", ID: page.Page.ID}}, "Kept page words."}, - {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: record.Record.ID}}, "Kept record text."}, - {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "message", ID: "unlooked-up", ChatID: workstreamPtr("chat")}}, "pointer"}, - {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "factory_job", ID: "7", Factory: workstreamPtr("f")}}, "pointer"}, - {appwire.WorkstreamSourceAddParams{Kind: "repo", Repo: workstreamPtr("owner/name")}, "pointer"}, - {appwire.WorkstreamSourceAddParams{Kind: "issue", Repo: workstreamPtr("owner/name"), Number: workstreamPtr(412)}, "pointer"}, - {appwire.WorkstreamSourceAddParams{Kind: "link", URL: workstreamPtr("https://example.com")}, "pointer"}, - } - for _, tc := range cases { - tc.ask.WorkstreamID = w.ID - made, err := b.WorkstreamSourceAdd(tc.ask) - if err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: made.Source.ID}) - if err != nil || !strings.Contains(string(raw), tc.want) { - t.Fatalf("kept read: %s %v", raw, err) - } - } -} - -// WP-12 (g), (h): a done state committed after resolving the agent door is -// still enforced by the store, with an empty change receipt. -func TestWorkstreamSourceProposalDoneAfterResolution(t *testing.T) { - _, _, _, door := workstreamAgentFixture(t) - ctx := context.Background() - s, w, err := door.(*workstreamAgentDoor).workstreamOwner(ctx, true) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - _, _, change, err := s.ProposeSource(ctx, w.ID, workstream.SourceAdd{Kind: "link", URL: workstreamPtr("https://example.com")}, "Read it.") - if workstreamAgentError(err) != session.ErrWorkstreamDone || len(change.Parts) != 0 { - t.Fatalf("late done: %+v %v", change, err) - } -} diff --git a/cmd/codeaf/appworkstreams_agent_test.go b/cmd/codeaf/appworkstreams_agent_test.go deleted file mode 100644 index df93039c21..0000000000 --- a/cmd/codeaf/appworkstreams_agent_test.go +++ /dev/null @@ -1,583 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "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"}, "Review"}, - {session.WorkstreamGoAheadAsk{Scope: "wave", Wave: 2, CapUSD: 20, EndsOn: "2026-11-04", Why: "Read"}, "Read"}, - {session.WorkstreamGoAheadAsk{Scope: "milestone", MilestoneID: "m1", CapUSD: 30, EndsOn: "2026-11-04", Why: "Ship"}, "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, "goahead", "proposed") - want := &workstream.GoAheadProposal{Scope: tc.ask.Scope, Factory: tc.ask.Factory, Job: tc.ask.Job, Wave: tc.ask.Wave, MilestoneID: tc.ask.MilestoneID, CapUSD: tc.ask.CapUSD, EndsAt: time.Date(2026, 11, 4, 23, 59, 59, 0, time.UTC)} - if err != nil || len(rows) == 0 || rows[0].ID != p.ID || rows[0].Text != tc.text || !reflect.DeepEqual(rows[0].GoAhead, want) || rows[0].GoAheadID != "" { - t.Fatalf("go-ahead proposal: %+v %v", rows, err) - } - } - rows, err := s.Records(ctx, w.ID, "", "", true) - 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") - } -} - -func TestStabEm1AgentSpecAndDecisionsPagesAreReadByJobs(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - title string - want bool - }{{"Spec", true}, {" decisions ", true}, {" sPeC ", true}, {"DECISIONS", true}, {"Notes", false}} { - t.Run(tc.title, func(t *testing.T) { - made, err := door.WritePage(ctx, session.WorkstreamPageWrite{Title: tc.title, Markdown: "First", UpdatedFrom: "Discussion"}) - if err != nil { - t.Fatal(err) - } - page, err := s.Page(ctx, made.ID, false) - if err != nil || page.ReadByJobs != tc.want { - t.Fatalf("agent default: read_by_jobs=%v, want %v: %v", page.ReadByJobs, tc.want, err) - } - wire, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: tc.title}) - if err != nil || wire.Page.ReadByJobs != tc.want { - t.Fatalf("page.create default: read_by_jobs=%v, want %v: %v", wire.Page.ReadByJobs, tc.want, err) - } - // Explicit choices survive later agent writes, even for these default titles. - for _, read := range []bool{false, true} { - if _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: page.ID, ReadByJobs: &read}); err != nil { - t.Fatal(err) - } - if _, err := door.WritePage(ctx, session.WorkstreamPageWrite{PageID: page.ID, Markdown: "Updated", UpdatedFrom: "Review"}); err != nil { - t.Fatal(err) - } - page, err = s.Page(ctx, page.ID, false) - if err != nil || page.ReadByJobs != read { - t.Fatalf("agent update: read_by_jobs=%v, want %v: %v", page.ReadByJobs, read, err) - } - } - }) - } -} - -func TestStabEM4PlanWireBoundsAreAtomic(t *testing.T) { - for _, name := range []string{"draft text", "milestones", "waves", "items", "encoded JSON"} { - t.Run(name, func(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - params := appwire.WorkstreamPlanSetParams{ID: w.ID, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{{N: 1, Items: []appwire.WorkstreamPlanItem{{Kind: "draft", DraftID: workstreamPtr("d"), Title: workstreamPtr("Draft")}}}}} - switch name { - case "draft text": - params.Waves[0].Items[0].Text = workstreamPtr(strings.Repeat("x", workstream.PlanDraftTextMax)) - case "milestones": - for range workstream.PlanMilestonesMax { - params.Milestones = append(params.Milestones, appwire.WorkstreamMilestone{Title: "Milestone"}) - } - case "waves": - params.Waves = []appwire.WorkstreamWave{} - for i := range workstream.PlanWavesMax { - params.Waves = append(params.Waves, appwire.WorkstreamWave{N: i + 1, Items: []appwire.WorkstreamPlanItem{}}) - } - case "items": - item := params.Waves[0].Items[0] - params.Waves[0].Items = []appwire.WorkstreamPlanItem{} - for range workstream.PlanItemsMax { - params.Waves[0].Items = append(params.Waves[0].Items, item) - } - } - var before appwire.WorkstreamPlanSetResult - if err := json.Unmarshal(workstreamWireEncode(t, workstreamWireResult(t, connection.call(t, "workstream.plan_set", params))), &before); err != nil { - t.Fatal(err) - } - if before.Plan.Version != 1 { - t.Fatalf("at bound: version=%d", before.Plan.Version) - } - params.BaseVersion = before.Plan.Version - switch name { - case "draft text": - params.Waves[0].Items[0].Text = workstreamPtr(*params.Waves[0].Items[0].Text + "x") - case "milestones": - params.Milestones = append(params.Milestones, appwire.WorkstreamMilestone{Title: "Extra"}) - case "waves": - params.Waves = append(params.Waves, appwire.WorkstreamWave{N: workstream.PlanWavesMax + 1, Items: []appwire.WorkstreamPlanItem{}}) - case "items": - params.Waves[0].Items = append(params.Waves[0].Items, params.Waves[0].Items[0]) - case "encoded JSON": - item := params.Waves[0].Items[0] - // JSON escapes each '<' to six bytes, while every draft stays below its text bound. - item.Text = workstreamPtr(strings.Repeat("<", workstream.PlanDraftTextMax)) - params.Waves[0].Items = []appwire.WorkstreamPlanItem{} - for range 9 { - params.Waves[0].Items = append(params.Waves[0].Items, item) - } - } - state := workstreamLimitSnapshot(t, b, w.ID) - events := len(workstreamEvents(rec)) - workstreamLimitWireRefusal(t, connection, "workstream.plan_set", params) - if name == "items" { - // The agent uses the same store authority and must receive the actual limit. - plan, err := door.Plan(ctx) - if err != nil { - t.Fatal(err) - } - agentPlan := plan - agentPlan.Waves[0].Items = append(agentPlan.Waves[0].Items, workstream.PlanItem{Kind: "draft", DraftID: "extra", Title: "Extra"}) - _, err = door.SetPlan(ctx, plan.Version, agentPlan) - if !errors.Is(err, workstream.ErrLimit) || !strings.Contains(err.Error(), "5000 items") || len(workstreamEvents(rec)) != events { - t.Fatalf("agent plan limit: %v", err) - } - } - after, err := b.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{ID: w.ID}) - if err != nil || !reflect.DeepEqual(before.Plan, after.Plan) || !reflect.DeepEqual(state, workstreamLimitSnapshot(t, b, w.ID)) || len(workstreamEvents(rec)) != events { - t.Fatalf("refused plan changed state: version=%d: %v", after.Plan.Version, err) - } - }) - } -} - -func TestStabEM4PageCountIncludesDeleted(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - // Together with Overview, these rows leave exactly one page slot. - if err := b.collections.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `WITH RECURSIVE workstream_seed(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM workstream_seed WHERE n= total { - return nil - } - _, err := tx.Exec(`WITH RECURSIVE workstream_seed(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM workstream_seed WHERE n 0 { - comparison.Differed[itemPath] = true - // The question permits only the shape explicitly illustrated for - // this element, so it cannot hide new keys, nulls or wrong kinds. - differences = comparison.shape(itemPath, item, example[i], workstreamWirePreparedShape) - } - failures = append(failures, differences...) - } - } - return failures -} - -func workstreamWireCheck(t *testing.T, failures []string) { - t.Helper() - for _, failure := range failures { - t.Error(failure) - } -} - -func workstreamWireLoad(t *testing.T) (workstreamWireContract, map[string]workstreamWireExamples) { - t.Helper() - root := filepath.Join("testdata", "workstreams-wire") - raw, err := os.ReadFile(filepath.Join(root, "contract.json")) - if err != nil { - t.Fatal(err) - } - var contract workstreamWireContract - if err := json.Unmarshal(raw, &contract); err != nil { - t.Fatal(err) - } - paths, err := filepath.Glob(filepath.Join(root, "*.json")) - if err != nil { - t.Fatal(err) - } - files := make(map[string]workstreamWireExamples) - for _, path := range paths { - name := strings.TrimSuffix(filepath.Base(path), ".json") - if name == "contract" { - continue - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - var examples workstreamWireExamples - if err := json.Unmarshal(raw, &examples); err != nil { - t.Fatalf("%s: %v", path, err) - } - files[name] = examples - } - return contract, files -} - -// Warm would open real provider/process owners. The existing harness already -// supplies their fakes; method dispatch and Close still belong to appBackend. -type workstreamWireBackend struct{ *appBackend } - -func (*workstreamWireBackend) Warm() {} - -type workstreamWireUnavailable struct{ *appBackend } - -func (*workstreamWireUnavailable) Warm() {} -func (*workstreamWireUnavailable) ServesWorkstreams() bool { return false } - -const workstreamWireTimeout = 30 * time.Second - -type workstreamWireConnection struct { - In *io.PipeWriter - Lines <-chan []byte - Err <-chan error - Next int - Writes sync.WaitGroup -} - -func workstreamWireConnect(t *testing.T, backend appwire.Engine) *workstreamWireConnection { - t.Helper() - inR, inW := io.Pipe() - outR, outW := io.Pipe() - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - server := appwire.NewServer(outW) - go func() { - err := server.Serve(ctx, inR, appwire.Hello{Version: "test"}, backend) - _ = inR.Close() - _ = outW.Close() - done <- err - }() - lines := make(chan []byte, 32) - readErr := make(chan error, 1) - readerDone := make(chan struct{}) - go func() { - defer close(readerDone) - defer close(lines) - scanner := bufio.NewScanner(outR) - scanner.Buffer(make([]byte, 64<<10), 4<<20) - for scanner.Scan() { - select { - case lines <- bytes.Clone(scanner.Bytes()): - case <-ctx.Done(): - return - } - } - readErr <- scanner.Err() - }() - connection := &workstreamWireConnection{In: inW, Lines: lines, Err: readErr} - t.Cleanup(func() { - _ = inW.Close() - connection.Writes.Wait() - select { - case err := <-done: - if err != nil { - t.Errorf("wire Serve: %v", err) - } - case <-time.After(workstreamWireTimeout): - t.Error("wire Serve did not close the backend") - } - cancel() - _ = inR.Close() - _ = outR.Close() - select { - case <-readerDone: - case <-time.After(5 * time.Second): - t.Error("wire reader did not stop") - } - }) - hello := connection.read(t) - if hello["event"] != appwire.EventHello { - t.Fatalf("want hello before replies, got %v", hello) - } - return connection -} - -func (connection *workstreamWireConnection) read(t *testing.T) map[string]any { - t.Helper() - select { - case raw, open := <-connection.Lines: - if !open { - t.Fatalf("wire ended before reply: %v", <-connection.Err) - } - frame, ok := workstreamWireDecode(t, raw).(map[string]any) - if !ok { - t.Fatalf("wire frame is not an object: %s", raw) - } - return frame - case <-time.After(workstreamWireTimeout): - t.Fatal("wire reply timed out") - return nil - } -} - -func (connection *workstreamWireConnection) call(t *testing.T, method string, params any) map[string]any { - t.Helper() - raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": method, "params": params}) - return connection.callRaw(t, raw) -} - -func (connection *workstreamWireConnection) callRaw(t *testing.T, raw []byte) map[string]any { - t.Helper() - connection.Next++ - writeErr := make(chan error, 1) - connection.Writes.Add(1) - go func() { - defer connection.Writes.Done() - _, err := connection.In.Write(append(raw, '\n')) - writeErr <- err - }() - select { - case err := <-writeErr: - if err != nil { - t.Fatal(err) - } - case <-time.After(workstreamWireTimeout): - t.Fatal("wire request write timed out") - } - for { - frame := connection.read(t) - id, reply := frame["id"].(json.Number) - if !reply { - if _, event := frame["event"].(string); !event { - t.Fatalf("frame has neither a numeric id nor an event: %v", frame) - } - continue - } - if id.String() != strconv.Itoa(connection.Next) { - t.Fatalf("reply id %s, want %d", id, connection.Next) - } - if len(frame) != 2 { - t.Fatalf("reply must have id and one result or error, got keys %v", workstreamWireKeys(frame)) - } - return frame - } -} - -func workstreamWireResult(t *testing.T, frame map[string]any) any { - t.Helper() - result, present := frame["result"] - if !present || result == nil { - t.Fatalf("want result without error, got %v", frame) - } - return result -} - -type workstreamWireFixture struct { - Backend *appBackend - Recorder *appRecorder - IDs map[string]string - Now time.Time - Stream appwire.Workstream -} - -func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWireFixture { - t.Helper() - backend, recorder, _, origin := workstreamBackend(t) - now := time.Now().UTC().Truncate(time.Millisecond) - backend.now = func() time.Time { return now } - backend.workstreams.archiveChats = func([]string) error { return nil } - fixture := &workstreamWireFixture{ - Backend: backend, Recorder: recorder, Now: now, - IDs: map[string]string{"conv_5b9e": origin.ID, "ms_usage": "conformance-milestone", "draft_418c": "conformance-draft"}, - } - fixture.IDs["/Users/ada/billing/usage-pricing.md"] = workstreamSourcesFile(t) - if name == "workstream.create" { - // An unobserved member job keeps all optional numbers unknown on create. - return fixture - } - zero := name == "workstream.get" && situation == 1 - unknown := name == "workstream.list" && situation == 1 - params := appwire.WorkstreamCreateParams{Name: "Usage-based billing", Why: workstreamPtr("Charge for recorded usage.")} - if !zero { - // WP-F2 (c), (f): the unknown list example deliberately has no origin. - if !unknown { - params.OriginChatID = &origin.ID - } - if name != "collections.add" { - ref := workstreamJobRef("1107") - ref.Factory = workstreamPtr("f_local") - params.Refs = []appwire.WorkstreamRef{ref} - } - } - if zero { - params.Why = nil - } - made, err := backend.WorkstreamCreate(params) - if err != nil { - t.Fatal(err) - } - fixture.Stream = made.Workstream - fixture.IDs["ws_8f2c1a"] = made.Workstream.ID - fixture.IDs["conv_8d22"] = made.Workstream.AgentID - if unknown { - backend.workstreams.spend = func(string) *float64 { return nil } - } else { - backend.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{{ID: 1107, Num: 2107, State: "needs_you", Cost: 2.5}}}, nil - } - } - if !zero && !unknown && strings.HasPrefix(name, "workstream.") && name != "workstream.changed" { - if _, err := backend.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: made.Workstream.ID, Patch: appwire.WorkstreamPatch{BudgetUSD: workstreamPtr(100.0)}}); err != nil { - t.Fatal(err) - } - } - pages, err := backend.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: made.Workstream.ID}) - if err != nil || len(pages.Pages) != 1 { - t.Fatalf("seed Overview: %+v, %v", pages, err) - } - fixture.IDs["pg_31d0"] = pages.Pages[0].ID - switch name { - case "wake.list", "wake.apply", "wake.undo", "wake.not_now": - fixture.wake(t, name) - case "workstream.reopen": - if _, err := backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: made.Workstream.ID}); err != nil { - t.Fatal(err) - } - case "page.update", "page.delete", "page.move", "workstream.changed": - parent := fixture.IDs["pg_31d0"] - if name == "page.move" { - parent = "" - } - fixture.page(t, "pg_32e1", parent) - if name == "page.move" && situation == 1 { - fixture.page(t, "pg_548c", fixture.IDs["pg_31d0"]) - } - if name == "page.delete" { - fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"]) - } - case "page.history": - fixture.saveByAgent(t) - case "page.save": - // WP-F2 (b), (f): example_1 saves version 3 with a source. - if situation == 1 { - if _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: fixture.IDs["pg_31d0"], BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { - t.Fatal(err) - } - } - // WP-F1 (g): prepare all three additive example_1 pairs with the exact - // optional keys illustrated by the wire contract. - case "page.list": - if situation == 1 { - fixture.deletedPage(t) - } - case "page.get": - if situation == 1 { - fixture.saveByAgent(t) - } - case "page.restore": - if situation == 1 { - fixture.deletedPage(t) - } else { - fixture.saveByAgent(t) - } - case "suggestion.list", "suggestion.keep", "suggestion.drop": - fixture.suggestion(t) - if name == "suggestion.keep" && situation == 1 { - if _, err := backend.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: fixture.IDs["sg_7c41"], EditID: workstreamPtr("e1")}); err != nil { - t.Fatal(err) - } - } - } - if name == "record.list" || name == "record.keep" || name == "record.drop" || name == "record.supersede" || name == "workstream.changed" { - // WP-F2 (d), (f): only the illustrated kind is present in each example. - if situation == 1 && (name == "record.list" || name == "record.keep") { - fixture.plan(t) - fixture.goaheadRecord(t) - } else { - fixture.record(t) - } - if name == "record.supersede" { - if _, err := backend.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: fixture.IDs["rec_7a1e"]}); err != nil { - t.Fatal(err) - } - } - } - if name == "record.propose" && situation == 1 { - fixture.plan(t) - } - if strings.HasPrefix(name, "goahead.") || name == "workstream.changed" { - fixture.plan(t) - if name != "goahead.grant" { - fixture.grant(t) - } - } - if name == "source.list" || name == "source.update" || name == "source.remove" { - fixture.sources(t) - } - return fixture -} - -func (fixture *workstreamWireFixture) page(t *testing.T, alias, parent string) { - t.Helper() - params := appwire.WorkstreamPageCreateParams{WorkstreamID: fixture.Stream.ID, Title: "Metering decisions"} - if parent != "" { - params.ParentID = &parent - } - page, err := fixture.Backend.WorkstreamPageCreate(params) - if err != nil { - t.Fatal(err) - } - fixture.IDs[alias] = page.Page.ID -} - -// WP-6d conformance: both anchors and every optional key match the wire contract. -func (fixture *workstreamWireFixture) suggestion(t *testing.T) { - t.Helper() - s, err := fixture.Backend.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - id := fixture.IDs["pg_31d0"] - doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Meter usage nightly."}]},{"type":"paragraph","content":[{"type":"text","text":"Bill at month end."}]}]}`) - page, err := s.Page(context.Background(), id, false) - if err != nil { - t.Fatal(err) - } - for page.Version < 3 { - page, _, err = s.SavePage(context.Background(), id, page.Version, doc, workstream.Attribution{}) - if err != nil { - t.Fatal(err) - } - } - suggestion, _, err := s.CreateSuggestion(context.Background(), workstream.SuggestionCreate{WorkstreamID: fixture.Stream.ID, PageID: id, BaseVersion: page.Version, Why: "api#1107 moved metering to hourly", Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: fixture.Stream.AgentID}}, Edits: []workstream.SuggestionFind{ - {Find: "nightly", Replace: "every hour", Why: "The meter runs hourly since api#1107"}, - {Find: "Bill at month end.", Replace: "Invoices stay monthly. Bill at month end.", Why: "Says what did not change"}, - }}) - if err != nil { - t.Fatal(err) - } - fixture.IDs["sg_7c41"] = suggestion.ID -} - -func (fixture *workstreamWireFixture) deletedPage(t *testing.T) { - t.Helper() - fixture.page(t, "pg_33f2", fixture.IDs["pg_31d0"]) - id := fixture.IDs["pg_33f2"] - if _, err := fixture.Backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { - t.Fatal(err) - } - if _, err := fixture.Backend.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); err != nil { - t.Fatal(err) - } -} - -func (fixture *workstreamWireFixture) source() *appwire.WorkstreamSource { - return &appwire.WorkstreamSource{Kind: "message", ID: "msg_19a2", ChatID: workstreamPtr(fixture.IDs["conv_5b9e"]), Label: workstreamPtr("Billing scope")} -} - -func (fixture *workstreamWireFixture) saveByAgent(t *testing.T) { - t.Helper() - store, err := fixture.Backend.workstreamStore(false) - if err != nil || store == nil { - t.Fatalf("seed page store: %v", err) - } - // 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) - } -} - -func (fixture *workstreamWireFixture) record(t *testing.T) { - t.Helper() - record, err := fixture.Backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{ - WorkstreamID: fixture.Stream.ID, Kind: "question", Text: "Should retries count toward usage?", Source: fixture.source(), - }) - if err != nil { - t.Fatal(err) - } - fixture.IDs["rec_7a1e"] = record.Record.ID -} - -func (fixture *workstreamWireFixture) goaheadRecord(t *testing.T) { - t.Helper() - record, err := fixture.Backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{ - WorkstreamID: fixture.Stream.ID, Kind: "goahead", Text: "Wave 1 is small; let it run without asking.", Source: fixture.source(), - GoAhead: &appwire.WorkstreamGoAheadProposal{Scope: "wave", Wave: workstreamPtr(1), CapUSD: 40, EndsAt: fixture.Now.Add(time.Hour)}, - }) - if err != nil { - t.Fatal(err) - } - fixture.IDs["rec_5c20"] = record.Record.ID -} - -func (fixture *workstreamWireFixture) plan(t *testing.T) { - t.Helper() - _, err := fixture.Backend.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ - ID: fixture.Stream.ID, - Milestones: []appwire.WorkstreamMilestone{{ID: fixture.IDs["ms_usage"], Title: "Recorded usage"}}, - Waves: []appwire.WorkstreamWave{{N: 1, Title: workstreamPtr("Metering"), MilestoneID: workstreamPtr(fixture.IDs["ms_usage"]), Items: []appwire.WorkstreamPlanItem{ - {Kind: "job", Factory: workstreamPtr("f_local"), Job: workstreamPtr(1107)}, - {Kind: "draft", DraftID: workstreamPtr(fixture.IDs["draft_418c"]), Title: workstreamPtr("Expose usage totals"), Repo: workstreamPtr("acme/billing"), Text: workstreamPtr("Show the recorded usage.")}, - }}}, - }) - if err != nil { - t.Fatal(err) - } -} - -func (fixture *workstreamWireFixture) grant(t *testing.T) { - t.Helper() - grant, err := fixture.Backend.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{ - WorkstreamID: fixture.Stream.ID, Scope: "job", Factory: workstreamPtr("f_local"), Job: workstreamPtr(1107), CapUSD: 40, EndsAt: fixture.Now.Add(time.Hour), - }) - if err != nil { - t.Fatal(err) - } - fixture.IDs["ga_0c44"] = grant.GoAhead.ID -} - -// The decoded example is a fresh tree on every call. Only existing values are -// replaced; prepared shape comparison proves that keys and kinds survive. -func (fixture *workstreamWireFixture) prepare(t *testing.T, raw json.RawMessage) map[string]any { - t.Helper() - var replace func(any) any - replace = func(value any) any { - switch value := value.(type) { - case map[string]any: - for key, item := range value { - if key == "ends_at" { - value[key] = fixture.Now.Add(time.Hour).Format(time.RFC3339Nano) - } else { - value[key] = replace(item) - } - } - case []any: - for i, item := range value { - value[i] = replace(item) - } - case string: - if seeded, known := fixture.IDs[value]; known { - return seeded - } - } - return value - } - return replace(workstreamWireDecode(t, raw)).(map[string]any) -} - -func (contract *workstreamWireContract) errorType(code string) (workstreamWireErrorType, bool) { - for _, definition := range contract.Errors { - if definition.Code == code { - return definition, true - } - } - return workstreamWireErrorType{}, false -} - -func (contract *workstreamWireContract) checkError(t *testing.T, path string, value any, code string, example any, exactMessage bool) { - t.Helper() - object, ok := value.(map[string]any) - if !ok { - t.Fatalf("%s: want error object, got %s", path, workstreamWireKind(value)) - } - workstreamWireCheck(t, workstreamWireNoNulls(path, object)) - if object["code"] != code { - t.Errorf("%s.code: got %v, want %s", path, object["code"], code) - } - message, ok := object["message"].(string) - if !ok || strings.TrimSpace(message) == "" { - t.Errorf("%s.message: want non-empty string, got %v", path, object["message"]) - } - definition, known := contract.errorType(code) - if !known || definition.DeskLocal { - t.Fatalf("%s: %s is not an engine-sent contract error", path, code) - } - for key := range object { - if key != "code" && key != "message" && key != "detail" { - t.Errorf("%s.%s: unexpected error field", path, key) - } - } - detail, present := object["detail"] - if definition.DetailType == nil { - if present { - t.Errorf("%s.detail: must be absent for %s", path, code) - } - } else if !present { - t.Errorf("%s.detail: missing %s", path, *definition.DetailType) - } else { - workstreamWireCheck(t, contract.conforms(path+".detail", detail, *definition.DetailType)) - } - if example != nil { - comparison := &workstreamWireComparison{} - workstreamWireCheck(t, comparison.shape(path, object, example, workstreamWireReplyShape)) - if exactMessage && object["message"] != example.(map[string]any)["message"] { - t.Errorf("%s.message: got %q, want %q", path, message, example.(map[string]any)["message"]) - } - } -} - -type workstreamWireErrorCase struct { - Name string - Method string - Code string - Example string - ParamsExample int -} - -// TestAppWorkstreamWireConformance keeps the table independent of the fixtures: -// adding a contract method, event, file or engine-sent error must require a case. -func TestAppWorkstreamWireConformance(t *testing.T) { - contract, files := workstreamWireLoad(t) - cases := []string{ - "workstream.list", "workstream.get", "workstream.create", "workstream.update", - "workstream.mark_done", "workstream.reopen", "workstream.plan_get", "workstream.plan_set", - "page.list", "page.get", "page.save", "page.create", "page.update", "page.move", - "page.delete", "page.history", "page.restore", "record.list", "record.propose", - "record.keep", "record.drop", "record.supersede", "goahead.list", "goahead.grant", - "goahead.revoke", "wake.list", "wake.apply", "wake.undo", "wake.not_now", - "suggestion.list", "suggestion.keep", "suggestion.drop", - "source.list", "source.add", "source.update", "source.remove", "collections.add", "workstream.changed", - } - errorCases := []workstreamWireErrorCase{ - {Name: "bad_params", Method: "workstream.get", Code: "bad_params"}, - {Name: "not_found", Method: "workstream.get", Code: "not_found", Example: "workstream.get"}, - // WP-F1 (a), (b), (g): new version and restore error doors use real dispatch. - {Name: "get_unknown_version", Method: "page.get", Code: "not_found", ParamsExample: 1}, - {Name: "restore_version_only", Method: "page.restore", Code: "bad_params"}, - {Name: "restore_base_only", Method: "page.restore", Code: "bad_params"}, - {Name: "restore_live_id_only", Method: "page.restore", Code: "bad_params", ParamsExample: 1}, - {Name: "restore_deleted_version", Method: "page.restore", Code: "bad_params"}, - {Name: "conflict", Method: "workstream.create", Code: "conflict"}, - {Name: "collection_owner", Method: "collections.add", Code: "in_other_workstream", Example: "collections.add"}, - {Name: "create_owner", Method: "workstream.create", Code: "in_other_workstream", Example: "collections.add"}, - {Name: "page_version", Method: "page.save", Code: "version_conflict", Example: "page.save"}, - {Name: "plan_version", Method: "workstream.plan_set", Code: "version_conflict", Example: "page.save"}, - {Name: "suggestion_version", Method: "suggestion.keep", Code: "version_conflict", Example: "suggestion.keep"}, - {Name: "cycle", Method: "page.move", Code: "cycle"}, - {Name: "too_large", Method: "page.save", Code: "too_large"}, - {Name: "plan_too_large", Method: "workstream.plan_set", Code: "too_large"}, - {Name: "records_too_large", Method: "record.propose", Code: "too_large"}, - {Name: "unknown_method", Method: "workstream.list", Code: "unknown_method", Example: "workstream.list"}, - {Name: "busy", Method: "workstream.update", Code: "busy"}, - // WP-12 (b), (h): each sources door exercises its own refusals. - {Name: "source_list_bad", Method: "source.list", Code: "bad_params"}, - {Name: "source_list_missing", Method: "source.list", Code: "not_found"}, - {Name: "source_add_bad", Method: "source.add", Code: "bad_params"}, - {Name: "source_add_missing", Method: "source.add", Code: "not_found"}, - {Name: "source_add_done", Method: "source.add", Code: "conflict"}, - {Name: "source_add_limit", Method: "source.add", Code: "too_large"}, - {Name: "source_update_bad", Method: "source.update", Code: "bad_params"}, - {Name: "source_update_missing", Method: "source.update", Code: "not_found"}, - {Name: "source_update_done", Method: "source.update", Code: "conflict"}, - {Name: "source_remove_bad", Method: "source.remove", Code: "bad_params"}, - {Name: "source_remove_missing", Method: "source.remove", Code: "not_found"}, - {Name: "source_remove_done", Method: "source.remove", Code: "conflict"}, - } - comparison := &workstreamWireComparison{Questions: make(map[string]string), Differed: make(map[string]bool)} - for _, question := range workstreamWireDocQuestions { - if question.Path == "" || strings.TrimSpace(question.Question) == "" || comparison.Questions[question.Path] != "" { - t.Fatalf("invalid or duplicate doc question: %+v", question) - } - comparison.Questions[question.Path] = question.Question - } - - // (a), (c): coverage is checked in both directions, including auxiliary files - // and all engine-sent errors. No busy exemption is needed: it has a real case. - if !t.Run("coverage", func(t *testing.T) { - workstreamWireCoverage(t, &contract, files, cases, errorCases) - }) { - return - } - - // (b), (d): the same strict type validator checks all documentation examples. - t.Run("shape_rules", func(t *testing.T) { - for _, rule := range []struct { - Name string - Type string - JSON string - Valid bool - }{ - {"string", "string", `"text"`, true}, - {"string_number", "string", `1`, false}, - {"nano_time", "string (RFC 3339)", `"2026-11-02T14:05:00.123456789Z"`, true}, - {"bad_time", "string (RFC 3339)", `"yesterday"`, false}, - {"boolean", "boolean", `false`, true}, - {"boolean_string", "boolean", `"false"`, false}, - {"zero", "integer", `0`, true}, - {"integral_decimal", "integer", `1.0`, true}, - {"fraction", "integer", `1.5`, false}, - {"precise_fraction", "integer", `9007199254740992.1`, false}, - {"negative", "integer", `-1`, false}, - {"numeric_string", "integer", `"1"`, false}, - {"usd", "number (USD)", `2.5`, true}, - {"usd_string", "number (USD)", `"2.5"`, false}, - {"enum", "WorkstreamStage", `"building"`, true}, - {"unknown_enum", "WorkstreamStage", `"working"`, false}, - {"empty_array", "WorkstreamChangedPart[]", `[]`, true}, - {"unknown_array_enum", "WorkstreamChangedPart[]", `["jobs","other"]`, false}, - {"null_array", "WorkstreamChangedPart[]", `null`, false}, - {"optional_absent", "WorkstreamChanged", `{"id":"seeded"}`, true}, - {"optional_null", "WorkstreamChanged", `{"id":"seeded","parts":null}`, false}, - {"missing_required", "WorkstreamChanged", `{"parts":[]}`, false}, - {"unknown_field", "WorkstreamChanged", `{"id":"seeded","extra":true}`, false}, - {"opaque_object", "object", `{"arbitrary":[{"value":0}]}`, true}, - {"opaque_nested_null", "object", `{"arbitrary":[null]}`, false}, - } { - t.Run(rule.Name, func(t *testing.T) { - path := "shape rule " + rule.Name + " " - failures := contract.conforms(path, workstreamWireDecode(t, []byte(rule.JSON)), rule.Type) - if (len(failures) == 0) != rule.Valid { - t.Errorf("valid = %v, want %v: %v", len(failures) == 0, rule.Valid, failures) - } - for _, failure := range failures { - if !strings.HasPrefix(failure, path) { - t.Errorf("failure lost its JSON path: %s", failure) - } - } - }) - } - }) - - // (b), (d): missing reply tails cannot skip illustrated shapes; events may - // report fewer changed parts, and prepared arrays must retain exact lengths. - t.Run("array_coverage", func(t *testing.T) { - example := `[{"job":1},{"draft":"draft"}]` - for _, rule := range []struct { - Name string - JSON string - Example string - Mode workstreamWireShapeMode - Valid bool - }{ - {"same_index", example, example, workstreamWireReplyShape, true}, - {"missing_tail", `[{"job":1}]`, example, workstreamWireReplyShape, false}, - {"empty_reply", `[]`, example, workstreamWireReplyShape, false}, - {"nested_missing_tail", `{"items":[{"job":1}]}`, `{"items":` + example + `}`, workstreamWireReplyShape, false}, - {"additional_first_shape", `[{"job":1},{"draft":"draft"},{"job":2}]`, example, workstreamWireReplyShape, true}, - {"additional_wrong_shape", `[{"job":1},{"draft":"draft"},{"draft":"other"}]`, example, workstreamWireReplyShape, false}, - {"empty_example", `[{"job":1}]`, `[]`, workstreamWireReplyShape, true}, - {"prepared_short", `[{"job":1}]`, example, workstreamWirePreparedShape, false}, - {"prepared_long", `[{"job":1},{"draft":"draft"},{"job":2}]`, example, workstreamWirePreparedShape, false}, - {"event_fewer_parts", `{"parts":["pages"]}`, `{"parts":["pages","plan"]}`, workstreamWireEventShape, true}, - {"event_empty_parts", `{"parts":[]}`, `{"parts":["pages","plan"]}`, workstreamWireEventShape, false}, - } { - t.Run(rule.Name, func(t *testing.T) { - path := "array coverage " + rule.Name + " " - comparison := &workstreamWireComparison{} - failures := comparison.shape(path, workstreamWireDecode(t, []byte(rule.JSON)), workstreamWireDecode(t, []byte(rule.Example)), rule.Mode) - if (len(failures) == 0) != rule.Valid { - t.Errorf("valid = %v, want %v: %v", len(failures) == 0, rule.Valid, failures) - } - for _, failure := range failures { - if !strings.HasPrefix(failure, path) { - t.Errorf("failure lost its JSON path: %s", failure) - } - } - }) - } - }) - - // (b), (d): malformed examples are contract failures, before any engine call. - t.Run("doc_self_check", func(t *testing.T) { - for _, name := range cases { - t.Run(name, func(t *testing.T) { - examples := files[name] - for _, part := range []struct { - Name string - Type string - Rows []json.RawMessage - }{ - {"params", examples.ParamsType, examples.Examples.Params}, - {"result", examples.ResultType, examples.Examples.Result}, - {"data", examples.DataType, examples.Examples.Data}, - } { - for i, raw := range part.Rows { - typeString := part.Type - if name == "collections.add" { - typeString = "object" - } - path := fmt.Sprintf("%s %s[%d] ", name, part.Name, i) - workstreamWireCheck(t, contract.conforms(path, workstreamWireDecode(t, raw), typeString)) - } - } - for i, raw := range examples.Examples.Error { - value := workstreamWireDecode(t, raw) - object, ok := value.(map[string]any) - if !ok { - t.Fatalf("%s error[%d] is not an object", name, i) - } - code, ok := object["code"].(string) - if !ok || (name != "collections.add" && !slices.Contains(examples.Errors, code)) { - t.Fatalf("%s error[%d] has unlisted code %v", name, i, object["code"]) - } - contract.checkError(t, fmt.Sprintf("%s error[%d]", name, i), value, code, nil, false) - } - }) - } - }) - - // (b), (d): each params/result pairing gets an isolated seeded situation. - // A single params example with multiple results exercises every situation. - for _, name := range cases { - t.Run(name, func(t *testing.T) { - examples := files[name] - if examples.Event != "" { - workstreamWireEvent(t, &contract, examples, comparison) - return - } - count := max(len(examples.Examples.Params), len(examples.Examples.Result)) - for i := 0; i < count; i++ { - t.Run(fmt.Sprintf("example_%d", i), func(t *testing.T) { - paramsIndex, resultIndex := i, i - if len(examples.Examples.Params) == 1 { - paramsIndex = 0 - } - if len(examples.Examples.Result) == 1 { - resultIndex = 0 - } - fixture := workstreamWireSeed(t, name, i) - params := fixture.prepare(t, examples.Examples.Params[paramsIndex]) - paramsPath := fmt.Sprintf("%s params[%d] ", name, paramsIndex) - workstreamWireCheck(t, comparison.shape(paramsPath, params, workstreamWireDecode(t, examples.Examples.Params[paramsIndex]), workstreamWirePreparedShape)) - if examples.ParamsType != "" { - workstreamWireCheck(t, contract.conforms(paramsPath, params, examples.ParamsType)) - } - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: fixture.Backend}) - result := workstreamWireResult(t, connection.call(t, name, params)) - resultPath := fmt.Sprintf("%s result[%d] ", name, resultIndex) - if examples.ResultType != "" { - workstreamWireCheck(t, contract.conforms(resultPath, result, examples.ResultType)) - } - workstreamWireCheck(t, workstreamWireNoNulls(resultPath, result)) - workstreamWireCheck(t, comparison.shape(resultPath, result, workstreamWireDecode(t, examples.Examples.Result[resultIndex]), workstreamWireReplyShape)) - if name == "page.delete" { - deleted, ok := result.(map[string]any)["deleted"].([]any) - want := []any{fixture.IDs["pg_32e1"], fixture.IDs["conformance-descendant"]} - if !ok || len(deleted) != len(want) || !slices.Contains(deleted, want[0]) || !slices.Contains(deleted, want[1]) { - t.Errorf("%s.deleted: want exactly seeded parent and descendant %v, got %v", resultPath, want, deleted) - } - } - if name == "collections.add" { - workstreamWireCollectionResult(t, result) - } - if name == "record.keep" && i == 1 { - workstreamWireKeptGoAhead(t, connection, fixture.Stream.ID, result) - } - }) - } - }) - } - - // (c): every engine-sent code is produced by a real wire call, including - // both ownership doors and both optimistic-version doors. - t.Run("errors", func(t *testing.T) { - for _, errorCase := range errorCases { - t.Run(errorCase.Name, func(t *testing.T) { - workstreamWireError(t, &contract, files, errorCase) - }) - } - }) - - // (d): fixing an example or changing the observed discrepancy requires - // removing its exception; doc questions cannot silently become permanent. - t.Run("doc_questions_are_current", func(t *testing.T) { - for _, question := range workstreamWireDocQuestions { - if !comparison.Differed[question.Path] { - t.Errorf("stale doc question at %s: %s", question.Path, question.Question) - } - } - }) -} - -// WP-F2 (d), (f): the keep example grants exactly its structured permission. -func workstreamWireKeptGoAhead(t *testing.T, connection *workstreamWireConnection, id string, result any) { - t.Helper() - record := result.(map[string]any)["record"].(map[string]any) - proposal := record["goahead"].(map[string]any) - listed := workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": id})).(map[string]any)["goaheads"].([]any) - if len(listed) != 1 { - t.Fatalf("keep granted %d go-aheads, want one", len(listed)) - } - grant := listed[0].(map[string]any) - if grant["id"] != record["goahead_id"] || grant["state"] != "active" { - t.Fatalf("keep grant identity/state: %v %v", record, grant) - } - for _, key := range []string{"scope", "wave", "cap_usd", "ends_at"} { - if !reflect.DeepEqual(grant[key], proposal[key]) { - t.Errorf("keep grant %s: got %v, want %v", key, grant[key], proposal[key]) - } - } -} - -func workstreamWireCoverage(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, cases []string, errorCases []workstreamWireErrorCase) { - t.Helper() - if len(contract.Methods) != 36 { - t.Errorf("the Suggestions addition (v0.5) has %d methods, want 36", len(contract.Methods)) - } - if len(contract.Events) != 1 || contract.Events[0].Event != appwire.EventWorkstreamChanged { - t.Errorf("the Suggestions addition (v0.5) events: want only workstream.changed, got %v", contract.Events) - } - if !reflect.DeepEqual(contract.Methods, appwire.WorkstreamMethods) { - t.Errorf("contract methods/order %v differ from appwire.WorkstreamMethods %v", contract.Methods, appwire.WorkstreamMethods) - } - counts := make(map[string]int) - for _, name := range cases { - counts[name]++ - if _, exists := files[name]; !exists { - t.Errorf("stale case %s has no example file", name) - } - } - events := make(map[string]string) - for _, event := range contract.Events { - if _, duplicate := events[event.Event]; duplicate { - t.Errorf("duplicate contract event %s", event.Event) - } - events[event.Event] = event.DataType - if _, exists := files[event.Event]; !exists || counts[event.Event] != 1 { - t.Errorf("event %s needs one file and one case", event.Event) - } - } - methods := make(map[string]bool) - for _, name := range contract.Methods { - if methods[name] { - t.Errorf("duplicate contract method %s", name) - } - methods[name] = true - if _, exists := files[name]; !exists || counts[name] != 1 { - t.Errorf("method %s needs one file and one case, got %d cases", name, counts[name]) - } - } - for name, examples := range files { - if counts[name] != 1 { - t.Errorf("file %s.json needs exactly one case, got %d", name, counts[name]) - } - if dataType, event := events[name]; event { - if examples.Event != name || examples.Method != "" || examples.DataType != dataType || len(examples.Examples.Data) == 0 { - t.Errorf("event file %s disagrees with contract or lacks examples", name) - } - continue - } - if !methods[name] && name != "collections.add" { - t.Errorf("file/case %s names no contract method or event", name) - } - if examples.Method != name || examples.Event != "" || len(examples.Examples.Params) == 0 || len(examples.Examples.Result) == 0 { - t.Errorf("method file %s disagrees with its name or lacks examples", name) - } - params, results := len(examples.Examples.Params), len(examples.Examples.Result) - if params != results && params != 1 && results != 1 { - t.Errorf("%s: unpaired example counts %d/%d need explicit situations", name, params, results) - } - if name != "collections.add" { - for _, typeString := range []string{examples.ParamsType, examples.ResultType} { - if _, known := contract.Types[typeString]; !known { - t.Errorf("%s: missing declared type %q", name, typeString) - } - } - } - for _, code := range examples.Errors { - if _, known := contract.errorType(code); !known { - t.Errorf("%s: unlisted error code %s", name, code) - } - } - } - if !slices.Contains(appwire.Methods(), "collections.add") { - t.Error("collections.add is not a registered wire method") - } - coveredErrors := make(map[string]bool) - caseNames := make(map[string]bool) - for _, errorCase := range errorCases { - if caseNames[errorCase.Name] { - t.Errorf("duplicate error case %s", errorCase.Name) - } - caseNames[errorCase.Name] = true - definition, known := contract.errorType(errorCase.Code) - if !known || definition.DeskLocal { - t.Errorf("stale error case %s: code %s is not engine-sent", errorCase.Name, errorCase.Code) - } - if _, exists := files[errorCase.Method]; !exists { - t.Errorf("stale error case %s: missing method file %s", errorCase.Name, errorCase.Method) - } - if errorCase.Method != "collections.add" && !slices.Contains(files[errorCase.Method].Errors, errorCase.Code) { - t.Errorf("%s does not list %s", errorCase.Method, errorCase.Code) - } - if errorCase.Example != "" { - found := false - for _, raw := range files[errorCase.Example].Examples.Error { - if workstreamWireDecode(t, raw).(map[string]any)["code"] == errorCase.Code { - found = true - } - } - if !found { - t.Errorf("stale error example %s/%s", errorCase.Example, errorCase.Code) - } - } - coveredErrors[errorCase.Code] = true - } - listedErrors := make(map[string]bool) - for _, definition := range contract.Errors { - if listedErrors[definition.Code] { - t.Errorf("duplicate contract error %s", definition.Code) - } - listedErrors[definition.Code] = true - if !definition.DeskLocal && !coveredErrors[definition.Code] { - t.Errorf("engine-sent contract error %s has no wire case", definition.Code) - } - } -} - -func workstreamWireCollectionResult(t *testing.T, result any) { - t.Helper() - object := result.(map[string]any) - added, addedArray := object["added"].([]any) - already, alreadyArray := object["already"].([]any) - if !addedArray || len(added) != 1 || !alreadyArray || len(already) != 0 { - t.Fatalf("collections.add result: want one added job and empty already array, got %v", result) - } - for i, value := range added { - member := value.(map[string]any) - if member["kind"] != "factory_job" || member["id"] != "1107" || member["factory"] != "f_local" { - t.Errorf("collections.add .added[%d]: wrong factory job ref %v", i, member) - } - at, ok := member["added_at"].(string) - if _, err := time.Parse(time.RFC3339Nano, at); !ok || err != nil { - t.Errorf("collections.add .added[%d].added_at: want RFC 3339 string, got %v", i, member["added_at"]) - } - } -} - -func workstreamWireEvent(t *testing.T, contract *workstreamWireContract, examples workstreamWireExamples, comparison *workstreamWireComparison) { - t.Helper() - fixture := workstreamWireSeed(t, "workstream.changed", 0) - before := len(workstreamEvents(fixture.Recorder)) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: fixture.Backend}) - workstreamWireResult(t, connection.call(t, "workstream.update", map[string]any{ - "id": fixture.Stream.ID, "patch": map[string]any{"why": "Updated through the wire"}, - })) - events := workstreamEvents(fixture.Recorder) - if len(events) <= before { - t.Fatal("wire mutation did not emit workstream.changed") - } - for i, event := range events { - value := workstreamWireDecode(t, workstreamWireEncode(t, event)) - path := fmt.Sprintf("workstream.changed data[%d] ", i) - workstreamWireCheck(t, contract.conforms(path, value, examples.DataType)) - exampleIndex := 0 - if _, parts := value.(map[string]any)["parts"]; !parts { - exampleIndex = 1 - } - if exampleIndex >= len(examples.Examples.Data) { - t.Fatalf("%s: no example for parts presence", path) - } - workstreamWireCheck(t, comparison.shape(path, value, workstreamWireDecode(t, examples.Examples.Data[exampleIndex]), workstreamWireEventShape)) - if event.ID != fixture.Stream.ID { - t.Errorf("%s.id: got %s, want seeded workstream %s", path, event.ID, fixture.Stream.ID) - } - } -} - -func workstreamWireError(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, errorCase workstreamWireErrorCase) { - t.Helper() - fixture := workstreamWireSeed(t, "workstream.get", 0) - if errorCase.Name == "suggestion_version" { - fixture.suggestion(t) - } - if errorCase.Method == "source.update" || errorCase.Method == "source.remove" { - fixture.sources(t) - } - backend := fixture.Backend - examples := files[errorCase.Method] - params := fixture.prepare(t, examples.Examples.Params[errorCase.ParamsExample]) - var engine appwire.Engine = &workstreamWireBackend{appBackend: backend} - release := func() {} - changedShape := false - switch errorCase.Name { - case "bad_params": - delete(params, "id") - changedShape = true - case "not_found": - params["id"] = "missing-workstream" - case "get_unknown_version": - params["version"] = json.Number("99") - case "restore_version_only": - delete(params, "base_version") - changedShape = true - case "restore_base_only": - delete(params, "version") - changedShape = true - case "restore_live_id_only": - params["id"] = fixture.IDs["pg_31d0"] - case "restore_deleted_version": - fixture.deletedPage(t) - params["id"] = fixture.IDs["pg_33f2"] - case "conflict": - params["name"] = fixture.Stream.Name - case "collection_owner": - other := workstreamCreateForTest(t, backend, "Account migration") - params["id"] = other.ID - case "create_owner": - params["name"] = "Another workstream" - case "page_version": - fixture.saveByAgent(t) - case "plan_version": - fixture.plan(t) - case "suggestion_version": - if _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: fixture.IDs["pg_31d0"], BaseVersion: 3, Doc: json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Meter usage daily."}]},{"type":"paragraph","content":[{"type":"text","text":"Bill at month end."}]}]}`)}); err != nil { - t.Fatal(err) - } - case "cycle": - fixture.page(t, "pg_32e1", fixture.IDs["pg_31d0"]) - fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"]) - params["id"] = fixture.IDs["pg_32e1"] - params["parent_id"] = fixture.IDs["conformance-descendant"] - case "too_large": - changedShape = true - params["doc"] = map[string]any{"type": "doc", "content": []any{ - map[string]any{"type": "paragraph", "content": []any{ - map[string]any{"type": "text", "text": strings.Repeat("x", (1<<20)+1)}, - }}, - }} - case "plan_too_large": - changedShape = true - params["milestones"] = []any{} - params["waves"] = []any{map[string]any{"n": 1, "auto_start": false, "items": []any{ - map[string]any{"kind": "draft", "draft_id": "oversize-draft", "title": "Draft", "text": strings.Repeat("x", workstream.PlanDraftTextMax+1)}, - }}} - case "records_too_large": - workstreamSeedRecords(t, backend, fixture.Stream.ID, workstream.RecordsMax) - case "unknown_method": - engine = &workstreamWireUnavailable{appBackend: backend} - case "busy": - release = workstreamWireHoldWriter(t, backend) - default: - if !workstreamWireSourceError(t, fixture, errorCase, params) { - t.Fatalf("unimplemented error case %s", errorCase.Name) - } - changedShape = strings.HasSuffix(errorCase.Name, "_bad") - } - if !changedShape { - comparison := &workstreamWireComparison{} - workstreamWireCheck(t, comparison.shape(errorCase.Method+" error params ", params, workstreamWireDecode(t, examples.Examples.Params[errorCase.ParamsExample]), workstreamWirePreparedShape)) - } - connection := workstreamWireConnect(t, engine) - frame := connection.call(t, errorCase.Method, params) - release() - value, present := frame["error"] - if !present { - t.Fatalf("%s: want %s error, got %v", errorCase.Method, errorCase.Code, frame) - } - var example any - if errorCase.Example != "" { - for _, raw := range files[errorCase.Example].Examples.Error { - candidate := workstreamWireDecode(t, raw).(map[string]any) - if candidate["code"] == errorCase.Code { - example = candidate - break - } - } - } - contract.checkError(t, errorCase.Method+" error", value, errorCase.Code, example, errorCase.Code == "unknown_method") - object := value.(map[string]any) - if errorCase.Code == "in_other_workstream" { - detail, ok := object["detail"].(map[string]any) - if !ok || detail["workstream_id"] != fixture.Stream.ID || detail["workstream_name"] != fixture.Stream.Name || detail["factory"] != "f_local" || detail["job"] != json.Number("1107") { - t.Errorf("ownership detail does not identify the seeded owner/job: %v", object["detail"]) - } - } - if errorCase.Code == "version_conflict" { - detail, ok := object["detail"].(map[string]any) - id, version, by := fixture.IDs["pg_31d0"], "2", "agent" - if errorCase.Name == "plan_version" { - id, version, by = fixture.Stream.ID, "1", "you" - } - if errorCase.Name == "suggestion_version" { - version, by = "4", "you" - } - if !ok || detail["id"] != id || detail["version"] != json.Number(version) || detail["updated_by"] != by { - t.Errorf("stale detail does not describe the current seeded version: %v", object["detail"]) - } - } -} - -// Hold a real writer on a second handle to the same store. Shorten only the -// backend's lock wait to 200 ms on its single connection so the case proves the -// real SQLITE_BUSY -> ErrBusy -> wire "busy" path without waiting ten seconds. -// Production keeps internal/workspace's 10-second wait. The refusal still says -// "(waited 10s)" because storeError uses that constant; conformance checks the -// code and a non-empty message, not that text. -func workstreamWireHoldWriter(t *testing.T, backend *appBackend) func() { - t.Helper() - if backend.collections == nil { - t.Fatal("busy fixture must seed the backend's collections store before holding a writer") - } - const workstreamBusyTimeoutMS = 200 - if err := backend.collections.Write(context.Background(), func(tx *sql.Tx) error { - _, err := tx.Exec(fmt.Sprintf("PRAGMA busy_timeout = %d", workstreamBusyTimeoutMS)) - return err - }); err != nil { - t.Fatalf("set backend busy timeout: %v", err) - } - var ms int - if err := backend.collections.Read(context.Background(), func(tx *sql.Tx, _ int) error { - return tx.QueryRow("PRAGMA busy_timeout").Scan(&ms) - }); err != nil { - t.Fatalf("read backend busy timeout: %v", err) - } - if ms != workstreamBusyTimeoutMS { - t.Fatalf("backend busy timeout = %d ms, want %d ms", ms, workstreamBusyTimeoutMS) - } - other, err := workspace.Open(backend.collectionsPath) - if err != nil { - t.Fatal(err) - } - entered := make(chan struct{}) - unlock := make(chan struct{}) - done := make(chan error, 1) - go func() { - done <- other.Write(context.Background(), func(*sql.Tx) error { - close(entered) - <-unlock - return nil - }) - }() - var once sync.Once - release := func() { - once.Do(func() { - close(unlock) - select { - case err := <-done: - if err != nil { - t.Errorf("held writer: %v", err) - } - case <-time.After(30 * time.Second): - t.Error("held writer did not release") - } - if err := other.Close(); err != nil { - t.Errorf("held writer close: %v", err) - } - }) - } - t.Cleanup(release) - select { - case <-entered: - case err := <-done: - // Put the result back so the shared cleanup can still consume it. - done <- err - t.Fatalf("writer did not take the lock: %v", err) - case <-time.After(30 * time.Second): - t.Fatal("writer did not take the lock") - } - return release -} - -// WP-11 (j): every wake example uses a fake turn and the real durable action path. -func (fixture *workstreamWireFixture) wake(t *testing.T, name string) { - t.Helper() - b := fixture.Backend - s, err := b.workstreamStore(false) - if err != nil || s == nil { - t.Fatal(err) - } - if name == "wake.list" { - small := "small" - if _, _, err := s.Update(context.Background(), fixture.Stream.ID, workstream.Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - _, _, err = s.SetPlan(context.Background(), fixture.Stream.ID, 0, nil, []workstream.Wave{{N: 1, Items: []workstream.PlanItem{{Kind: "job", Factory: "f_local", Job: 1107}}}, {N: 3, Items: []workstream.PlanItem{}}}, workstream.Attribution{}) - if err != nil { - t.Fatal(err) - } - } - b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - text := `{"summary":"Review the rollout.","items":[{"kind":"decision","text":"Roll out after review."}]}` - if name == "wake.list" { - text = `{"summary":"Applied: moved it to wave 3; updated Where it stands. Keep the rollout decision and consider a small follow-up job.","items":[{"kind":"move","factory":"f_local","job":1107,"to_wave":3},{"kind":"decision","text":"Roll out after review.","why":"The merge is ready for review."},{"kind":"draft","title":"Check rollout","budget_usd":3,"wave":99}]}` - } - return session.WorkstreamWakeAnswer{Text: text, CostUSD: workstreamPtr(.04), Model: "fake"}, nil - } - var task workstreamWakeTask - b.workstreams.wakeDispatch = func(v workstreamWakeTask) { task = v } - id, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f_local", Job: 1107, Kind: "merged", ID: "wire-contract", At: fixture.Now, Label: "api#1102 merged"}) - if !woke { - t.Fatal("seed wake was refused") - } - b.workstreamRunWake(context.Background(), task) - fixture.IDs["wake_11"] = id - runs, err := s.Wakes(context.Background(), fixture.Stream.ID, 20) - if err != nil || len(runs) != 1 || len(runs[0].Items) == 0 { - t.Fatalf("seed wake: %+v %v", runs, err) - } - item := runs[0].Items[0] - fixture.IDs["wi_decision"] = item.ID - if name == "wake.undo" { - if _, _, err := s.ActOnWake(context.Background(), id, item.ID, "apply"); err != nil { - t.Fatal(err) - } - } -} diff --git a/cmd/codeaf/appworkstreams_fields_v02_test.go b/cmd/codeaf/appworkstreams_fields_v02_test.go deleted file mode 100644 index 17859777e6..0000000000 --- a/cmd/codeaf/appworkstreams_fields_v02_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "math" - "reflect" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WP-F2 (a): real plan_set/get dispatch preserves draft fields and refuses atomically. -func TestAppWorkstreamDraftFieldsWireRoundTrip(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Draft wire", workstreamJobRef("7")) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - item := map[string]any{"kind": "draft", "draft_id": "d", "title": "Draft", "size": "M", "budget_usd": 25.0, "source": map[string]any{"kind": "message", "id": "m", "chat_id": "chat", "label": "Scope"}} - params := map[string]any{"id": w.ID, "base_version": 0, "milestones": []any{}, "waves": []any{map[string]any{"n": 1, "auto_start": false, "items": []any{item}}}} - set := workstreamWireResult(t, connection.call(t, "workstream.plan_set", params)) - get := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) - if !reflect.DeepEqual(set, get) { - t.Fatalf("round trip: %v %v", set, get) - } - draft := get.(map[string]any)["plan"].(map[string]any)["waves"].([]any)[0].(map[string]any)["items"].([]any)[0] - want := workstreamWireDecode(t, json.RawMessage(`{"kind":"draft","draft_id":"d","title":"Draft","size":"M","budget_usd":25,"source":{"kind":"message","id":"m","chat_id":"chat","label":"Scope"}}`)) - if !reflect.DeepEqual(draft, want) { - t.Fatalf("draft fields: %v", draft) - } - before := len(workstreamEvents(rec)) - for _, invalid := range []map[string]any{ - {"kind": "job", "factory": "f", "job": 7, "size": "S"}, - {"kind": "job", "factory": "f", "job": 7, "budget_usd": 1}, - {"kind": "job", "factory": "f", "job": 7, "source": map[string]any{"kind": "chat", "id": "c"}}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "size": "XL"}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": 0}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": -1}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": 10000.01}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "source": map[string]any{"kind": "message", "id": "m"}}, - } { - params["base_version"] = 1 - params["waves"] = []any{map[string]any{"n": 1, "auto_start": false, "items": []any{invalid}}} - frame := connection.call(t, "workstream.plan_set", params) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { - t.Fatalf("refusal: %v", frame) - } - after := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) - if !reflect.DeepEqual(after, get) { - t.Fatalf("refusal changed plan: %v", after) - } - } - _, err := b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: w.ID, BaseVersion: 1, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{{N: 1, Items: []appwire.WorkstreamPlanItem{{Kind: "draft", DraftID: workstreamPtr("d"), Title: workstreamPtr("Draft"), BudgetUSD: workstreamPtr(math.NaN())}}}}}) - workstreamCheckError(t, err, appwire.CodeBadParams) - after := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) - if !reflect.DeepEqual(after, get) { - t.Fatalf("NaN refusal changed plan: %v", after) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("refused plan emitted a change") - } -} - -// WP-F2 (b): save/create source belongs to that version, and absent source clears it. -func TestAppWorkstreamPageSourcesThroughWire(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Page source") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - source := map[string]any{"kind": "message", "id": "message", "chat_id": "chat", "label": "Scope"} - doc := map[string]any{"type": "doc", "content": []any{}} - created := workstreamWireResult(t, connection.call(t, "page.create", map[string]any{"workstream_id": w.ID, "title": "Notes", "source": source, "doc": doc})).(map[string]any)["page"].(map[string]any) - id := created["id"] - if !reflect.DeepEqual(created["updated_from"], source) { - t.Fatalf("create source: %v", created) - } - saved := workstreamWireResult(t, connection.call(t, "page.save", map[string]any{"id": id, "base_version": 1, "doc": doc, "source": source})).(map[string]any)["page"].(map[string]any) - if !reflect.DeepEqual(saved["updated_from"], source) { - t.Fatalf("save source: %v", saved) - } - last := workstreamWireResult(t, connection.call(t, "page.save", map[string]any{"id": id, "base_version": 2, "doc": doc})).(map[string]any)["page"].(map[string]any) - if _, exists := last["updated_from"]; exists { - t.Fatalf("source retained: %v", last) - } - history := workstreamWireResult(t, connection.call(t, "page.history", map[string]any{"id": id})).(map[string]any)["versions"].([]any) - if len(history) != 3 { - t.Fatalf("history: %v", history) - } - if _, exists := history[0].(map[string]any)["from"]; exists { - t.Fatalf("source retained in history: %v", history) - } - for _, version := range history[1:] { - if !reflect.DeepEqual(version.(map[string]any)["from"], source) { - t.Fatalf("version source: %v", version) - } - } - before := last - for _, method := range []string{"page.save", "page.create"} { - params := map[string]any{"id": id, "base_version": 3, "doc": doc, "workstream_id": w.ID, "title": "Invalid", "source": map[string]any{"kind": "message", "id": "m"}} - frame := connection.call(t, method, params) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { - t.Fatalf("invalid source: %v", frame) - } - } - current := workstreamWireResult(t, connection.call(t, "page.get", map[string]any{"id": id})).(map[string]any)["page"] - if !reflect.DeepEqual(current, before) { - t.Fatalf("invalid source changed page: %v", current) - } -} - -// WP-F2 (c): list gets both conversation identities from existing row data. -func TestAppWorkstreamListConversationIDsThroughWire(t *testing.T) { - b, _, _, origin := workstreamBackend(t) - first, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "With origin", OriginChatID: &origin.ID}) - if err != nil { - t.Fatal(err) - } - second := workstreamCreateForTest(t, b, "Without origin") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - rows := workstreamWireResult(t, connection.call(t, "workstream.list", map[string]any{})).(map[string]any)["workstreams"].([]any) - if len(rows) != 2 { - t.Fatalf("rows: %v", rows) - } - for _, raw := range rows { - row := raw.(map[string]any) - get := workstreamWireResult(t, connection.call(t, "workstream.get", map[string]any{"id": row["id"]})).(map[string]any)["workstream"].(map[string]any) - if row["agent_id"] == "" || row["agent_id"] != get["agent_id"] { - t.Fatalf("agent id: %v %v", row, get) - } - if row["id"] == first.Workstream.ID && row["origin_chat_id"] != origin.ID { - t.Fatalf("origin: %v", row) - } - if row["id"] == second.ID { - if _, exists := row["origin_chat_id"]; exists { - t.Fatalf("absent origin: %v", row) - } - } - } -} - -// WP-F2 (e): the adapter resolves real sources before any draft can be added. -func TestAppWorkstreamAgentDraftSourceResolution(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Spec"}) - if err != nil { - t.Fatal(err) - } - for _, asked := range []*workstream.Source{nil, {Kind: "page", ID: page.Page.ID}} { - source, err := door.Source(ctx, asked) - if err != nil { - t.Fatal(err) - } - want := &workstream.Source{Kind: "chat", ID: w.AgentID} - if asked != nil { - want = asked - } - if !reflect.DeepEqual(source, want) { - t.Fatalf("source: %+v", source) - } - plan, err := door.Plan(ctx) - if err != nil { - t.Fatal(err) - } - n := len(plan.Waves) + 1 - plan.Waves = append(plan.Waves, workstream.Wave{N: n, Items: []workstream.PlanItem{{Kind: "draft", DraftID: source.Kind, Title: "Draft", Source: source}}}) - saved, err := door.SetPlan(ctx, plan.Version, plan) - if err != nil || !reflect.DeepEqual(saved.Waves[n-1].Items[0].Source, want) { - t.Fatalf("saved source: %+v %v", saved, err) - } - } - before, err := door.Plan(ctx) - if err != nil { - t.Fatal(err) - } - for _, source := range []*workstream.Source{{Kind: "page", ID: "missing"}, {Kind: "factory_job", ID: "99", Factory: "f"}, {Kind: "url", ID: "file:///tmp/source"}, {Kind: "page", ID: page.Page.ID, ChatID: "chat"}} { - if _, err := door.Source(ctx, source); err == nil { - t.Fatalf("accepted source: %+v", source) - } - } - after, err := door.Plan(ctx) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("source refusal changed plan: %+v %v", after, err) - } -} - -// WP-F2 (e), (f): agent reads include proposed and kept go-ahead structures. -func TestAppWorkstreamAgentReadsGoAheadRecords(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - ask := session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 10, EndsOn: b.now().AddDate(0, 0, 89).Format("2006-01-02"), Why: "Review"} - proposal, err := door.Propose(ctx, session.WorkstreamProposal{GoAhead: &ask}) - if err != nil { - t.Fatal(err) - } - for _, kind := range []string{"", "goahead"} { - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "records", Kind: kind}) - var records appwire.WorkstreamRecordListResult - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &records); err != nil || len(records.Records) != 1 || records.Records[0].Kind != "goahead" || records.Records[0].GoAhead == nil { - t.Fatalf("read: %s %v", raw, err) - } - } - kept, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: proposal.ID}) - if err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "records"}) - var records appwire.WorkstreamRecordListResult - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &records); err != nil || len(records.Records) != 1 || !reflect.DeepEqual(records.Records[0], kept.Record) { - t.Fatalf("kept read: %s %v", raw, err) - } - grant, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID}) - if err != nil || len(grant.GoAheads) != 1 || grant.GoAheads[0].EndsAt.Hour() != 23 || grant.GoAheads[0].EndsAt.Minute() != 59 || grant.GoAheads[0].EndsAt.Second() != 59 { - t.Fatalf("end of day: %+v %v", grant, err) - } - ask.EndsOn = b.now().AddDate(0, 0, 90).Format("2006-01-02") - if _, err := door.Propose(ctx, session.WorkstreamProposal{GoAhead: &ask}); err == nil { - t.Fatal("90-day end accepted") - } -} - -// WP-F2 (d), (f): real wire dispatch exposes grants, undo and compatibility filters. -func TestAppWorkstreamGoAheadRecordsThroughWire(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) - b.now = func() time.Time { return now } - w := workstreamCreateForTest(t, b, "Record wire", workstreamJobRef("7")) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - proposal := map[string]any{"scope": "job", "factory": "f", "job": 7, "cap_usd": 12.5, "ends_at": now.Add(time.Hour).Format(time.RFC3339)} - raw := workstreamWireResult(t, connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": "goahead", "text": "Why", "goahead": proposal})).(map[string]any)["record"].(map[string]any) - id := raw["id"] - if raw["kind"] != "goahead" || raw["state"] != "proposed" || raw["text"] != "Why" { - t.Fatalf("proposal: %v", raw) - } - for _, kind := range []string{"decision", "question"} { - workstreamWireResult(t, connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": kind, "text": kind})) - } - for _, tc := range []struct { - kind string - include *bool - count int - }{{"", nil, 2}, {"", workstreamPtr(false), 2}, {"", workstreamPtr(true), 3}, {"goahead", nil, 1}, {"question", workstreamPtr(true), 1}} { - params := map[string]any{"workstream_id": w.ID} - if tc.kind != "" { - params["kind"] = tc.kind - } - if tc.include != nil { - params["include_goaheads"] = *tc.include - } - rows := workstreamWireResult(t, connection.call(t, "record.list", params)).(map[string]any)["records"].([]any) - if len(rows) != tc.count { - t.Fatalf("filter %+v: %v", tc, rows) - } - for _, row := range rows { - kind := row.(map[string]any)["kind"] - if tc.kind != "" && kind != tc.kind || tc.kind == "" && (tc.include == nil || !*tc.include) && kind == "goahead" { - t.Fatalf("filter %+v: %v", tc, rows) - } - } - } - kept := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) - grants := workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID})).(map[string]any)["goaheads"].([]any) - if len(grants) != 1 || grants[0].(map[string]any)["id"] != kept["goahead_id"] { - t.Fatalf("keep: %v %v", kept, grants) - } - before := len(workstreamEvents(rec)) - again := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"] - grants = workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID})).(map[string]any)["goaheads"].([]any) - if !reflect.DeepEqual(again, kept) || len(grants) != 1 || len(workstreamEvents(rec)) != before { - t.Fatal("repeat keep changed state") - } - for _, kind := range []string{"", "decision", "goahead"} { - params := map[string]any{"id": id, "text": "Replacement"} - if kind != "" { - params["kind"] = kind - } - frame := connection.call(t, "record.supersede", params) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { - t.Fatalf("supersede: %v", frame) - } - } - dropped := workstreamWireResult(t, connection.call(t, "record.drop", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) - grants = workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID, "ended": true})).(map[string]any)["goaheads"].([]any) - if dropped["state"] != "dropped" || dropped["goahead_id"] != kept["goahead_id"] || len(grants) != 1 || grants[0].(map[string]any)["end_reason"] != "revoked" { - t.Fatalf("drop: %v %v", dropped, grants) - } - newKeep := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) - if newKeep["goahead_id"] == kept["goahead_id"] { - t.Fatal("re-keep reused the grant") - } - workstreamWireResult(t, connection.call(t, "record.drop", map[string]any{"id": id})) - workstreamWireResult(t, connection.call(t, "workstream.mark_done", map[string]any{"id": w.ID})) - frame := connection.call(t, "record.keep", map[string]any{"id": id}) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "conflict" { - t.Fatalf("keep done: %v", frame) - } - frame = connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": "goahead", "text": "Why", "goahead": proposal}) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "conflict" { - t.Fatalf("propose done: %v", frame) - } -} diff --git a/cmd/codeaf/appworkstreams_pages_test.go b/cmd/codeaf/appworkstreams_pages_test.go deleted file mode 100644 index 1a521d06f6..0000000000 --- a/cmd/codeaf/appworkstreams_pages_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WP-F1 (c): creation alone chooses the title default; explicit booleans win, -// renames retain the existing choice, and the initial Overview remains off. -func TestAppWorkstreamPageReadByJobsDefaults(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Read defaults") - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil || len(pages.Pages) != 1 || pages.Pages[0].ReadByJobs { - t.Fatalf("initial Overview: %+v %v", pages, err) - } - for _, tc := range []struct { - title string - value *bool - want bool - }{ - {"Spec", nil, true}, {" decisions ", nil, true}, {"DECISIONS", nil, true}, - {"Spec notes", nil, false}, {"Overview", nil, false}, {"Notes", nil, false}, - {"Spec", workstreamPtr(false), false}, {"Notes", workstreamPtr(true), true}, - } { - t.Run(tc.title, func(t *testing.T) { - made, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: tc.title, ReadByJobs: tc.value}) - if err != nil || made.Page.ReadByJobs != tc.want { - t.Fatalf("created: %+v %v, want read_by_jobs=%v", made, err, tc.want) - } - rename := "Spec" - if tc.want { - rename = "Notes" - } - updated, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: made.Page.ID, Title: &rename}) - if err != nil || updated.Page.ReadByJobs != tc.want { - t.Fatalf("rename changed read_by_jobs: %+v %v", updated, err) - } - }) - } -} - -// WP-F1 (a): handlers expose historical content and attribution, optional -// markdown and current metadata, including deleted_at for a deleted page. -func TestAppWorkstreamPageGetVersion(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Version handler") - store, err := b.workstreamStore(false) - if err != nil || store == nil { - 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", Owner: "agent", Doc: doc, - Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: "old-source"}}}) - if err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: first.ID, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: first.ID, Title: workstreamPtr("After"), ReadByJobs: workstreamPtr(true)}); err != nil { - t.Fatal(err) - } - before := len(workstreamEvents(rec)) - current, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID}) - if err != nil { - t.Fatal(err) - } - selected, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(2)}) - if err != nil || !reflect.DeepEqual(selected, current) { - t.Fatalf("current version: %+v, want %+v: %v", selected, current, err) - } - old, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(1), Markdown: workstreamPtr(true)}) - if err != nil || old.Page.Title != "Before" || string(old.Page.Doc) != string(doc) || old.Page.Version != 1 || old.Page.UpdatedBy != "agent" || !old.Page.UpdatedAt.Equal(first.UpdatedAt) || old.Page.UpdatedFrom == nil || old.Page.UpdatedFrom.ID != "old-source" || !old.Page.ReadByJobs || old.Page.Markdown == nil || *old.Page.Markdown != "Earlier\n" { - t.Fatalf("historical page: %+v %v", old, err) - } - _, err = b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(99)}) - workstreamCheckError(t, err, appwire.CodeNotFound) - for _, version := range []int{0, -1} { - _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: &version}) - workstreamCheckError(t, err, appwire.CodeBadParams) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("version reads emitted changes") - } - if _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: first.ID}); err != nil { - t.Fatal(err) - } - old, err = b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(1)}) - if err != nil || old.Page.DeletedAt.IsZero() || old.Page.Markdown != nil || old.Page.Title != "Before" { - t.Fatalf("deleted historical page: %+v %v", old, err) - } -} - -// WP-F1 (b): list presence, deleted write refusals, restore parameter dispatch -// and the committed pages event are visible through the backend handlers. -func TestAppWorkstreamDeletedPageHandlers(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Deleted handler") - list := func(include *bool) map[string]json.RawMessage { - t.Helper() - result, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID, IncludeDeleted: include}) - if err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(result) - if err != nil { - t.Fatal(err) - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - t.Fatal(err) - } - return fields - } - for _, include := range []*bool{nil, workstreamPtr(false)} { - if _, present := list(include)["deleted"]; present { - t.Fatal("deleted present without include_deleted=true") - } - } - if raw := string(list(workstreamPtr(true))["deleted"]); raw != "[]" { - t.Fatalf("empty deleted list = %s", raw) - } - made, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Spec"}) - if err != nil { - t.Fatal(err) - } - id := made.Page.ID - _, err = b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeBadParams) - if _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); err != nil { - t.Fatal(err) - } - fields := list(workstreamPtr(true)) - var nodes []appwire.WorkstreamPageNode - if err := json.Unmarshal(fields["deleted"], &nodes); err != nil || len(nodes) != 1 || nodes[0].ID != id || nodes[0].DeletedAt.IsZero() { - t.Fatalf("deleted list: %s %v", fields["deleted"], err) - } - for name, call := range map[string]func() error{ - "save": func() error { - _, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: made.Page.Doc}) - return err - }, - "update": func() error { _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: id}); return err }, - "move": func() error { _, err := b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: id}); return err }, - "delete": func() error { _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); return err }, - "history": func() error { - _, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) - return err - }, - "create parent": func() error { - _, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, ParentID: &id, Title: "No"}) - return err - }, - "create after": func() error { - _, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, AfterID: &id, Title: "No"}) - return err - }, - } { - t.Run(name, func(t *testing.T) { workstreamCheckError(t, call(), appwire.CodeNotFound) }) - } - for _, p := range []appwire.WorkstreamPageRestoreParams{ - {ID: id, Version: workstreamPtr(1)}, {ID: id, BaseVersion: workstreamPtr(1)}, - {ID: id, Version: workstreamPtr(1), BaseVersion: workstreamPtr(1)}, - } { - _, err := b.WorkstreamPageRestore(p) - workstreamCheckError(t, err, appwire.CodeBadParams) - } - _, err = b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - before := len(workstreamEvents(rec)) - restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id}) - if err != nil || restored.Page.Version != 1 || !restored.Page.DeletedAt.IsZero() || restored.Page.UpdatedBy != "you" || restored.Page.UpdatedFrom != nil { - t.Fatalf("undelete: %+v %v", restored, err) - } - events := workstreamEvents(rec) - if len(events) != before+1 || events[before].ID != w.ID || !reflect.DeepEqual(events[before].Parts, []string{"workstream", "pages"}) { - t.Fatalf("undelete event: %+v", events[before:]) - } - history, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) - if err != nil || len(history.Versions) != 1 { - t.Fatalf("undelete added history: %+v %v", history, err) - } - if raw := string(list(workstreamPtr(true))["deleted"]); raw != "[]" { - t.Fatalf("deleted list after restore = %s", raw) - } -} diff --git a/cmd/codeaf/appworkstreams_source_open_other.go b/cmd/codeaf/appworkstreams_source_open_other.go deleted file mode 100644 index cd6d0a0173..0000000000 --- a/cmd/codeaf/appworkstreams_source_open_other.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build !unix - -package main - -import "os" - -// Verify the final component and its opened identity before any content is read. -func workstreamAgentOpenSourcePath(path string) (*os.File, error) { - before, err := os.Lstat(path) - if err != nil { - return nil, err - } - if before.Mode()&os.ModeSymlink != 0 { - return nil, errWorkstreamSourceLink - } - file, err := os.Open(path) - if err != nil { - return nil, err - } - after, err := file.Stat() - if err != nil { - file.Close() - return nil, err - } - if !os.SameFile(before, after) { - file.Close() - return nil, os.ErrInvalid - } - return file, nil -} diff --git a/cmd/codeaf/appworkstreams_source_open_unix.go b/cmd/codeaf/appworkstreams_source_open_unix.go deleted file mode 100644 index a4da049da2..0000000000 --- a/cmd/codeaf/appworkstreams_source_open_unix.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build unix - -package main - -import ( - "errors" - "os" - "syscall" -) - -// Refuse final-component links atomically and keep FIFO replacement reads nonblocking. -func workstreamAgentOpenSourcePath(path string) (*os.File, error) { - fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NONBLOCK|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0) - if errors.Is(err, syscall.ELOOP) { - return nil, errWorkstreamSourceLink - } - if err != nil { - return nil, err - } - return os.NewFile(uintptr(fd), path), nil -} diff --git a/cmd/codeaf/appworkstreams_source_open_unix_test.go b/cmd/codeaf/appworkstreams_source_open_unix_test.go deleted file mode 100644 index 0a6c351126..0000000000 --- a/cmd/codeaf/appworkstreams_source_open_unix_test.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build unix - -package main - -import ( - "context" - "os" - "strings" - "syscall" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" -) - -// WP-12 (g): replacing an added file with a named pipe cannot make a source -// read wait for a writer or read bytes from a non-regular descriptor. -func TestWorkstreamAgentSourceRefusesAReplacementFIFO(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - path := workstreamSourcesFile(t) - item, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) - if err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := syscall.Mkfifo(path, 0600); err != nil { - t.Fatal(err) - } - done := make(chan string, 1) - go func() { - raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) - if err != nil { - done <- err.Error() - return - } - done <- string(raw) - }() - select { - case answer := <-done: - if !strings.Contains(answer, "not a regular text file") { - t.Fatalf("special file answer: %s", answer) - } - case <-time.After(5 * time.Second): - if writer, err := os.OpenFile(path, os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil { - writer.Close() - } - t.Fatal("source read blocked on a named pipe") - } -} diff --git a/cmd/codeaf/appworkstreams_sources.go b/cmd/codeaf/appworkstreams_sources.go deleted file mode 100644 index 31a44b644b..0000000000 --- a/cmd/codeaf/appworkstreams_sources.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "context" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func appWorkstreamSourceItem(item workstream.SourceItem) appwire.WorkstreamSourceItem { - out := appwire.WorkstreamSourceItem{ID: item.ID, WorkstreamID: item.WorkstreamID, Kind: item.Kind, Title: item.Title, Path: workstreamOptional(item.Path), Repo: workstreamOptional(item.Repo), Number: workstreamPositive(item.Number), URL: workstreamOptional(item.URL), ReadByJobs: item.ReadByJobs, Proposed: item.Proposed, Why: workstreamOptional(item.Why), AddedBy: item.AddedBy, AddedAt: item.AddedAt} - if item.Ref != nil { - out.Ref = &appwire.WorkstreamKeptRef{Kind: item.Ref.Kind, ID: item.Ref.ID, ChatID: item.Ref.ChatID, Factory: item.Ref.Factory} - } - if item.Gone { - out.Gone = &item.Gone - } - return out -} - -func (b *appBackend) WorkstreamSourceList(p appwire.WorkstreamSourceListParams) (appwire.WorkstreamSourceListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceListResult, workstream.Change, error) { - items, err := s.Sources(context.Background(), p.WorkstreamID) - out := appwire.WorkstreamSourceListResult{Sources: []appwire.WorkstreamSourceItem{}} - for _, item := range items { - out.Sources = append(out.Sources, appWorkstreamSourceItem(item)) - } - return out, workstream.Change{}, err - }) -} - -func (b *appBackend) WorkstreamSourceAdd(p appwire.WorkstreamSourceAddParams) (appwire.WorkstreamSourceAddResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceAddResult, workstream.Change, error) { - item, already, change, err := s.AddSource(context.Background(), p.WorkstreamID, p.SourceAdd()) - return appwire.WorkstreamSourceAddResult{Source: appWorkstreamSourceItem(item), Already: already}, change, err - }) -} - -func (b *appBackend) WorkstreamSourceUpdate(p appwire.WorkstreamSourceUpdateParams) (appwire.WorkstreamSourceUpdateResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceUpdateResult, workstream.Change, error) { - item, change, err := s.UpdateSource(context.Background(), p.ID, workstream.SourcePatch{Title: p.Title, ReadByJobs: p.ReadByJobs, Keep: p.Keep}) - return appwire.WorkstreamSourceUpdateResult{Source: appWorkstreamSourceItem(item)}, change, err - }) -} - -func (b *appBackend) WorkstreamSourceRemove(p appwire.WorkstreamSourceRemoveParams) (appwire.WorkstreamSourceRemoveResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceRemoveResult, workstream.Change, error) { - item, change, err := s.RemoveSource(context.Background(), p.ID) - return appwire.WorkstreamSourceRemoveResult{Source: appWorkstreamSourceItem(item)}, change, err - }) -} diff --git a/cmd/codeaf/appworkstreams_sources_test.go b/cmd/codeaf/appworkstreams_sources_test.go deleted file mode 100644 index 0782bb6477..0000000000 --- a/cmd/codeaf/appworkstreams_sources_test.go +++ /dev/null @@ -1,514 +0,0 @@ -package main - -import ( - "bytes" - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "reflect" - "slices" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func workstreamSourcesFile(t *testing.T) string { - t.Helper() - path := filepath.Join(t.TempDir(), "usage-pricing.md") - if err := os.WriteFile(path, []byte("Recorded usage.\n"), 0600); err != nil { - t.Fatal(err) - } - return path -} - -func workstreamSourceWireCall(t *testing.T, connection *workstreamWireConnection, method string, params any) map[string]any { - t.Helper() - return workstreamWireResult(t, connection.call(t, method, params)).(map[string]any) -} - -// WP-12 (a), (d), (f): all target forms round-trip through actual typed wire -// dispatch, with exact kind keys, derived titles, order and duplicate receipts. -func TestAppWorkstreamSourcesEveryKindOnWire(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Sources wire", workstreamJobRef("7")) - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil { - t.Fatal(err) - } - record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "decision", Text: "Use recorded totals.\nDetails.", Kept: workstreamPtr(true)}) - if err != nil { - t.Fatal(err) - } - path := workstreamSourcesFile(t) - folder := t.TempDir() - cases := []struct { - fields map[string]any - title string - }{ - {map[string]any{"kind": "file", "path": path}, "usage-pricing.md"}, - {map[string]any{"kind": "repo", "repo": "ACME/Billing"}, "ACME/Billing"}, - {map[string]any{"kind": "repo", "repo": folder}, filepath.Base(folder)}, - {map[string]any{"kind": "issue", "repo": "acme/billing", "number": 412}, "acme/billing#412"}, - {map[string]any{"kind": "link", "url": "HTTPS://Docs.Example.com/x/?q=1#part"}, "docs.example.com/x"}, - {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "record", "id": record.Record.ID}}, "Use recorded totals."}, - {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "page", "id": pages.Pages[0].ID}}, "Overview"}, - {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "message", "id": "m", "chat_id": "chat"}}, "Kept message"}, - {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "factory_job", "id": "7", "factory": "f"}}, "Job 7"}, - } - membersBefore, err := b.collections.Members(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - want := []any{} - for i, tc := range cases { - params := map[string]any{"workstream_id": w.ID} - for key, value := range tc.fields { - params[key] = value - } - before := len(workstreamEvents(rec)) - result := workstreamSourceWireCall(t, connection, "source.add", params) - item := result["source"].(map[string]any) - keys := []string{"id", "workstream_id", "kind", "title", "read_by_jobs", "proposed", "added_by", "added_at"} - for key := range tc.fields { - if key != "kind" { - keys = append(keys, key) - } - } - slices.Sort(keys) - if result["already"] != false || item["title"] != tc.title || item["added_by"] != "you" || item["read_by_jobs"] != false || item["proposed"] != false || !reflect.DeepEqual(workstreamWireKeys(item), keys) { - t.Fatalf("kind %d: %+v", i, result) - } - for key, value := range tc.fields { - if key == "url" { - value = "https://docs.example.com/x/?q=1#part" - } - if !reflect.DeepEqual(item[key], workstreamWireDecode(t, workstreamWireEncode(t, value))) { - t.Fatalf("kind %d field %s: %v", i, key, item) - } - } - if i == 1 { - params["repo"] = "acme/billing" - } - if i == 3 { - params["repo"] = "ACME/Billing" - } - if i == 4 { - params["url"] = "https://docs.example.com/x/?q=1#part" - } - again := workstreamSourceWireCall(t, connection, "source.add", params) - if again["already"] != true || !reflect.DeepEqual(again["source"], item) || len(workstreamEvents(rec)) != before+1 { - t.Fatalf("duplicate kind %d: %+v", i, again) - } - want = append(want, item) - } - listed := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"] - if !reflect.DeepEqual(listed, want) { - t.Fatalf("source list order or fields: %v", listed) - } - membersAfter, err := b.collections.Members(context.Background(), w.ID) - if err != nil || !reflect.DeepEqual(membersAfter, membersBefore) { - t.Fatalf("sources changed members: %+v %v", membersAfter, err) - } - for _, index := range []int{0, 5, 6} { - item := want[index].(map[string]any) - removed := workstreamSourceWireCall(t, connection, "source.remove", map[string]any{"id": item["id"]})["source"] - if !reflect.DeepEqual(item, removed) { - t.Fatalf("remove changed receipt: %v", removed) - } - } - data, err := os.ReadFile(path) - if err != nil || string(data) != "Recorded usage.\n" { - t.Fatalf("removed source changed file: %q %v", data, err) - } - if _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: pages.Pages[0].ID}); err != nil { - t.Fatal(err) - } - if records, err := b.WorkstreamRecordList(appwire.WorkstreamRecordListParams{WorkstreamID: w.ID}); err != nil || len(records.Records) != 1 { - t.Fatalf("source removal changed records: %v %v", records, err) - } -} - -// WP-12 (b): invalid paths, URLs, kind fields, refs and updates return -// bad_params/not_found without rows, timestamps or changed events. -func TestAppWorkstreamSourcesRefusalsOnWire(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Invalid source wire") - path := workstreamSourcesFile(t) - bad := []map[string]any{} - for _, p := range []string{"relative", filepath.Dir(path) + "/../file", filepath.Dir(path) + "/./usage-pricing.md", filepath.Dir(path) + "//usage-pricing.md", path + "/", path + "\x00", path + "\n", filepath.Join(t.TempDir(), "missing"), "/" + strings.Repeat("x", 4096)} { - bad = append(bad, map[string]any{"kind": "file", "path": p}) - } - for _, repo := range []string{"../x", "owner/..", "-owner/x", "owner/name/extra", "relative", path} { - bad = append(bad, map[string]any{"kind": "repo", "repo": repo}) - } - for _, number := range []int{0, -1, 2147483648} { - bad = append(bad, map[string]any{"kind": "issue", "repo": "owner/name", "number": number}) - } - bad = append(bad, map[string]any{"kind": "issue", "repo": filepath.Dir(path), "number": 1}) - for _, url := range []string{"file:///etc/passwd", "javascript:alert(1)", "ftp://h/x", "//h/x", "https:///x", "https://user:pw@h/x", "https://h/x y", "https://h/x\n", "https://h/" + strings.Repeat("x", 2039)} { - bad = append(bad, map[string]any{"kind": "link", "url": url}) - } - bad = append(bad, map[string]any{"kind": "file", "path": path, "url": "https://example.com"}) - for _, title := range []string{"", strings.Repeat("x", 201), "two\nlines", " untrimmed "} { - bad = append(bad, map[string]any{"kind": "file", "path": path, "title": title}) - } - bad = append(bad, map[string]any{"kind": "kept", "ref": map[string]any{"kind": "factory_job", "id": "7", "factory": "f"}}, map[string]any{"kind": "kept", "ref": map[string]any{"kind": "message", "id": "m", "chat_id": "c", "factory": ""}}) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - before := len(workstreamEvents(rec)) - for i, params := range bad { - params["workstream_id"] = w.ID - frame := connection.call(t, "source.add", params) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("invalid %d: %v", i, frame) - } - } - for _, kind := range []string{"record", "page"} { - frame := connection.call(t, "source.add", map[string]any{"workstream_id": w.ID, "kind": "kept", "ref": map[string]any{"kind": kind, "id": "missing"}}) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "not_found" { - t.Fatalf("unknown kept: %v", frame) - } - } - for _, patch := range []map[string]any{{"id": "missing"}, {"id": "missing", "keep": false}, {"id": "missing", "title": ""}} { - frame := connection.call(t, "source.update", patch) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("invalid update: %v", frame) - } - } - listed := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"].([]any) - after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || len(listed) != 0 || len(workstreamEvents(rec)) != before || !after.Workstream.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("refusal mutated state: %v %v", listed, err) - } -} - -// WP-12 Fix 1 Major 3 (b): literal invalid UTF-8 must be refused before JSON -// can repair source strings, with no source, workstream, agent or event added. -func TestAppWorkstreamMajor3InvalidUTF8BOnWire(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "UTF-8 source wire") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - seed := workstreamSourceWireCall(t, connection, "source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com", "title": "Original title"})["source"].(map[string]any) - path := filepath.Join(t.TempDir(), "source-�.txt") - if err := os.WriteFile(path, []byte("Replacement-character file exists."), 0600); err != nil { - t.Fatal(err) - } - listParams := map[string]any{"workstream_id": w.ID} - before := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] - rowBefore, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - for _, tc := range []struct { - name string - fields map[string]any - }{ - {"path", map[string]any{"kind": "file", "path": path}}, - {"url", map[string]any{"kind": "link", "url": "https://example.com/�"}}, - {"title", map[string]any{"kind": "link", "url": "https://example.com/title", "title": "Title �"}}, - } { - for _, method := range []string{"source.add", "workstream.create"} { - t.Run(method+"/"+tc.name, func(t *testing.T) { - params := map[string]any{"name": "Invalid UTF-8 " + tc.name, "sources": []any{tc.fields}} - if method == "source.add" { - params = map[string]any{"workstream_id": w.ID} - for key, value := range tc.fields { - params[key] = value - } - } - raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": method, "params": params}) - raw = bytes.ReplaceAll(raw, []byte("�"), []byte{0xff}) - if !bytes.Contains(raw, []byte{0xff}) { - t.Fatal("fixture must contain a literal invalid UTF-8 byte") - } - frame := connection.callRaw(t, raw) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("invalid raw UTF-8 accepted: %v", frame) - } - }) - } - } - raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": "source.update", "params": map[string]any{"id": seed["id"], "title": "Changed �"}}) - frame := connection.callRaw(t, bytes.ReplaceAll(raw, []byte("�"), []byte{0xff})) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("invalid update accepted: %v", frame) - } - after := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] - rowAfter, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || !reflect.DeepEqual(after, before) || !rowAfter.Workstream.UpdatedAt.Equal(rowBefore.Workstream.UpdatedAt) || *calls != 1 || len(workstreamEvents(rec)) != events { - t.Fatalf("invalid UTF-8 changed state: sources=%v agents=%d err=%v", after, *calls, err) - } - if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 1 { - t.Fatalf("invalid UTF-8 created workstreams: %+v %v", list, err) - } -} - -// WP-12 Fix 1 Major 3 (b): source validation preserves create's existing -// replacement decoding for name and why, and accepts literal valid U+FFFD. -func TestAppWorkstreamMajor3InvalidUTF8BLeavesCreateTextDecoding(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - raw := fmt.Appendf(nil, `{"id":%d,"method":"workstream.create","params":{"name":"Name `, connection.Next+1) - raw = append(raw, 0xff) - raw = append(raw, []byte(`","why":"Why `)...) - raw = append(raw, 0xff) - raw = append(raw, []byte(`","sources":[{"kind":"link","url":"https://example.com","title":"Valid �"}]}}`)...) - created := workstreamWireResult(t, connection.callRaw(t, raw)).(map[string]any)["workstream"].(map[string]any) - if created["name"] != "Name �" || created["why"] != "Why �" { - t.Fatalf("create text decoding changed: %v", created) - } - sources := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": created["id"]})["sources"].([]any) - if len(sources) != 1 || sources[0].(map[string]any)["title"] != "Valid �" { - t.Fatalf("valid replacement character refused: %v", sources) - } -} - -// WP-12 Fix 1 Minor 1 (a), (b): wire adds refuse overlong normalized URLs -// without writes/events, while a returned URL at the bound can be re-added. -func TestAppWorkstreamMinor1LinkBoundABOnWire(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Normalized URL wire") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - events := len(workstreamEvents(rec)) - params := map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://h/" + strings.Repeat("é", 1019)} - frame := connection.call(t, "source.add", params) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("expanded URL accepted: %v", frame) - } - listParams := map[string]any{"workstream_id": w.ID} - list := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"].([]any) - after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || len(list) != 0 || len(workstreamEvents(rec)) != events || !after.Workstream.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("invalid URL changed state: %v %v", list, err) - } - params["url"] = "https://h/" + strings.Repeat("é", 339) + "abcd" - created := workstreamSourceWireCall(t, connection, "source.add", params) - item := created["source"].(map[string]any) - want := "https://h/" + strings.Repeat("%C3%A9", 339) + "abcd" - if created["already"] != false || item["url"] != want || len(item["url"].(string)) != 2048 { - t.Fatalf("fitting URL: %v", created) - } - params["url"] = item["url"] - again := workstreamSourceWireCall(t, connection, "source.add", params) - list = workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"].([]any) - if again["already"] != true || !reflect.DeepEqual(again["source"], item) || len(list) != 1 || len(workstreamEvents(rec)) != events+1 { - t.Fatalf("returned URL retry: %v sources=%v", again, list) - } -} - -// WP-12 (b), (e): create refuses every bad source before opening an agent; -// good sources are separate from why and duplicate/client retries add none. -func TestAppWorkstreamCreateSourcesPreflight(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - path := workstreamSourcesFile(t) - good := appwire.WorkstreamSourceAdd{Kind: "file", Path: &path, ReadByJobs: workstreamPtr(true)} - for _, bad := range []appwire.WorkstreamSourceAdd{{Kind: "file", Path: workstreamPtr("relative")}, {Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: "missing"}}} { - _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Free name", Sources: []appwire.WorkstreamSourceAdd{good, bad}}) - workstreamCheckError(t, err, appwire.CodeBadParams) - } - if *calls != 0 || len(workstreamEvents(rec)) != 0 { - t.Fatal("refused source created an agent or event") - } - if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 0 { - t.Fatalf("refused create left rows: %+v %v", list, err) - } - why := "Exactly sent.\n" - p := appwire.WorkstreamCreateParams{Name: "Free name", Why: &why, ClientID: workstreamPtr("source-retry"), Sources: []appwire.WorkstreamSourceAdd{{Kind: "repo", Repo: workstreamPtr("acme/billing")}, good, good}} - created, err := b.WorkstreamCreate(p) - if err != nil || created.Workstream.Why != why || *calls != 1 { - t.Fatalf("create: %+v %v", created, err) - } - p.Sources = []appwire.WorkstreamSourceAdd{{Kind: "link", URL: workstreamPtr("https://example.com/new")}} - if again, err := b.WorkstreamCreate(p); err != nil || again.Workstream.ID != created.Workstream.ID || *calls != 1 { - t.Fatalf("retry: %+v %v", again, err) - } - list, err := b.WorkstreamSourceList(appwire.WorkstreamSourceListParams{WorkstreamID: created.Workstream.ID}) - if err != nil || len(list.Sources) != 2 || list.Sources[0].ReadByJobs || !list.Sources[1].ReadByJobs || list.Sources[1].AddedBy != "you" || list.Sources[1].Proposed { - t.Fatalf("created sources: %+v %v", list, err) - } - tooMany := make([]appwire.WorkstreamSourceAdd, workstream.CreateSourcesMax+1) - for i := range tooMany { - tooMany[i] = good - } - _, err = b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Over limit", Sources: tooMany}) - workstreamCheckError(t, err, appwire.CodeBadParams) -} - -// WP-12 Fix 1 Major 2 (e): identical wire retries bypass source existence -// checks and create no agent, sources or events; new clients keep the checks. -func TestAppWorkstreamMajor2CreateRetryEOnWire(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - path := workstreamSourcesFile(t) - p := map[string]any{"name": "Retry with removed source", "client_id": "retry-removed", "sources": []any{map[string]any{"kind": "file", "path": path}}} - created := workstreamSourceWireCall(t, connection, "workstream.create", p)["workstream"].(map[string]any) - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - listParams := map[string]any{"workstream_id": created["id"]} - before := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] - events := len(workstreamEvents(rec)) - again := workstreamSourceWireCall(t, connection, "workstream.create", p)["workstream"] - after := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] - if !reflect.DeepEqual(again, created) || !reflect.DeepEqual(after, before) || len(after.([]any)) != 1 || *calls != 1 || len(workstreamEvents(rec)) != events { - t.Fatalf("retry changed state: workstream=%v sources=%v calls=%d", again, after, *calls) - } - p["client_id"], p["name"] = "new-client", "New client name" - frame := connection.call(t, "workstream.create", p) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" || *calls != 1 || len(workstreamEvents(rec)) != events { - t.Fatalf("new client bypassed validation: %v", frame) - } - if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 1 { - t.Fatalf("new client wrote a workstream: %+v %v", list, err) - } - delete(p, "sources") - workstreamSourceWireCall(t, connection, "workstream.create", p) -} - -// WP-12 (c), (h): toggles and keeping a proposal use the real wire path; -// done rejects all mutations and reopen restores them. -func TestAppWorkstreamSourceUpdatesAndDoneOnWire(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - proposal, err := door.ProposeSource(ctx, sessionWorkstreamSourceProposalForTest()) - if err != nil { - t.Fatal(err) - } - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - before := len(workstreamEvents(rec)) - frame := connection.call(t, "source.update", map[string]any{"id": proposal.ID, "read_by_jobs": true}) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" || len(workstreamEvents(rec)) != before { - t.Fatalf("proposal jobs: %v", frame) - } - kept := workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "keep": true, "read_by_jobs": true})["source"].(map[string]any) - if kept["proposed"] != false || kept["read_by_jobs"] != true || kept["why"] != "Read the guide." || kept["added_by"] != "agent" { - t.Fatalf("keep: %v", kept) - } - for _, flag := range []bool{false, true} { - workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "read_by_jobs": flag}) - list := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"].([]any) - if len(list) != 1 || list[0].(map[string]any)["read_by_jobs"] != flag { - t.Fatalf("toggle: %v", list) - } - } - before = len(workstreamEvents(rec)) - workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "keep": true}) - if len(workstreamEvents(rec)) != before { - t.Fatal("no-op update sent an event") - } - workstreamSourceWireCall(t, connection, "workstream.mark_done", map[string]any{"id": w.ID}) - before = len(workstreamEvents(rec)) - for _, tc := range []struct { - method string - params map[string]any - }{ - {"source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com/new"}}, - {"source.update", map[string]any{"id": proposal.ID, "title": "Changed"}}, - {"source.remove", map[string]any{"id": proposal.ID}}, - } { - frame := connection.call(t, tc.method, tc.params) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "conflict" { - t.Fatalf("done: %v", frame) - } - } - if len(workstreamEvents(rec)) != before { - t.Fatal("done write sent an event") - } - if _, err := door.ProposeSource(ctx, sessionWorkstreamSourceProposalForTest()); err == nil { - t.Fatal("done proposal succeeded") - } - workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID}) - workstreamSourceWireCall(t, connection, "workstream.reopen", map[string]any{"id": w.ID}) - workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "title": "Reopened"}) - workstreamSourceWireCall(t, connection, "source.remove", map[string]any{"id": proposal.ID}) - workstreamSourceWireCall(t, connection, "source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com/new"}) -} - -// WP-12 conformance: four heterogeneous source rows retain exact optional keys -// at each illustrated index, including the agent's reason only on its proposal. -func (fixture *workstreamWireFixture) sources(t *testing.T) { - t.Helper() - b := fixture.Backend - fixture.record(t) - adds := []appwire.WorkstreamSourceAddParams{ - {WorkstreamID: fixture.Stream.ID, Kind: "file", Path: workstreamPtr(fixture.IDs["/Users/ada/billing/usage-pricing.md"]), ReadByJobs: workstreamPtr(true)}, - {WorkstreamID: fixture.Stream.ID, Kind: "issue", Repo: workstreamPtr("acme/billing"), Number: workstreamPtr(412), ReadByJobs: workstreamPtr(true)}, - {WorkstreamID: fixture.Stream.ID, Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: fixture.IDs["rec_7a1e"]}}, - } - for i, ask := range adds { - made, err := b.WorkstreamSourceAdd(ask) - if err != nil { - t.Fatal(err) - } - fixture.IDs[fmt.Sprintf("src_2b7%d", i+1)] = made.Source.ID - } - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - item, _, change, err := s.ProposeSource(context.Background(), fixture.Stream.ID, workstream.SourceAdd{Kind: "link", URL: workstreamPtr("https://docs.stripe.com/billing/subscriptions/usage-based")}, "The metering rules the plan follows.") - if err != nil { - t.Fatal(err) - } - b.workstreamChanged(change) - fixture.IDs["src_2b74"] = item.ID -} - -func workstreamSeedSources(t *testing.T, b *appBackend, id string, total int) { - t.Helper() - store, err := b.collectionStore(false) - if err != nil { - t.Fatal(err) - } - if err := store.Write(context.Background(), func(tx *sql.Tx) error { - statement, err := tx.Prepare(`INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,added_by,added_at) VALUES (?,?,'link',?,'Example',?,'you',100)`) - if err != nil { - return err - } - defer statement.Close() - for i := 0; i < total; i++ { - url := fmt.Sprintf("https://example.com/source/%d", i) - if _, err := statement.Exec(fmt.Sprintf("fixture-source-%d", i), id, url, url); err != nil { - return err - } - } - return nil - }); err != nil { - t.Fatal(err) - } -} - -func workstreamWireSourceError(t *testing.T, fixture *workstreamWireFixture, errorCase workstreamWireErrorCase, params map[string]any) bool { - t.Helper() - if !strings.HasPrefix(errorCase.Name, "source_") { - return false - } - switch { - case strings.HasSuffix(errorCase.Name, "_bad"): - if errorCase.Method == "source.list" || errorCase.Method == "source.add" { - delete(params, "workstream_id") - } else { - delete(params, "id") - } - case strings.HasSuffix(errorCase.Name, "_missing"): - if errorCase.Method == "source.list" || errorCase.Method == "source.add" { - params["workstream_id"] = "missing-workstream" - } else { - params["id"] = "missing-source" - } - case strings.HasSuffix(errorCase.Name, "_done"): - if _, err := fixture.Backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: fixture.Stream.ID}); err != nil { - t.Fatal(err) - } - case strings.HasSuffix(errorCase.Name, "_limit"): - workstreamSeedSources(t, fixture.Backend, fixture.Stream.ID, workstream.SourcesMax) - default: - return false - } - return true -} diff --git a/cmd/codeaf/appworkstreams_suggestions_test.go b/cmd/codeaf/appworkstreams_suggestions_test.go deleted file mode 100644 index 970abafa05..0000000000 --- a/cmd/codeaf/appworkstreams_suggestions_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -// WP-6d Schema, Tool: both wire lists and workstream_read pages work before -// suggestion tables exist. Refused decisions do not commit their migration. -func TestAppWorkstreamSuggestionsOnOlderSchemas(t *testing.T) { - for _, version := range []int{3, 4, 5} { - t.Run(fmt.Sprint(version), func(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil || len(pages.Pages) == 0 { - t.Fatal("missing fixture page", err) - } - pageID := pages.Pages[0].ID - store, err := b.collectionStore(false) - if err != nil { - t.Fatal(err) - } - if err := store.Write(ctx, func(tx *sql.Tx) error { - ddl := "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions;" - if version < workspace.WorkstreamSourcesSchemaVersion { - ddl += "DROP TABLE workstream_sources;" - } - if version == 3 { - ddl += "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes;" - } - _, err := tx.Exec(ddl + fmt.Sprintf("PRAGMA user_version=%d", version)) - return err - }); err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - listed, err := b.WorkstreamSuggestionList(appwire.WorkstreamSuggestionListParams{PageID: pageID}) - if err != nil || listed.Suggestions == nil || len(listed.Suggestions) != 0 { - t.Fatalf("old-schema list: %+v %v", listed, err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: pageID}) - if err != nil { - t.Fatal(err) - } - var read struct { - Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` - } - if err := json.Unmarshal(raw, &read); err != nil || read.Suggestions == nil || len(read.Suggestions) != 0 { - t.Fatalf("old-schema page read: %s %v", raw, err) - } - _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - _, err = b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - if len(workstreamEvents(rec)) != events { - t.Fatal("old-schema reads or refused decisions emitted an event") - } - if err := store.Read(ctx, func(_ *sql.Tx, got int) error { - if got != version { - t.Fatalf("old-schema reads or refused decisions migrated to %d", got) - } - return nil - }); err != nil { - t.Fatal(err) - } - }) - } -} - -// WP-6d (a), Tool: the real door refuses agent-owned pages and direct writes -// to person pages, then suggests with chat attribution and pages-only events. -func TestAppWorkstreamAgentSuggestionsAndOwnership(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old words.")}) - if err != nil { - t.Fatal(err) - } - history, err := s.PageHistory(ctx, page.ID, 50) - if err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - _, err = door.WritePage(ctx, session.WorkstreamPageWrite{PageID: page.ID, Markdown: "Direct write", UpdatedFrom: "Discussion"}) - if !errors.Is(err, session.ErrWorkstreamNotAgentPage) || err.Error() != "That page is the person's. You write only your own pages; suggest edits on theirs with suggest_page_edit." { - t.Fatalf("direct write refusal: %v", err) - } - after, _ := s.Page(ctx, page.ID, false) - afterHistory, _ := s.PageHistory(ctx, page.ID, 50) - if !reflect.DeepEqual(page, after) || !reflect.DeepEqual(history, afterHistory) || len(workstreamEvents(rec)) != events { - t.Fatal("direct write changed state") - } - owner := "agent" - if _, _, err := s.UpdatePage(ctx, page.ID, workstream.PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - ask := session.WorkstreamSuggestionAsk{PageID: page.ID, BaseVersion: page.Version, Why: "Clarify", Edits: []workstream.SuggestionFind{{Find: "Old", Replace: "New", Why: "Clearer"}}} - _, err = door.SuggestEdits(ctx, ask) - if !errors.Is(err, workstream.ErrNotPersonPage) || err.Error() != "That page is yours; change it with write_agent_page. Suggest edits only on the person's pages." { - t.Fatalf("own page refusal: %v", err) - } - open, err := s.Suggestions(ctx, page.ID) - if err != nil || len(open) != 0 || len(workstreamEvents(rec)) != events { - t.Fatal("own-page refusal stored or emitted", err) - } - owner = "you" - if _, _, err := s.UpdatePage(ctx, page.ID, workstream.PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - result, err := door.SuggestEdits(ctx, ask) - if err != nil || result.Title != page.Title || result.Count != 1 { - t.Fatalf("suggest: %+v %v", result, err) - } - open, err = s.Suggestions(ctx, page.ID) - if err != nil || len(open) != 1 || open[0].ID != result.ID || !reflect.DeepEqual(open[0].Source, &workstream.Source{Kind: "chat", ID: w.AgentID}) { - t.Fatalf("suggested source: %+v %v", open, err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: page.ID}) - if err != nil { - t.Fatal(err) - } - var read struct { - Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` - } - if err := json.Unmarshal(raw, &read); err != nil || len(read.Suggestions) != 1 || !reflect.DeepEqual(read.Suggestions[0], appWorkstreamSuggestion(open[0])) { - t.Fatalf("page read lost suggestions: %s %v", raw, err) - } - after, _ = s.Page(ctx, page.ID, false) - afterHistory, _ = s.PageHistory(ctx, page.ID, 50) - if !reflect.DeepEqual(after.Doc, page.Doc) || after.Version != page.Version || !reflect.DeepEqual(history, afterHistory) { - t.Fatal("suggestion changed page") - } - changes := workstreamEvents(rec) - if len(changes) != events+1 || !reflect.DeepEqual(changes[len(changes)-1], appwire.WorkstreamChanged{ID: w.ID, Parts: []string{"pages"}}) { - t.Fatalf("create events: %+v", changes[events:]) - } -} - -// WP-6d (a): every source-taking wire parameter refuses suggestion attribution -// without changing pages, history, records, plans, workstream or event count. -func TestAppWorkstreamSuggestionSourceParamsRefused(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Source refusals") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - pages, err := s.Pages(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - id := pages[0].ID - record, _, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Decision"}) - if err != nil { - t.Fatal(err) - } - snapshot := func() []any { - page, err := s.Page(context.Background(), id, false) - if err != nil { - t.Fatal(err) - } - history, err := s.PageHistory(context.Background(), id, 50) - if err != nil { - t.Fatal(err) - } - nodes, err := s.Pages(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - records, err := s.Records(context.Background(), w.ID, "", "", true) - if err != nil { - t.Fatal(err) - } - plan, err := s.Plan(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - stream, err := s.Get(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - return []any{page, history, nodes, records, plan, stream} - } - before := snapshot() - events := len(workstreamEvents(rec)) - source := map[string]any{"kind": "suggestion", "id": "sg_forged"} - for _, test := range []struct { - method string - params map[string]any - }{ - {"page.save", map[string]any{"id": id, "base_version": 1, "doc": map[string]any{"type": "doc"}, "source": source}}, - {"page.create", map[string]any{"workstream_id": w.ID, "title": "Forged", "source": source}}, - {"record.propose", map[string]any{"workstream_id": w.ID, "kind": "decision", "text": "Forged", "source": source}}, - {"record.supersede", map[string]any{"id": record.ID, "text": "Forged", "source": source}}, - {"workstream.plan_set", map[string]any{"id": w.ID, "base_version": 0, "milestones": []any{}, "waves": []any{map[string]any{"n": 1, "auto_start": false, "items": []any{map[string]any{"kind": "draft", "draft_id": "draft", "title": "Forged", "source": source}}}}}}, - } { - frame := connection.call(t, test.method, test.params) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { - t.Fatalf("%s: %v", test.method, frame) - } - if !reflect.DeepEqual(before, snapshot()) || len(workstreamEvents(rec)) != events { - t.Fatalf("%s refusal changed state", test.method) - } - } -} - -// WP-6d (b), (d), (h): wire decisions emit pages only; stale and done decisions -// emit nothing and carry the contract error/detail. Done reads remain available. -func TestAppWorkstreamSuggestionDecisionsAndDone(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old words.\n\nOther words.")}) - if err != nil { - t.Fatal(err) - } - ask := session.WorkstreamSuggestionAsk{PageID: page.ID, BaseVersion: page.Version, Why: "Clarity", Edits: []workstream.SuggestionFind{{Find: "Old", Replace: "New", Why: "First"}, {Find: "Other", Replace: "Clear", Why: "Second"}}} - suggested, err := door.SuggestEdits(context.Background(), ask) - if err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - if _, err := b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: suggested.ID, EditID: workstreamPtr("e1")}); err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: suggested.ID, EditID: workstreamPtr("e2")}); err != nil { - t.Fatal(err) - } - changes := workstreamEvents(rec) - if len(changes) != events+2 { - t.Fatal("missing decision events") - } - for _, change := range changes[events:] { - if !reflect.DeepEqual(change, appwire.WorkstreamChanged{ID: w.ID, Parts: []string{"pages"}}) { - t.Fatalf("decision parts: %+v", change) - } - } - page, err = s.Page(context.Background(), page.ID, false) - if err != nil { - t.Fatal(err) - } - ask.BaseVersion, ask.Edits = page.Version, []workstream.SuggestionFind{{Find: "New", Replace: "Newest", Why: "Update"}} - stale, err := door.SuggestEdits(context.Background(), ask) - if err != nil { - t.Fatal(err) - } - page, _, err = s.SavePage(context.Background(), page.ID, page.Version, pagedoc.FromMarkdown("Changed by the person."), workstream.Attribution{}) - if err != nil { - t.Fatal(err) - } - events = len(workstreamEvents(rec)) - _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: stale.ID}) - failure := workstreamCheckError(t, err, appwire.CodeVersionConflict) - want := appwire.WorkstreamVersionConflict{ID: page.ID, Version: page.Version, UpdatedAt: page.UpdatedAt, UpdatedBy: page.UpdatedBy} - if failure.Message != "the page changed and this edit no longer applies" || !reflect.DeepEqual(failure.Detail, want) || len(workstreamEvents(rec)) != events { - t.Fatalf("stale receipt: %+v", failure) - } - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - events = len(workstreamEvents(rec)) - _, err = door.SuggestEdits(context.Background(), ask) - if !errors.Is(err, session.ErrWorkstreamDone) || err.Error() != "This workstream is done; nothing was added or written." { - t.Fatalf("done tool: %v", err) - } - _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: stale.ID}) - workstreamCheckError(t, err, appwire.CodeConflict) - _, err = b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: stale.ID}) - workstreamCheckError(t, err, appwire.CodeConflict) - if _, err := b.WorkstreamSuggestionList(appwire.WorkstreamSuggestionListParams{PageID: page.ID}); err != nil { - t.Fatal(err) - } - if len(workstreamEvents(rec)) != events { - t.Fatal("done refusals or read emitted events") - } -} - -// WP-6d Tool: the adapter maps the all-states count bound to its exact answer. -func TestAppWorkstreamSuggestionCountRefusal(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old")}) - if err != nil { - t.Fatal(err) - } - store, err := b.collectionStore(false) - if err != nil { - t.Fatal(err) - } - if err := store.Write(context.Background(), func(tx *sql.Tx) error { - _, err := tx.Exec(`WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x 1 { - return api.Queue{}, errors.New("unreachable") - } - return api.Queue{Jobs: []api.Job{{ID: 7, State: "running", Cost: 1.25}}}, nil - } - w := workstreamCreateForTest(t, b, "First start", workstreamJobRef("7")) - events := len(workstreamEvents(rec)) - var result appwire.Workstream - switch method { - case "update": - v, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Updated before get")}}) - if err != nil { - t.Fatal(err) - } - result = v.Workstream - case "mark_done": - v, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - result = v.Workstream - case "reopen": - // Reopening an already open workstream still observes its jobs. - v, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - result = v.Workstream - } - stage := "building" - if method == "mark_done" { - stage = "done" - } - if result.Stage != stage || result.StartedAt.IsZero() || !result.UpdatedAt.After(w.UpdatedAt) { - t.Fatalf("first observation: %+v", result) - } - changed := workstreamEvents(rec)[events:] - if len(changed) != 1 || changed[0].ID != w.ID || !reflect.DeepEqual(changed[0].Parts, []string{"workstream"}) { - t.Fatalf("mutation events: %+v", changed) - } - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || !reflect.DeepEqual(result, got.Workstream) || len(workstreamEvents(rec)) != events+1 || reads != 1 { - t.Fatalf("get after mutation: %+v, %v; events %d, reads %d", got, err, len(workstreamEvents(rec))-events, reads) - } - clock.Add(workstreamFactoryFresh.Milliseconds()) - got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.Stage != stage || !got.Workstream.StartedAt.Equal(result.StartedAt) || !got.Workstream.UpdatedAt.Equal(result.UpdatedAt) || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil || reads != 2 || len(workstreamEvents(rec)) != events+1 { - t.Fatalf("unreachable Factory lost durable observation: %+v, %v; reads %d", got, err, reads) - } - updated, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Committed while unreachable")}}) - if err != nil || updated.Workstream.Why != "Committed while unreachable" || updated.Workstream.Stage != stage || updated.Workstream.SpendUSD != nil || len(workstreamEvents(rec)) != events+2 { - t.Fatalf("unreachable Factory failed mutation: %+v, %v", updated, err) - } - }) - } -} - -// (e): marking done ends grants of all three scopes and archives member chats -// through the existing archive door, excluding the workstream's agent record. -func TestAppWorkstreamDoneEndsGoAheadsAndArchivesChats(t *testing.T) { - b, _, _, origin := workstreamBackend(t) - made, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Done", OriginChatID: &origin.ID, Refs: []appwire.WorkstreamRef{workstreamJobRef("7"), {Kind: appwire.RefConversation, ID: "second-chat"}}}) - if err != nil { - t.Fatal(err) - } - id := made.Workstream.ID - _, err = b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: id, Milestones: []appwire.WorkstreamMilestone{{ID: "m", Title: "Ship"}}, Waves: []appwire.WorkstreamWave{{N: 1, MilestoneID: workstreamPtr("m"), Items: []appwire.WorkstreamPlanItem{}}}}) - if err != nil { - t.Fatal(err) - } - for _, p := range []appwire.WorkstreamGoAheadGrantParams{ - {Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7)}, - {Scope: "wave", Wave: workstreamPtr(1)}, - {Scope: "milestone", MilestoneID: workstreamPtr("m")}, - } { - p.WorkstreamID = id - p.CapUSD = 40 - p.EndsAt = time.Now().Add(time.Hour) - if _, err := b.WorkstreamGoAheadGrant(p); err != nil { - t.Fatal(err) - } - } - var archived []string - b.workstreams.archiveChats = func(ids []string) error { archived = append(archived, ids...); return nil } - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: id, ArchiveChats: workstreamPtr(true)}); err != nil { - t.Fatal(err) - } - grants, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: id, Ended: workstreamPtr(true)}) - if err != nil || len(grants.GoAheads) != 3 { - t.Fatalf("grants %+v, %v", grants, err) - } - for _, g := range grants.GoAheads { - if g.State != "ended" || workstreamValue(g.EndReason) != "workstream_done" || g.EndedAt.IsZero() { - t.Fatalf("unended %+v", g) - } - } - if slices.Contains(archived, made.Workstream.AgentID) || len(archived) != 2 || !slices.Contains(archived, origin.ID) || !slices.Contains(archived, "second-chat") { - t.Fatalf("archived %v", archived) - } -} - -// (f): stale saves preserve the document and history; restore creates a new -// version instead of modifying an old one, with complete typed stale detail. -func TestAppWorkstreamPageVersions(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Pages") - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil { - t.Fatal(err) - } - id := pages.Pages[0].ID - doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Saved"}]}]}`) - saved, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: doc}) - if err != nil || saved.Page.Version != 2 { - t.Fatalf("save %+v, %v", saved, err) - } - before := len(workstreamEvents(rec)) - _, err = b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: json.RawMessage(`{}`)}) - wire := workstreamCheckError(t, err, appwire.CodeVersionConflict) - detail, ok := wire.Detail.(appwire.WorkstreamVersionConflict) - if !ok || detail.ID != id || detail.Version != 2 || detail.UpdatedAt != saved.Page.UpdatedAt || detail.UpdatedBy != "you" { - t.Fatalf("detail %+v", wire.Detail) - } - read, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: id, Markdown: workstreamPtr(true)}) - if err != nil || !bytes.Equal(read.Page.Doc, doc) || read.Page.Markdown == nil || !strings.Contains(*read.Page.Markdown, "Saved") { - t.Fatalf("read %+v, %v", read, err) - } - history, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) - if err != nil || len(history.Versions) != 2 || history.Versions[0].Version != 2 { - t.Fatalf("history %+v, %v", history, err) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("stale save or reads emitted") - } - restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id, Version: workstreamPtr(1), BaseVersion: workstreamPtr(2)}) - if err != nil || restored.Page.Version != 3 || string(restored.Page.Doc) != `{"type":"doc","content":[]}` { - t.Fatalf("restore %+v, %v", restored, err) - } - history, err = b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) - if err != nil || len(history.Versions) != 3 || history.Versions[0].Version != 3 { - t.Fatalf("restored history %+v, %v", history, err) - } - _, err = b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeBadParams) -} - -// (g): keep/drop support undo; a replacement links both records and closes -// every later mutation of the superseded record with conflict. -func TestAppWorkstreamRecordLifecycle(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Records") - proposed, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Ship?", Source: &appwire.WorkstreamSource{Kind: "chat", ID: "chat"}}) - if err != nil || proposed.Record.State != "proposed" || proposed.Record.By != "agent" { - t.Fatalf("propose %+v, %v", proposed, err) - } - id := proposed.Record.ID - kept, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}) - if err != nil || kept.Record.State != "kept" { - t.Fatalf("keep %+v, %v", kept, err) - } - dropped, err := b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: id}) - if err != nil || dropped.Record.State != "dropped" { - t.Fatalf("drop %+v, %v", dropped, err) - } - if _, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}); err != nil { - t.Fatal(err) - } - replacement, err := b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: id, Text: "Ship it", Kind: workstreamPtr("decision")}) - if err != nil || replacement.Record.State != "kept" || replacement.Record.Kind != "decision" || workstreamValue(replacement.Record.Supersedes) != id || replacement.Superseded.State != "superseded" || workstreamValue(replacement.Superseded.SupersededBy) != replacement.Record.ID { - t.Fatalf("supersede %+v, %v", replacement, err) - } - _, err = b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeConflict) - _, err = b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeConflict) - _, err = b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: id, Text: "Again"}) - workstreamCheckError(t, err, appwire.CodeConflict) -} - -// (h): missing scope, cap or end date, and clock-dependent invalid ends, store -// nothing and emit nothing, through the backend's own checked entry point. -func TestAppWorkstreamInvalidGoAheads(t *testing.T) { - for _, name := range []string{"scope", "cap", "end", "past", "too far", "too much"} { - t.Run(name, func(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Invalid", workstreamJobRef("7")) - p := appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)} - switch name { - case "scope": - p.Scope = "" - case "cap": - p.CapUSD = 0 - case "end": - p.EndsAt = time.Time{} - case "past": - p.EndsAt = time.Now().Add(-time.Second) - case "too far": - p.EndsAt = time.Now().Add(91 * 24 * time.Hour) - case "too much": - p.CapUSD = 10001 - } - before := len(workstreamEvents(rec)) - _, err := b.WorkstreamGoAheadGrant(p) - workstreamCheckError(t, err, appwire.CodeBadParams) - list, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID, Ended: workstreamPtr(true)}) - if err != nil || len(list.GoAheads) != 0 || len(workstreamEvents(rec)) != before { - t.Fatalf("invalid grant stored or emitted: %+v, %v", list, err) - } - }) - } -} - -// (j): encode the actual handler results so absence cannot pass as null or 0. -func TestAppWorkstreamUnknownNumbersAreAbsent(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Unknown", workstreamJobRef("7")) - for _, method := range []string{"get", "list"} { - var result any - var err error - if method == "get" { - result, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - } else { - result, err = b.WorkstreamList(appwire.WorkstreamListParams{}) - } - if err != nil { - t.Fatal(err) - } - data, err := json.Marshal(result) - if err != nil { - t.Fatal(err) - } - for _, field := range []string{`"merged":`, `"needs_you":`, `"spend_usd":`} { - if bytes.Contains(data, []byte(field)) { - t.Fatalf("%s fabricated %s: %s", method, field, data) - } - } - if !bytes.Contains(data, []byte(`"total":1`)) { - t.Fatalf("total absent: %s", data) - } - } - opened, err := b.OpenCollection(w.ID, 1, 200, "") - if err != nil { - t.Fatal(err) - } - for _, node := range opened.Members { - if node.Kind == appwire.RefFactoryJob && node.Target.State != appwire.TargetUnknown { - t.Fatalf("unattached Factory fabricated target state: %+v", node.Target) - } - } -} - -func TestAppWorkstreamCreatePreflightAndCleanup(t *testing.T) { - for _, name := range []string{"name", "origin", "ref", "bounds"} { - t.Run(name, func(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - p := appwire.WorkstreamCreateParams{Name: "Refused"} - code := appwire.CodeBadParams - switch name { - case "name": - if _, err := b.CreateCollection("Refused"); err != nil { - t.Fatal(err) - } - code = appwire.CodeConflict - case "origin": - p.OriginChatID = workstreamPtr("no-chat") - code = appwire.CodeNotFound - case "ref": - p.Refs = []appwire.WorkstreamRef{{Kind: appwire.RefCollection, ID: "no-collection"}} - code = appwire.CodeNotFound - case "bounds": - p.Why = workstreamPtr(strings.Repeat("x", 4001)) - } - before := len(workstreamEvents(rec)) - _, err := b.WorkstreamCreate(p) - workstreamCheckError(t, err, code) - if *calls != 0 || len(workstreamEvents(rec)) != before { - t.Fatal("preflight created or emitted") - } - if name != "name" { - if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("preflight created store folder: %v", err) - } - } - }) - } - t.Run("peer name race closes unused agent", func(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - create := b.workstreams.createAgent - b.workstreams.createAgent = func(p appwire.CreateAsk) (appwire.Agent, error) { - if _, err := b.CreateCollection("Raced"); err != nil { - return appwire.Agent{}, err - } - return create(p) - } - closed := "" - b.workstreams.closeAgent = func(id string) error { closed = id; return nil } - _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Raced"}) - workstreamCheckError(t, err, appwire.CodeConflict) - list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil || len(list.Workstreams) != 0 || *calls != 1 || closed != "workstream-agent-1" || len(workstreamEvents(rec)) != 0 { - t.Fatalf("cleanup: %+v, %v, closed %q", list, err, closed) - } - }) - t.Run("agent refusal creates no store", func(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - b.workstreams.createAgent = func(appwire.CreateAsk) (appwire.Agent, error) { - return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "agent refused") - } - _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Refused"}) - workstreamCheckError(t, err, appwire.CodeBadParams) - if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("agent refusal created store folder: %v", err) - } - if len(workstreamEvents(rec)) != 0 { - t.Fatal("agent refusal emitted") - } - }) -} - -// A fresh workstream list remains a non-migrating, non-creating read. -func TestAppWorkstreamFreshReadsDoNotCreate(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil || list.Workstreams == nil || len(list.Workstreams) != 0 { - t.Fatalf("fresh list %+v, %v", list, err) - } - _, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("fresh read made store folder: %v", err) - } -} - -// The shared hello hook must recognize the real backend's capability. -func TestAppWorkstreamHelloCarriesFeature(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - var output bytes.Buffer - server := appwire.NewServer(&output) - if err := server.Serve(context.Background(), strings.NewReader(""), appwire.Hello{}, workstreamHelloEngine{Engine: b, Workstreamer: b}); err != nil { - t.Fatal(err) - } - var frame struct { - Event string `json:"event"` - Data appwire.Hello `json:"data"` - } - if err := json.NewDecoder(&output).Decode(&frame); err != nil { - t.Fatal(err) - } - if frame.Event != appwire.EventHello || !slices.Contains(frame.Data.Features, appwire.FeatureWorkstreams) { - t.Fatalf("hello %+v", frame) - } -} - -// Only the tested capability is exposed, leaving the real backend's unrelated -// process warm-up outside this protocol test. -type workstreamHelloEngine struct { - appwire.Engine - appwire.Workstreamer -} - -// Session metadata is the recorded spend source, including a fresh bare open -// excluded from ordinary agents.list. Missing/corrupt metadata stays unknown. -func TestAppWorkstreamRecordedAgentSpend(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Spend") - b.workstreams.spend = nil - dir := t.TempDir() - transcript := filepath.Join(dir, "transcript.jsonl") - a := b.agent(w.AgentID) - b.locked(func() { a.transcript = transcript }) - if err := os.WriteFile(transcript, nil, 0600); err != nil { - t.Fatal(err) - } - if got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); err != nil || got.Workstream.SpendUSD != nil { - t.Fatalf("missing metadata: %+v, %v", got, err) - } - for _, cost := range []float64{0, 2.5} { - meta := session.Meta{ID: w.AgentID, SpentUSD: cost} - if cost > 0 { - meta.LastUserAt = time.Now() - } - data, err := json.Marshal(meta) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "meta.json"), data, 0600); err != nil { - t.Fatal(err) - } - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != cost { - t.Fatalf("recorded %v, got %+v, %v", cost, got, err) - } - } -} - -// (i): every mutation is observed through its public handler and produces one -// event for the changed workstream, containing every part its write changed. -func TestAppWorkstreamEveryWriteEmitsOnce(t *testing.T) { - type fixture struct { - b *appBackend - id, page, parent, record, grant string - base int - } - cases := []struct { - method string - parts []string - done bool - call func(*fixture) error - }{ - {"workstream.create", []string{"workstream", "pages", "jobs"}, false, func(f *fixture) error { - v, err := f.b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Second"}) - f.id = v.Workstream.ID - return err - }}, - {"workstream.update", []string{"workstream"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: f.id, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("New why")}}) - return err - }}, - {"workstream.mark_done", []string{"workstream", "goaheads"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: f.id}) - return err - }}, - {"workstream.reopen", []string{"workstream"}, true, func(f *fixture) error { - _, err := f.b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: f.id}) - return err - }}, - {"workstream.plan_set", []string{"workstream", "plan"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: f.id, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}}) - return err - }}, - {"page.create", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: f.id, Title: "New"}) - return err - }}, - {"page.save", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: f.page, BaseVersion: f.base, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}) - return err - }}, - {"page.update", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: f.page, Title: workstreamPtr("Renamed")}) - return err - }}, - {"page.move", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: f.page}) - return err - }}, - {"page.delete", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: f.parent}) - return err - }}, - {"page.restore", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: f.page, Version: workstreamPtr(1), BaseVersion: workstreamPtr(f.base)}) - return err - }}, - {"record.propose", []string{"workstream", "records"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: f.id, Kind: "decision", Text: "New"}) - return err - }}, - {"record.keep", []string{"workstream", "records"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: f.record}) - return err - }}, - {"record.drop", []string{"workstream", "records"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: f.record}) - return err - }}, - {"record.supersede", []string{"workstream", "records"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: f.record, Text: "Replaced"}) - return err - }}, - {"goahead.grant", []string{"workstream", "goaheads"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: f.id, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 2, EndsAt: time.Now().Add(time.Hour)}) - return err - }}, - {"goahead.revoke", []string{"workstream", "goaheads"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: f.grant}) - return err - }}, - {"collections.add", []string{"workstream", "jobs"}, false, func(f *fixture) error { - _, err := f.b.AddToCollection(f.id, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "8", Factory: "f"}}}) - return err - }}, - {"collections.remove", []string{"workstream", "jobs"}, false, func(f *fixture) error { - _, err := f.b.RemoveFromCollection(f.id, []appwire.Ref{{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}) - return err - }}, - {"collections.rename", []string{"workstream"}, false, func(f *fixture) error { _, err := f.b.RenameCollection(f.id, "Renamed"); return err }}, - {"collections.relink", []string{"workstream", "jobs"}, false, func(f *fixture) error { - _, err := f.b.RelinkCollection(f.id, appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}, appwire.Ref{Kind: appwire.RefFactoryJob, ID: "8", Factory: "f"}) - return err - }}, - } - for _, tc := range cases { - t.Run(tc.method, func(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}, {ID: 8, State: "backlog"}}}, nil - } - w := workstreamCreateForTest(t, b, "Events", workstreamJobRef("7")) - parent, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Parent"}) - if err != nil { - t.Fatal(err) - } - page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, ParentID: &parent.Page.ID, Title: "Child"}) - if err != nil { - t.Fatal(err) - } - saved, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: page.Page.ID, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc"}`)}) - if err != nil { - t.Fatal(err) - } - record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "What?"}) - if err != nil { - t.Fatal(err) - } - grant, err := b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)}) - if err != nil { - t.Fatal(err) - } - f := fixture{b: b, id: w.ID, page: page.Page.ID, parent: parent.Page.ID, record: record.Record.ID, grant: grant.GoAhead.ID, base: saved.Page.Version} - if tc.done { - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - } - before := len(workstreamEvents(rec)) - collectionsBefore := len(changedEvents(rec)) - if err := tc.call(&f); err != nil { - t.Fatal(err) - } - events := workstreamEvents(rec)[before:] - if len(events) != 1 || events[0].ID != f.id || !reflect.DeepEqual(events[0].Parts, tc.parts) { - t.Fatalf("%s: %+v, want %s %v", tc.method, events, f.id, tc.parts) - } - if strings.HasPrefix(tc.method, "collections.") && len(changedEvents(rec)) != collectionsBefore+1 { - t.Fatal("membership change lost collection.changed") - } - }) - } -} - -// (i): the read methods and idempotent mutations never manufacture a receipt. -func TestAppWorkstreamReadsAndUnchangedWritesEmitNothing(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Quiet", workstreamJobRef("7")) - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil - } - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil { - t.Fatal(err) - } - page := pages.Pages[0] - r, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "decision", Text: "Keep", Kept: workstreamPtr(true)}) - if err != nil { - t.Fatal(err) - } - g, err := b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)}) - if err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: g.GoAhead.ID}); err != nil { - t.Fatal(err) - } - calls := []struct { - name string - call func() error - }{ - {"workstream.list", func() error { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); return err }}, - {"workstream.get", func() error { _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); return err }}, - {"workstream.plan_get", func() error { _, err := b.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{ID: w.ID}); return err }}, - {"page.list", func() error { - _, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - return err - }}, - {"page.get", func() error { _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: page.ID}); return err }}, - {"page.history", func() error { - _, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: page.ID}) - return err - }}, - {"record.list", func() error { - _, err := b.WorkstreamRecordList(appwire.WorkstreamRecordListParams{WorkstreamID: w.ID}) - return err - }}, - {"goahead.list", func() error { - _, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID, Ended: workstreamPtr(true)}) - return err - }}, - {"workstream.update empty", func() error { _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID}); return err }}, - {"workstream.reopen open", func() error { _, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}); return err }}, - {"page.update same", func() error { - _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: page.ID, Title: &page.Title}) - return err - }}, - {"record.keep kept", func() error { - _, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: r.Record.ID}) - return err - }}, - {"goahead.revoke ended", func() error { - _, err := b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: g.GoAhead.ID}) - return err - }}, - {"collections.add already", func() error { - _, err := b.AddToCollection(w.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}}) - return err - }}, - {"collections.remove absent", func() error { - _, err := b.RemoveFromCollection(w.ID, []appwire.Ref{{Kind: appwire.RefFactoryJob, ID: "99", Factory: "f"}}) - return err - }}, - {"collections.rename same", func() error { _, err := b.RenameCollection(w.ID, w.Name); return err }}, - } - before := len(workstreamEvents(rec)) - for _, tc := range calls { - t.Run(tc.name, func(t *testing.T) { - if err := tc.call(); err != nil { - t.Fatal(err) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("unchanged operation emitted") - } - }) - } - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - before = len(workstreamEvents(rec)) - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("mark_done again emitted") - } -} - -// B3: distinct factories are read concurrently, once per list, and reused by -// get and collections.open. A barrier tests work rather than elapsed time. -func TestAppWorkstreamFactoryBatchCacheAndCollectionTargets(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - a := workstreamCreateForTest(t, b, "One", workstreamJobRef("7")) - second := workstreamJobRef("9") - second.Factory = workstreamPtr("g") - workstreamCreateForTest(t, b, "Two", second) - entered := make(chan string, 2) - release := make(chan struct{}) - var mu sync.Mutex - calls := map[string]int{} - workstreamRecordCall := func(factory string) { - mu.Lock() - defer mu.Unlock() - calls[factory]++ - } - b.workstreams.queue = func(ctx context.Context, factory string) (api.Queue, error) { - workstreamRecordCall(factory) - entered <- factory - select { - case <-release: - case <-ctx.Done(): - return api.Queue{}, ctx.Err() - } - id := 7 - if factory == "g" { - id = 9 - } - return api.Queue{Jobs: []api.Job{{ID: id, Num: 1107, Title: "Recorded title", State: "backlog", Repo: "acme/api", Cost: 0}}}, nil - } - done := make(chan error, 1) - guard.Go("app/workstream-list-test", func() { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); done <- err }) - for range 2 { - select { - case <-entered: - case <-time.After(time.Second): - close(release) - t.Fatal("factories were not read concurrently") - } - } - close(release) - if err := <-done; err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: a.ID}); err != nil { - t.Fatal(err) - } - open, err := b.OpenCollection(a.ID, 1, 200, "") - if err != nil { - t.Fatal(err) - } - found := false - for _, node := range open.Members { - if node.Kind == appwire.RefFactoryJob { - found = true - if node.Target.State != appwire.TargetLive || node.Target.Title != "Recorded title" || node.Target.Status != "backlog" || node.Target.Repo != "acme/api" { - t.Fatalf("target %+v", node.Target) - } - } - } - mu.Lock() - defer mu.Unlock() - if !found || calls["f"] != 1 || calls["g"] != 1 { - t.Fatalf("target found %v, queue calls %v", found, calls) - } -} - -// workstreamQueueWaitContext signals when a coalesced reader selects on Done, -// so the panic cannot race ahead of the waiter entering the flight branch. -type workstreamQueueWaitContext struct { - context.Context - waiting chan struct{} - once sync.Once -} - -func (c *workstreamQueueWaitContext) Done() <-chan struct{} { - c.once.Do(func() { close(c.waiting) }) - return c.Context.Done() -} - -// (j), B3: a guarded queue panic releases concurrent waiters, caches an -// unavailable reading for the normal window, and permits a successful retry. -func TestAppWorkstreamFactoryPanicFinishesFlight(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - var clock atomic.Int64 - clock.Store(time.Now().UnixMilli()) - b.now = func() time.Time { return time.UnixMilli(clock.Load()) } - w := workstreamCreateForTest(t, b, "Queue panic", workstreamJobRef("7")) - entered := make(chan struct{}) - release := make(chan struct{}) - var releaseOnce sync.Once - defer releaseOnce.Do(func() { close(release) }) - var calls atomic.Int32 - b.workstreams.queue = func(ctx context.Context, _ string) (api.Queue, error) { - if calls.Add(1) == 1 { - close(entered) - select { - case <-release: - panic("queue reader fault") - case <-ctx.Done(): - return api.Queue{}, ctx.Err() - } - } - return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog", Cost: 1.25}}}, nil - } - b.workstreams.job = func(context.Context, string, int) (api.Job, error) { - t.Error("unavailable queue triggered a job fallback") - return api.Job{}, errors.New("unexpected job fallback") - } - type workstreamGetRead struct { - result appwire.WorkstreamGetResult - err error - } - owner := make(chan workstreamGetRead, 1) - guard.Go("app/workstream-panic-owner-test", func() { - result, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - owner <- workstreamGetRead{result: result, err: err} - }) - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("queue reader did not enter") - } - _, flight, owns := b.workstreams.workstreamQueueStart("f", b.now()) - if flight == nil || owns { - t.Fatal("queue reader did not own a shared flight") - } - ctx, cancel := context.WithTimeout(context.Background(), workstreamFactoryTimeout) - defer cancel() - waitCtx := &workstreamQueueWaitContext{Context: ctx, waiting: make(chan struct{})} - waiter := make(chan workstreamQueueRead, 1) - guard.Go("app/workstream-panic-waiter-test", func() { waiter <- b.workstreamQueue(waitCtx, "f") }) - select { - case <-waitCtx.waiting: - case <-time.After(time.Second): - t.Fatal("concurrent reader did not wait on the flight") - } - releaseOnce.Do(func() { close(release) }) - select { - case read := <-owner: - if read.err != nil { - t.Fatal(read.err) - } - encoded, err := json.Marshal(read.result) - if err != nil { - t.Fatal(err) - } - for _, key := range []string{"merged", "needs_you", "spend_usd"} { - if bytes.Contains(encoded, []byte(`"`+key+`"`)) { - t.Fatalf("panic exposed a number in %s", encoded) - } - } - if read.result.Workstream.Total != 1 { - t.Fatalf("panic lost total: %+v", read.result) - } - case <-time.After(time.Second): - t.Fatal("guarded queue owner did not finish") - } - select { - case <-flight: - default: - t.Fatal("panic did not close the flight") - } - select { - case read := <-waiter: - if read.err == nil || errors.Is(read.err, context.DeadlineExceeded) || !read.at.Equal(b.now()) { - t.Fatalf("waiter did not receive the cached failure: %+v", read) - } - case <-time.After(time.Second): - t.Fatal("panic stranded the concurrent waiter") - } - func() { - b.workstreams.cacheMu.Lock() - defer b.workstreams.cacheMu.Unlock() - if _, ok := b.workstreams.flights["f"]; ok { - t.Fatal("panic left an in-flight entry") - } - }() - entry, flight, owns := b.workstreams.workstreamQueueStart("f", b.now()) - if entry.err == nil || flight != nil || owns || calls.Load() != 1 { - t.Fatalf("panic did not remove and cache the failed flight: %+v, %v, %v", entry, flight, owns) - } - clock.Add((workstreamFactoryFresh - time.Millisecond).Milliseconds()) - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil || calls.Load() != 1 { - t.Fatalf("failure did not stay cached: %+v, %v; calls %d", got, err, calls.Load()) - } - clock.Add(1) - got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || calls.Load() != 2 || got.Workstream.Merged == nil || *got.Workstream.Merged != 0 || got.Workstream.NeedsYou == nil || *got.Workstream.NeedsYou != 0 || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 1.25 { - t.Fatalf("retry after failure-cache expiry: %+v, %v; calls %d", got, err, calls.Load()) - } -} - -// B3: only successful queue reads allow missing-job fallbacks, each distinct -// address is asked once, and work over the per-call allowance remains unknown. -func TestAppWorkstreamMissingJobReadsAreBounded(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - refs := []appwire.WorkstreamRef{} - for i := 1; i <= 25; i++ { - refs = append(refs, workstreamJobRef(fmt.Sprint(i))) - } - w := workstreamCreateForTest(t, b, "Missing", refs...) - queuedReads, jobReads := 0, 0 - b.workstreams.queue = func(ctx context.Context, _ string) (api.Queue, error) { - queuedReads++ - if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > workstreamFactoryTimeout { - t.Error("queue read has no bounded deadline") - } - return api.Queue{}, nil - } - b.workstreams.job = func(ctx context.Context, _ string, job int) (api.Job, error) { - jobReads++ - if _, ok := ctx.Deadline(); !ok { - t.Error("job read has no deadline") - } - return api.Job{ID: job, Num: 999, State: "backlog", Cost: 0}, nil - } - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || queuedReads != 1 || jobReads != 20 || got.Workstream.Total != 25 || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil { - t.Fatalf("bounded fallback %+v, %v; queue %d jobs %d", got, err, queuedReads, jobReads) - } - b.workstreams.cache = nil - jobReads = 0 - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, errors.New("unreachable") } - if _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - if jobReads != 0 { - t.Fatal("unreachable queue triggered per-job reads") - } - open, err := b.OpenCollection(w.ID, 1, 200, "") - if err != nil { - t.Fatal(err) - } - for _, node := range open.Members { - if node.Kind == appwire.RefFactoryJob && (node.Target.State != appwire.TargetUnknown || node.Target.Status != "unknown") { - t.Fatalf("unavailable target %+v", node.Target) - } - } -} - -func TestAppWorkstreamPlanConflictDetailAndDoneRefusal(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Plan") - p := appwire.WorkstreamPlanSetParams{ID: w.ID, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}} - first, err := b.WorkstreamPlanSet(p) - if err != nil { - t.Fatal(err) - } - before := len(workstreamEvents(rec)) - _, err = b.WorkstreamPlanSet(p) - wire := workstreamCheckError(t, err, appwire.CodeVersionConflict) - detail, ok := wire.Detail.(appwire.WorkstreamVersionConflict) - if !ok || detail.ID != w.ID || detail.Version != 1 || detail.UpdatedAt != first.Plan.UpdatedAt || detail.UpdatedBy != "you" { - t.Fatalf("plan conflict %+v", wire) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("stale plan emitted") - } - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - p.BaseVersion = 1 - _, err = b.WorkstreamPlanSet(p) - workstreamCheckError(t, err, appwire.CodeConflict) -} - -// (c): a bare workstream agent survives the existing cleanup path and can be -// reopened after workstream.get restores it into a fresh backend's inventory. -func TestAppWorkstreamBareAgentSurvivesRestart(t *testing.T) { - t.Setenv(home.EnvVar, t.TempDir()) - b, _, _, _ := workstreamBackend(t) - b.collectionsPath = home.Join("v3", "collections.db") - b.workstreams.spend = nil - w := workstreamCreateForTest(t, b, "Persistent") - dir := filepath.Join(session.PlacesRoot(), "project", w.AgentID) - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - transcript := filepath.Join(dir, "transcript.jsonl") - if err := os.WriteFile(transcript, nil, 0600); err != nil { - t.Fatal(err) - } - if err := session.SaveMeta(dir, session.Meta{ID: w.AgentID, Workspace: t.TempDir(), Created: time.Now()}); err != nil { - t.Fatal(err) - } - a := b.agent(w.AgentID) - b.locked(func() { a.transcript = transcript; a.minted = true }) - if err := b.Close(); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(dir); err != nil { - t.Fatal(err) - } - restarted, _, _, _ := workstreamBackend(t) - restarted.collectionsPath = home.Join("v3", "collections.db") - restarted.workstreams.spend = nil - got, err := restarted.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 0 { - t.Fatalf("restore %+v, %v", got, err) - } - if _, err := restarted.OpenAgent(w.AgentID); err != nil { - t.Fatalf("agent could not be reopened: %v", err) - } - listed, err := restarted.ListAgents("") - if err != nil { - t.Fatal(err) - } - if _, found := listsAgent(listed, w.AgentID); found { - t.Fatal("restoring the bare agent changed agents.list") - } -} - -// (i): chat archiving is an external owner action after the committed done -// transaction. Its failure cannot hide that change or duplicate it on retry. -func TestAppWorkstreamDoneArchiveFailureStillAnnouncesCommit(t *testing.T) { - b, rec, _, origin := workstreamBackend(t) - made, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Archive retry", OriginChatID: &origin.ID}) - if err != nil { - t.Fatal(err) - } - b.workstreams.archiveChats = func([]string) error { return appwire.Errorf(appwire.CodeBusy, "chat is busy") } - p := appwire.WorkstreamMarkDoneParams{ID: made.Workstream.ID, ArchiveChats: workstreamPtr(true)} - before := len(workstreamEvents(rec)) - _, err = b.WorkstreamMarkDone(p) - workstreamCheckError(t, err, appwire.CodeBusy) - events := workstreamEvents(rec) - if len(events) != before+1 || events[before].ID != made.Workstream.ID { - t.Fatalf("lost done notification: %+v", events) - } - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: made.Workstream.ID}) - if err != nil || got.Workstream.Stage != "done" { - t.Fatalf("done not committed: %+v, %v", got, err) - } - b.workstreams.archiveChats = func([]string) error { return nil } - if _, err := b.WorkstreamMarkDone(p); err != nil { - t.Fatal(err) - } - if len(workstreamEvents(rec)) != before+1 { - t.Fatal("archive retry duplicated workstream event") - } -} - -// A job that ran again keeps its earlier runs' money in the workstream's -// spend, through the queue read and the missing-job read alike; an older -// factory that sends no lifetime cost still counts the cost it does send. -func TestAppWorkstreamSpendKeepsAJobsEarlierRuns(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{ - {ID: 7, Num: 1107, State: "running", Cost: 0, LifetimeCost: 12}, - {ID: 8, Num: 1108, State: "backlog", Cost: 1.25}, - }}, nil - } - b.workstreams.job = func(_ context.Context, _ string, job int) (api.Job, error) { - return api.Job{ID: job, Num: 1109, State: "running", Cost: 0.5, LifetimeCost: 3}, nil - } - b.workstreams.spend = func(string) *float64 { return workstreamPtr(2.0) } - w := workstreamCreateForTest(t, b, "Reruns", workstreamJobRef("7"), workstreamJobRef("8"), workstreamJobRef("9")) - const want = 12 + 1.25 + 3 + 2 - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != want { - t.Fatalf("get spend %+v, %v; want %v", got.Workstream.SpendUSD, err, want) - } - rows, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil || len(rows.Workstreams) != 1 || rows.Workstreams[0].SpendUSD == nil || *rows.Workstreams[0].SpendUSD != want { - t.Fatalf("list spend %+v, %v; want %v", rows, err, want) - } -} diff --git a/cmd/codeaf/appworkstreams_wake.go b/cmd/codeaf/appworkstreams_wake.go deleted file mode 100644 index 4a1fd48931..0000000000 --- a/cmd/codeaf/appworkstreams_wake.go +++ /dev/null @@ -1,369 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - "sync" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/automation" - "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -const workstreamWakeTimeout = 3 * time.Minute - -// One active call across backends also bounds tests and embedded app instances. -var workstreamWakeTurnMu sync.Mutex - -type workstreamJobEvent struct { - Factory string - Job int - Kind string - ID string - At time.Time - Label string - Detail string -} - -type workstreamWakeTask struct{ Run workstream.WakeRun } - -// WorkstreamJobEvent is the admission hook for the future job event source. -// Queue reads do not call it: each occurrence must have a stable source identity. -func (b *appBackend) WorkstreamJobEvent(ev workstreamJobEvent) (wakeID string, woke bool) { - if ev.At.IsZero() || ev.Factory == "" || ev.Job <= 0 || ev.Kind == "morning" { - return "", false - } - occurrence := ev.ID - if occurrence == "" { - occurrence = ev.At.UTC().Format(time.RFC3339) - } - key := fmt.Sprintf("job:%s:%d:%s:%s", ev.Factory, ev.Job, ev.Kind, occurrence) - event := workstream.WakeEvent{Kind: ev.Kind, Factory: ev.Factory, Job: ev.Job, At: ev.At, Label: ev.Label, Detail: ev.Detail} - return b.workstreamAdmitWake("", key, event) -} - -func (b *appBackend) workstreamAdmitWake(id, key string, event workstream.WakeEvent) (string, bool) { - s, err := b.workstreamStore(false) - if err != nil || s == nil { - return "", false - } - run, woke, err := s.BeginWake(context.Background(), id, key, event) - if err != nil { - log.Printf("workstream wake: %v", err) - return "", false - } - if !woke { - return "", false - } - b.workstreamEnqueueWake(workstreamWakeTask{Run: run}) - return run.ID, true -} - -func (b *appBackend) workstreamEnqueueWake(task workstreamWakeTask) { - state := &b.workstreams - if state.wakeDispatch != nil { - state.wakeDispatch(task) - return - } - state.wakeMu.Lock() - defer state.wakeMu.Unlock() - if state.wakeSignal == nil { - state.wakeSignal = make(chan struct{}, 1) - ctx, cancel := context.WithCancel(context.Background()) - state.wakeCancel = cancel - guard.Go("app/workstream-wake-worker", func() { b.workstreamWakeWorker(ctx) }) - } - state.wakeQueue = append(state.wakeQueue, task) - select { - case state.wakeSignal <- struct{}{}: - default: - } -} - -func (b *appBackend) workstreamWakeWorker(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-b.workstreams.wakeSignal: - } - for { - if ctx.Err() != nil { - return - } - task, ok := b.workstreamNextWake() - if !ok { - break - } - b.workstreamRunWake(ctx, task) - } - } -} - -func (b *appBackend) watchWorkstreamWakes(stop <-chan struct{}) { - timer := time.NewTimer(30 * time.Second) - defer timer.Stop() - defer func() { - b.workstreams.wakeMu.Lock() - defer b.workstreams.wakeMu.Unlock() - if b.workstreams.wakeCancel != nil { - b.workstreams.wakeCancel() - } - }() - for { - select { - case <-stop: - return - case <-timer.C: - } - b.workstreamWakeTick(b.now()) - timer.Reset(time.Minute) - } -} - -func (b *appBackend) workstreamExpireWakes(s *workstream.Store) error { - changes, err := s.ExpireWakes(context.Background()) - if err == nil { - for _, change := range changes { - b.workstreamChanged(change) - } - } - return err -} - -// workstreamMorningSlot uses the automation clock's local-zone cron semantics. -func workstreamMorningSlot(now time.Time) (time.Time, error) { - local := now.In(time.Local) - midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local) - schedule := automation.Schedule{Every: "0 8 * * *"} - slot, err := schedule.Next(midnight.Add(-time.Minute)) - if err != nil || slot.After(now) { - return time.Time{}, err - } - return slot, nil -} -func (b *appBackend) workstreamWakeTick(now time.Time) { - s, err := b.workstreamStore(false) - if err != nil || s == nil { - return - } - if err := b.workstreamExpireWakes(s); err != nil { - log.Printf("workstream wake: %v", err) - return - } - slot, err := workstreamMorningSlot(now) - if err != nil || slot.IsZero() { - return - } - streams, err := s.List(context.Background()) - if err != nil { - return - } - for _, w := range streams { - if w.Stage == "done" || w.Autonomy == "off" || !w.Wake.Morning || slot.Before(w.CreatedAt) { - continue - } - b.workstreamAdmitWake(w.ID, "morning:"+slot.In(time.Local).Format("2006-01-02"), workstream.WakeEvent{Kind: "morning", At: slot}) - } -} - -// workstreamNextWake takes the oldest queued wake, if any. -func (b *appBackend) workstreamNextWake() (workstreamWakeTask, bool) { - b.workstreams.wakeMu.Lock() - defer b.workstreams.wakeMu.Unlock() - if len(b.workstreams.wakeQueue) == 0 { - return workstreamWakeTask{}, false - } - task := b.workstreams.wakeQueue[0] - b.workstreams.wakeQueue = b.workstreams.wakeQueue[1:] - return task, true -} - -func (b *appBackend) workstreamRunWake(parent context.Context, task workstreamWakeTask) { - workstreamWakeTurnMu.Lock() - defer workstreamWakeTurnMu.Unlock() - ctx, cancel := context.WithTimeout(parent, workstreamWakeTimeout) - defer cancel() - s, err := b.workstreamStore(false) - if err != nil || s == nil { - return - } - run, err := s.WakeRun(ctx, task.Run.ID) - if err != nil || run.State != "running" { - return - } - if b.now().Sub(run.StartedAt) > workstream.WakeAbandonedAfter { - _ = b.workstreamExpireWakes(s) - return - } - task.Run = run - w, err := s.Get(ctx, task.Run.WorkstreamID) - if err != nil { - return - } - // A queued turn must not call the model after done or off was chosen. - if w.Stage == "done" || w.Autonomy == "off" { - _, change, err := s.SettleWake(context.Background(), task.Run.ID, workstream.WakeAnswer{}, nil, "") - if err == nil { - b.workstreamChanged(change) - } - return - } - ask, err := b.workstreamWakeSnapshot(ctx, s, w, task.Run) - var answer session.WorkstreamWakeAnswer - if err == nil { - turn := b.workstreams.wakeTurn - if turn == nil { - turn = b.workstreamRealWakeTurn - } - answer, err = turn(ctx, ask) - } - parsed := workstream.WakeAnswer{} - failure := "" - if err != nil { - failure = "The agent could not finish this wake." - } else { - parsed, err = workstream.ParseWakeAnswer(answer.Text) - if err != nil { - failure = workstream.WakeAnswerError - } - } - _, change, err := s.SettleWake(context.Background(), task.Run.ID, parsed, answer.CostUSD, failure) - if err != nil { - // A refused small change rolls back the entire batch; its call cost is still owed. - _, change, err = s.SettleWake(context.Background(), task.Run.ID, workstream.WakeAnswer{}, answer.CostUSD, "The agent's changes could not be saved.") - } - if err == nil { - b.workstreamChanged(change) - } else { - log.Printf("workstream wake settle: %v", err) - } -} - -func (b *appBackend) workstreamWakeSnapshot(ctx context.Context, s *workstream.Store, w workstream.Workstream, run workstream.WakeRun) (session.WorkstreamWakeAsk, error) { - door := &workstreamAgentDoor{backend: b, agentID: w.AgentID} - snapshot := map[string]json.RawMessage{} - for _, what := range []string{"workstream", "plan", "pages"} { - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: what}) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - snapshot[what] = raw - } - records := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} - for _, state := range []string{"kept", "proposed"} { - rows, err := s.Records(ctx, w.ID, "", state, true) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - for _, r := range rows { - records.Records = append(records.Records, appWorkstreamRecord(r)) - } - } - rawRecords, err := json.Marshal(records) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - snapshot["records"] = rawRecords - grants, err := s.GoAheads(ctx, w.ID, false) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - goaheads := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} - for _, g := range grants { - goaheads.GoAheads = append(goaheads.GoAheads, appWorkstreamGoAhead(g)) - } - rawGrants, err := json.Marshal(goaheads) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - snapshot["goaheads"] = rawGrants - pages, err := s.Pages(ctx, w.ID) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - for _, p := range pages { - if p.Owner != "agent" || p.Title != session.WorkstreamWhereItStandsTitle { - continue - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: p.ID}) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - snapshot["where_it_stands"] = raw - break - } - // The existing read mappers preserve numbers, plan draft ids and page ownership. - raw, err := json.Marshal(snapshot) - return session.WorkstreamWakeAsk{ID: run.ID, AgentID: w.AgentID, Event: run.Event, Snapshot: raw}, err -} - -func (b *appBackend) workstreamRealWakeTurn(ctx context.Context, ask session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - source, err := v3RolesSource("", b.crewProfile()) - if err != nil { - return session.WorkstreamWakeAnswer{}, err - } - proc, err := openAppEngineProcess() - if err != nil { - return session.WorkstreamWakeAnswer{}, err - } - settings := proc.Settings - return session.RunWorkstreamWake(ctx, session.TeamProposerConfig{Model: settings.Model, APIKey: settings.APIKey, BaseURL: settings.BaseURL, Sources: settings.Sources, ProfileDir: settings.ProfileDir, RolesSource: source}, ask) -} - -func appWorkstreamWake(run workstream.WakeRun) appwire.WorkstreamWakeRun { - out := appwire.WorkstreamWakeRun{ID: run.ID, WorkstreamID: run.WorkstreamID, Event: appwire.WorkstreamWakeEvent{Kind: run.Event.Kind, Factory: workstreamOptional(run.Event.Factory), Job: workstreamPositive(run.Event.Job), Label: workstreamOptional(run.Event.Label)}, State: run.State, StartedAt: run.StartedAt, SettledAt: run.SettledAt, CostUSD: run.CostUSD, Summary: workstreamOptional(run.Summary), Error: workstreamOptional(run.Error), DismissedAt: run.DismissedAt, Items: []appwire.WorkstreamWakeItem{}} - for _, item := range run.Items { - out.Items = append(out.Items, appwire.WorkstreamWakeItem{ID: item.ID, Kind: item.Kind, State: item.State, Text: item.Text, Why: workstreamOptional(item.Why), Factory: workstreamOptional(item.Factory), Job: workstreamPositive(item.Job), DraftID: workstreamOptional(item.DraftID), Wave: workstreamPositive(item.Wave), PageID: workstreamOptional(item.PageID), RecordID: workstreamOptional(item.RecordID), BudgetUSD: item.BudgetUSD, Note: workstreamOptional(item.Note), AppliedAt: item.AppliedAt, UndoneAt: item.UndoneAt}) - } - return out -} - -func (b *appBackend) WorkstreamWakeList(p appwire.WorkstreamWakeListParams) (appwire.WorkstreamWakeListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeListResult, workstream.Change, error) { - out := appwire.WorkstreamWakeListResult{Wakes: []appwire.WorkstreamWakeRun{}} - if err := b.workstreamExpireWakes(s); err != nil { - return out, workstream.Change{}, err - } - limit := 20 - if p.Limit != nil { - limit = *p.Limit - } - wakes, err := s.Wakes(context.Background(), p.WorkstreamID, limit) - for _, run := range wakes { - out.Wakes = append(out.Wakes, appWorkstreamWake(run)) - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamWakeApply(p appwire.WorkstreamWakeApplyParams) (appwire.WorkstreamWakeApplyResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeApplyResult, workstream.Change, error) { - item := "" - if p.ItemID != nil { - item = *p.ItemID - } - run, c, err := s.ActOnWake(context.Background(), p.ID, item, "apply") - return appwire.WorkstreamWakeApplyResult{Wake: appWorkstreamWake(run)}, c, err - }) -} -func (b *appBackend) WorkstreamWakeUndo(p appwire.WorkstreamWakeUndoParams) (appwire.WorkstreamWakeUndoResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeUndoResult, workstream.Change, error) { - item := "" - if p.ItemID != nil { - item = *p.ItemID - } - run, c, err := s.ActOnWake(context.Background(), p.ID, item, "undo") - return appwire.WorkstreamWakeUndoResult{Wake: appWorkstreamWake(run)}, c, err - }) -} -func (b *appBackend) WorkstreamWakeNotNow(p appwire.WorkstreamWakeNotNowParams) (appwire.WorkstreamWakeNotNowResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeNotNowResult, workstream.Change, error) { - run, c, err := s.ActOnWake(context.Background(), p.ID, "", "not_now") - return appwire.WorkstreamWakeNotNowResult{Wake: appWorkstreamWake(run)}, c, err - }) -} diff --git a/cmd/codeaf/appworkstreams_wake_test.go b/cmd/codeaf/appworkstreams_wake_test.go deleted file mode 100644 index d76646a831..0000000000 --- a/cmd/codeaf/appworkstreams_wake_test.go +++ /dev/null @@ -1,452 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "reflect" - "slices" - "sync" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/factory/api" - "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func workstreamWakeBackend(t *testing.T) (*appBackend, *appRecorder, *time.Time, *[]workstreamWakeTask, *int) { - t.Helper() - b, rec, _, _ := workstreamBackend(t) - now := time.Date(2026, 11, 3, 7, 59, 0, 0, time.Local) - b.now = func() time.Time { return now } - tasks := []workstreamWakeTask{} - b.workstreams.wakeDispatch = func(task workstreamWakeTask) { tasks = append(tasks, task) } - turns := 0 - b.workstreams.wakeTurn = func(ctx context.Context, ask session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - turns++ - if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > workstreamWakeTimeout { - t.Error("wake timeout missing") - } - return session.WorkstreamWakeAnswer{Text: `{"summary":"Review next steps.","items":[{"kind":"decision","text":"Review first."},{"kind":"draft","title":"Follow up","budget_usd":3}]}`, CostUSD: workstreamPtr(.04), Model: "fake"}, nil - } - return b, rec, &now, &tasks, &turns -} -func workstreamDrainWakes(b *appBackend, tasks *[]workstreamWakeTask) { - pending := *tasks - *tasks = nil - for _, task := range pending { - b.workstreamRunWake(context.Background(), task) - } -} -func workstreamWakeListForTest(t *testing.T, b *appBackend, id string) []appwire.WorkstreamWakeRun { - t.Helper() - out, err := b.WorkstreamWakeList(appwire.WorkstreamWakeListParams{WorkstreamID: id}) - if err != nil { - t.Fatal(err) - } - return out.Wakes -} - -func TestWakeA_MorningOncePerLocalDay(t *testing.T) { - b, _, now, tasks, turns := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Morning") - if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true}}}); err != nil { - t.Fatal(err) - } - b.workstreamWakeTick(*now) - if len(*tasks) != 0 { - t.Fatal("woke before first slot") - } - *now = now.Add(time.Minute) - b.workstreamWakeTick(*now) - workstreamDrainWakes(b, tasks) - for range 60 { - *now = now.Add(time.Minute) - b.workstreamWakeTick(*now) - } - if *turns != 1 || len(*tasks) != 0 { - t.Fatal("morning repeated") - } - *now = now.AddDate(0, 0, 1) - b.workstreamWakeTick(*now) - workstreamDrainWakes(b, tasks) - if *turns != 2 || len(workstreamWakeListForTest(t, b, w.ID)) != 2 { - t.Fatal("next day did not wake") - } - // Closed days produce no rows; opening late admits just the current day's slot. - *now = now.AddDate(0, 0, 3) - b.workstreamWakeTick(*now) - workstreamDrainWakes(b, tasks) - if *turns != 3 { - t.Fatal("closed days were replayed") - } - off := "off" - if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}); err != nil { - t.Fatal(err) - } - *now = now.AddDate(0, 0, 1) - b.workstreamWakeTick(*now) - if len(*tasks) != 0 { - t.Fatal("off woke") - } -} - -func TestWakeA_DisabledAndCreatedAfterSlotDoNotWake(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - *now = time.Date(2026, 11, 3, 12, 0, 0, 0, time.Local) - w := workstreamCreateForTest(t, b, "Late") - b.workstreamWakeTick(*now) - if len(*tasks) != 0 { - t.Fatal("disabled morning woke") - } - b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true, RelatedIssues: true}}}) - b.workstreamWakeTick(*now) - if len(*tasks) != 0 { - t.Fatal("slot before creation woke") - } -} - -func TestWakeB_JobHookBatchesAndDeduplicates(t *testing.T) { - b, rec, now, tasks, turns := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Events", workstreamJobRef("7")) - ev := workstreamJobEvent{Factory: "f", Job: 7, Kind: "merged", ID: "merge-1", At: *now, Label: "api#7 merged", Detail: "The merge was recorded."} - id, woke := b.WorkstreamJobEvent(ev) - if !woke || id == "" || len(*tasks) != 1 { - t.Fatal(id, woke) - } - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("duplicate woke") - } - ev.Job = 8 - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("nonmember woke") - } - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - if *turns != 1 || len(runs) != 1 || len(runs[0].Items) != 2 || runs[0].State != "settled" { - t.Fatal(runs, *turns) - } - count := 0 - for _, e := range workstreamEvents(rec) { - if slices.Contains(e.Parts, "wakes") { - count++ - } - } - if count != 1 { - t.Fatal("wanted one settle receipt", count) - } - off := "off" - b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}) - ev.Job = 7 - ev.ID = "off" - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("off job event woke") - } -} - -func TestWakeF_CostIsRecordedAndAddedToAllSpendResults(t *testing.T) { - for _, mode := range []string{"known", "unknown", "unreadable"} { - t.Run(mode, func(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{{ID: 7, State: "done", Cost: 2}}}, nil - } - w := workstreamCreateForTest(t, b, "Spend", workstreamJobRef("7")) - before, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || before.Workstream.SpendUSD == nil { - t.Fatal(before, err) - } - b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - a := session.WorkstreamWakeAnswer{Text: `{"summary":"Review","items":[]}`, CostUSD: workstreamPtr(.04)} - if mode == "unknown" { - a.CostUSD = nil - } - if mode == "unreadable" { - a.Text = "not JSON" - } - return a, nil - } - if _, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}); !woke { - t.Fatal("did not wake") - } - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil { - t.Fatal(err) - } - want := *before.Workstream.SpendUSD - if mode != "unknown" { - want += .04 - } - if after.Workstream.SpendUSD == nil || *after.Workstream.SpendUSD != want || list.Workstreams[0].SpendUSD == nil || *list.Workstreams[0].SpendUSD != want { - t.Fatal("spend drift", after, list) - } - if mode == "unknown" { - if runs[0].CostUSD != nil { - t.Fatal("fabricated cost") - } - } else if runs[0].CostUSD == nil || *runs[0].CostUSD != .04 { - t.Fatal("cost lost") - } - if mode == "unreadable" && (runs[0].State != "failed" || workstreamValue(runs[0].Error) != workstream.WakeAnswerError) { - t.Fatal(runs) - } - }) - } -} - -func TestWakeG_WireActionsSendOneReceiptPerChange(t *testing.T) { - b, rec, now, tasks, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Actions", workstreamJobRef("7")) - id, _ := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "failed", At: *now}) - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - item := runs[0].Items[0].ID - events := func() int { return len(workstreamEvents(rec)) } - before := events() - first, err := b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id, ItemID: &item}) - if err != nil || events() != before+1 { - t.Fatal(first, err) - } - again, err := b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id, ItemID: &item}) - if err != nil || events() != before+1 || !reflect.DeepEqual(first, again) { - t.Fatal("repeated apply wrote") - } - if _, err := b.WorkstreamWakeNotNow(appwire.WorkstreamWakeNotNowParams{ID: id}); err != nil || events() != before+2 { - t.Fatal(err) - } - b.WorkstreamWakeNotNow(appwire.WorkstreamWakeNotNowParams{ID: id}) - if events() != before+2 { - t.Fatal("repeated dismissal wrote") - } - if _, err := b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: &item}); err != nil || events() != before+3 { - t.Fatal(err) - } - b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: &item}) - if events() != before+3 { - t.Fatal("repeated undo wrote") - } - _, err = b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - _, err = b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: workstreamPtr("missing")}) - workstreamCheckError(t, err, appwire.CodeNotFound) - b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) - _, err = b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeConflict) - _, err = b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeConflict) -} - -func TestWakeH_DoneAndOffAreRereadAtSettlement(t *testing.T) { - for _, choice := range []string{"done", "off"} { - t.Run(choice, func(t *testing.T) { - b, _, now, tasks, turns := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Finish", workstreamJobRef("7")) - small := "small" - b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &small, Wake: &appwire.WorkstreamWake{Morning: true}}}) - b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - *turns++ - if choice == "done" { - _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - } else { - off := "off" - _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}) - if err != nil { - t.Fatal(err) - } - } - return session.WorkstreamWakeAnswer{Text: `{"summary":"Follow up","items":[{"kind":"draft","title":"Follow up","budget_usd":3},{"kind":"page","title":"Notes","markdown":"Next"}]}`}, nil - } - b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "needs_you", At: *now}) - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - for _, i := range runs[0].Items { - if i.State != "proposed" { - t.Fatal(i) - } - } - *now = now.AddDate(0, 0, 1) - b.workstreamWakeTick(*now) - if _, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}); woke || len(*tasks) != 0 || *turns != 1 { - t.Fatal("stopped workstream woke") - } - }) - } -} - -func TestWakeE_NoFactoryActionsAndPersonPageIsUntouched(t *testing.T) { - for _, autonomy := range []string{"propose", "small"} { - t.Run(autonomy, func(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Pages", workstreamJobRef("7")) - queueReads, jobReads := 0, 0 - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - queueReads++ - return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil - } - b.workstreams.job = func(context.Context, string, int) (api.Job, error) { jobReads++; return api.Job{}, nil } - b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &autonomy}}) - pages, _ := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - person, _ := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: pages.Pages[0].ID}) - b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - raw, _ := json.Marshal(map[string]any{"summary": "Next", "items": []any{map[string]any{"kind": "page", "page_id": person.Page.ID, "markdown": "Replace"}, map[string]any{"kind": "draft", "title": "Follow up", "budget_usd": 3}}}) - return session.WorkstreamWakeAnswer{Text: string(raw)}, nil - } - id, _ := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - if len(runs[0].Items) != 1 || runs[0].Items[0].Kind != "draft" { - t.Fatal(runs) - } - b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id}) - b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id}) - after, _ := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: person.Page.ID}) - if !reflect.DeepEqual(person, after) { - t.Fatal("person page changed") - } - // These seams are read-only Factory doors. No create/start door is reachable here. - if queueReads == 0 || jobReads != 0 { - t.Fatal("unexpected Factory door use", queueReads, jobReads) - } - }) - } -} - -func TestWakeB_ConcurrentDuplicateClaimsHaveOneTurn(t *testing.T) { - b, _, now, _, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Race", workstreamJobRef("7")) - var mu sync.Mutex - tasks := []workstreamWakeTask{} - b.workstreams.wakeDispatch = func(task workstreamWakeTask) { mu.Lock(); defer mu.Unlock(); tasks = append(tasks, task) } - var wg sync.WaitGroup - for range 8 { - wg.Add(1) - guard.Go("app/workstream-wake-claim-test", func() { - defer wg.Done() - b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "merged", ID: "one", At: *now}) - }) - } - wg.Wait() - if len(tasks) != 1 { - t.Fatal("multiple claims", len(tasks)) - } - workstreamDrainWakes(b, &tasks) - if runs := workstreamWakeListForTest(t, b, w.ID); len(runs) != 1 { - t.Fatal(runs) - } -} - -func TestWakeB_EventFallbackKeyUsesUTCSecondsAndValidatesWords(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Keys", workstreamJobRef("7")) - ev := workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: now.Add(100 * time.Millisecond)} - if _, woke := b.WorkstreamJobEvent(ev); !woke { - t.Fatal("first did not wake") - } - ev.At = ev.At.UTC().Add(200 * time.Millisecond) - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("same UTC second was not deduplicated") - } - for _, kind := range []string{"unknown", "morning"} { - ev.Kind = kind - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("invalid kind woke") - } - } - ev.Kind = "failed" - ev.Label = string(make([]byte, 201)) - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("oversize label woke") - } - workstreamDrainWakes(b, tasks) - if len(workstreamWakeListForTest(t, b, w.ID)) != 1 { - t.Fatal("invalid event made a row") - } -} - -func TestWakeA_LatestSlotIncludesExactEight(t *testing.T) { - now := time.Date(2026, 11, 3, 8, 0, 0, 0, time.Local) - for _, tc := range []struct{ at, want time.Time }{{now, now}, {now.Add(7 * time.Hour), now}, {now.Add(-time.Minute), time.Time{}}} { - slot, err := workstreamMorningSlot(tc.at) - if err != nil || !slot.Equal(tc.want) { - t.Fatalf("%s: slot %s want %s (%v)", tc.at, slot, tc.want, err) - } - } -} - -func TestWakeA_M3_PreEightOpeningNeverCatchesUpClosedDay(t *testing.T) { - b, _, now, tasks, turns := workstreamWakeBackend(t) - *now = time.Date(2026, 11, 1, 7, 59, 0, 0, time.Local) - w := workstreamCreateForTest(t, b, "Closed day") - if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true}}}); err != nil { - t.Fatal(err) - } - // No tick happens on the intervening day while the app is closed. - *now = time.Date(2026, 11, 3, 7, 0, 0, 0, time.Local) - b.workstreamWakeTick(*now) - if len(*tasks) != 0 || *turns != 0 { - t.Fatal("opening before 08:00 queued a closed day's wake") - } - if runs := workstreamWakeListForTest(t, b, w.ID); len(runs) != 0 { - t.Fatalf("opening before 08:00 created a wake row: %+v", runs) - } - *now = time.Date(2026, 11, 3, 8, 0, 0, 0, time.Local) - b.workstreamWakeTick(*now) - if len(*tasks) != 1 { - t.Fatalf("08:00 queued %d wakes, want one", len(*tasks)) - } - workstreamDrainWakes(b, tasks) - b.workstreamWakeTick(*now) - if *turns != 1 || len(*tasks) != 0 { - t.Fatalf("today did not wake exactly once: %d turns, %d queued", *turns, len(*tasks)) - } - s, err := b.workstreamStore(false) - if err != nil || s == nil { - t.Fatalf("store: %v", err) - } - runs, err := s.Wakes(context.Background(), w.ID, 100) - if err != nil || len(runs) != 1 || runs[0].Event.Kind != "morning" || !runs[0].Event.At.Equal(*now) || !runs[0].StartedAt.Equal(*now) || runs[0].State != "settled" { - t.Fatalf("expected only today's 08:00 wake: %+v %v", runs, err) - } -} - -func TestWakeF_RecordedWakeDoesNotInventUnknownSpend(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Unknown spend", workstreamJobRef("7")) - b.workstreams.spend = func(string) *float64 { return nil } - b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) - workstreamDrainWakes(b, tasks) - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.SpendUSD != nil { - t.Fatal(got, err) - } - listed, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil || listed.Workstreams[0].SpendUSD != nil { - t.Fatal(listed, err) - } - if runs := workstreamWakeListForTest(t, b, w.ID); runs[0].CostUSD == nil || *runs[0].CostUSD != .04 { - t.Fatal(runs) - } -} - -func TestWakeB_ExpiredQueuedClaimNeverCallsTheModel(t *testing.T) { - b, _, now, tasks, turns := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Stopped", workstreamJobRef("7")) - b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) - *now = now.Add(workstream.WakeAbandonedAfter + time.Millisecond) - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - if *turns != 0 || runs[0].State != "failed" || workstreamValue(runs[0].Error) != workstream.WakeStoppedError || runs[0].CostUSD != nil { - t.Fatal(runs, *turns) - } -} diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index d5eab92341..2197ff6065 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -718,8 +718,6 @@ func v3EmptySession(dir string) bool { return v3EmptyUnassignedSession(dir) } -var v3WorkstreamKeeps = workstreamKeepsConversation - func v3EmptyUnassignedSession(dir string) bool { place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) @@ -733,7 +731,7 @@ func v3EmptyUnassignedSession(dir string) bool { return false } } - return !v3WorkstreamKeeps(dir) && !collectionKeepsConversation(dir) + return !collectionKeepsConversation(dir) } // v3ReapEmpty removes the empty session folders a launch left behind, except diff --git a/cmd/codeaf/chatv3_layout_stab_test.go b/cmd/codeaf/chatv3_layout_stab_test.go deleted file mode 100644 index aa0ae2c616..0000000000 --- a/cmd/codeaf/chatv3_layout_stab_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "reflect" - "testing" - "time" -) - -// E-M3-a: a spoken transcript proves the folder must stay without consulting -// the workstream owner, even when its last-spoken metadata is absent. -func TestStabEM3SpokenSessionsSkipWorkstreamOwnership(t *testing.T) { - dir := writeV3Session(t, t.TempDir(), "spoken", "Keep this conversation", time.Time{}) - previous := v3WorkstreamKeeps - t.Cleanup(func() { v3WorkstreamKeeps = previous }) - lookups := 0 - v3WorkstreamKeeps = func(string) bool { - lookups++ - return false - } - if v3EmptyUnassignedSession(dir) { - t.Fatal("a spoken conversation was considered empty") - } - if lookups != 0 { - t.Fatalf("spoken conversation made %d ownership lookups, want 0", lookups) - } -} - -// E-M3-b: only a provably empty folder needs an ownership lookup, and a kept -// answer must prevent its reuse or removal. -func TestStabEM3EmptySessionsCheckWorkstreamOwnershipOnce(t *testing.T) { - dir := writeV3Session(t, t.TempDir(), "empty", "", time.Time{}) - for _, tc := range []struct { - name string - kept bool - empty bool - }{ - {"kept", true, false}, - {"unowned", false, true}, - } { - t.Run(tc.name, func(t *testing.T) { - previous := v3WorkstreamKeeps - t.Cleanup(func() { v3WorkstreamKeeps = previous }) - lookups := 0 - v3WorkstreamKeeps = func(got string) bool { - lookups++ - if got != dir { - t.Errorf("ownership lookup for %q, want %q", got, dir) - } - return tc.kept - } - if empty := v3EmptyUnassignedSession(dir); empty != tc.empty { - t.Fatalf("empty = %v, want %v when kept = %v", empty, tc.empty, tc.kept) - } - if lookups != 1 { - t.Fatalf("empty conversation made %d ownership lookups, want 1", lookups) - } - }) - } -} - -// E-M3-c: scanning several spoken folders and one empty folder asks about -// workstream ownership only for the empty candidate. -func TestStabEM3BucketScanOnlyLooksUpEmptyCandidates(t *testing.T) { - t.Setenv("CODEAF_HOME", t.TempDir()) - t.Setenv("CODEAF_PROFILE_DIR", t.TempDir()) - bucket := t.TempDir() - for _, id := range []string{"00000000000000a1", "00000000000000a2", "00000000000000a3"} { - writeV3Session(t, bucket, id, "Keep this conversation", time.Time{}) - } - dir := writeV3Session(t, bucket, "00000000000000a4", "", time.Time{}) - previous := v3WorkstreamKeeps - t.Cleanup(func() { v3WorkstreamKeeps = previous }) - var lookups []string - v3WorkstreamKeeps = func(dir string) bool { - lookups = append(lookups, dir) - return false - } - spoken, empty := v3ScanBucket(bucket) - if len(spoken) != 3 || len(empty) != 1 || empty[0].dir != dir { - t.Fatalf("bucket scan returned spoken %v, empty %v", spoken, empty) - } - if !reflect.DeepEqual(lookups, []string{dir}) { - t.Fatalf("ownership lookups = %v, want only %q", lookups, dir) - } -} diff --git a/cmd/codeaf/collections.go b/cmd/codeaf/collections.go index e77b1e9299..f0afef48ba 100644 --- a/cmd/codeaf/collections.go +++ b/cmd/codeaf/collections.go @@ -122,11 +122,7 @@ func runCollectionsTo(args []string, output io.Writer) error { result, err = store.Create(ctx, rest[1]) case "rename": err = store.Rename(ctx, rest[1], rest[2]) - if err == nil { - var summary workspace.Summary - summary, err = store.Describe(ctx, rest[1]) - result = summary.Collection - } + result = workspace.Collection{ID: rest[1], Name: rest[2]} case "duplicate": result, err = store.Duplicate(ctx, rest[1], rest[2]) case "delete": diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 829b3bd308..c13491c837 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1152,7 +1152,6 @@ 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/cmd/codeaf/testdata/model_spend_owners.tsv b/cmd/codeaf/testdata/model_spend_owners.tsv index 3eb9f26fdd..0d4c27a918 100644 --- a/cmd/codeaf/testdata/model_spend_owners.tsv +++ b/cmd/codeaf/testdata/model_spend_owners.tsv @@ -76,7 +76,6 @@ internal/session/tools_music.go:Agent.composeMusic:GenerateMusic exception: musi internal/session/tools_sense.go:Agent.audioSense:Transcribe exception: speech internal/session/tools_speak.go:Agent.speakTool:Speak exception: speech internal/session/tools_video.go:Agent.renderVideo:GenerateVideo exception: video -internal/session/workstream_wake.go:workstreamWakeCall:CompleteWithMessages owner: Workstream wake (workstreamWakeCall -> session.RecordUsage; its cost_usd counts in the workstream spend) internal/shaped/prose.go:Prose:CompleteWithMessages exception: untraced shaped helpers internal/shaped/shaped.go:Answer:CompleteWithMessages exception: untraced shaped helpers internal/shaped/shaped.go:repair:CompleteWithMessages exception: untraced shaped helpers diff --git a/cmd/codeaf/testdata/workstreams-wire/README.md b/cmd/codeaf/testdata/workstreams-wire/README.md deleted file mode 100644 index 29bc64a7b9..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# workstreams-wire - -The examples the desk's contract carries; regenerate when the contract changes: - - python3 -I extract.py . - -`contract.json` holds the method index, the event, the error codes, the enums and every -field table (type, required). Each other file holds one method's or event's examples. -`appworkstreams_conformance_test.go` sends the examples through the wire handler and -compares each answer's shape with them. Values may differ; shapes may not. diff --git a/cmd/codeaf/testdata/workstreams-wire/collections.add.json b/cmd/codeaf/testdata/workstreams-wire/collections.add.json deleted file mode 100644 index 767a4e61ed..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/collections.add.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "method": "collections.add", - "examples": { - "params": [ - { - "id": "ws_8f2c1a", - "refs": [ - { - "kind": "factory_job", - "id": "1107", - "factory": "f_local" - } - ] - } - ], - "result": [ - { - "added": [ - { - "kind": "factory_job", - "id": "1107", - "factory": "f_local", - "added_at": "2026-11-02T14:05:00Z" - } - ], - "already": [] - } - ], - "error": [ - { - "code": "in_other_workstream", - "message": "that job belongs to another workstream", - "detail": { - "factory": "f_local", - "job": 1107, - "workstream_id": "ws_92b7", - "workstream_name": "Account migration" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/contract.json b/cmd/codeaf/testdata/workstreams-wire/contract.json deleted file mode 100644 index 59f93b1a4e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/contract.json +++ /dev/null @@ -1,1865 +0,0 @@ -{ - "methods": [ - "workstream.list", - "workstream.get", - "workstream.create", - "workstream.update", - "workstream.mark_done", - "workstream.reopen", - "workstream.plan_get", - "workstream.plan_set", - "page.list", - "page.get", - "page.save", - "page.create", - "page.update", - "page.move", - "page.delete", - "page.history", - "page.restore", - "record.list", - "record.propose", - "record.keep", - "record.drop", - "record.supersede", - "goahead.list", - "goahead.grant", - "goahead.revoke", - "wake.list", - "wake.apply", - "wake.undo", - "wake.not_now", - "suggestion.list", - "suggestion.keep", - "suggestion.drop", - "source.list", - "source.add", - "source.update", - "source.remove" - ], - "events": [ - { - "event": "workstream.changed", - "data_type": "WorkstreamChanged" - } - ], - "errors": [ - { - "code": "bad_params", - "desk_local": false, - "detail_type": null - }, - { - "code": "not_found", - "desk_local": false, - "detail_type": null - }, - { - "code": "conflict", - "desk_local": false, - "detail_type": null - }, - { - "code": "in_other_workstream", - "desk_local": false, - "detail_type": "WorkstreamTaken" - }, - { - "code": "version_conflict", - "desk_local": false, - "detail_type": "WorkstreamVersionConflict" - }, - { - "code": "cycle", - "desk_local": false, - "detail_type": null - }, - { - "code": "too_large", - "desk_local": false, - "detail_type": null - }, - { - "code": "busy", - "desk_local": false, - "detail_type": null - }, - { - "code": "unknown_method", - "desk_local": false, - "detail_type": null - }, - { - "code": "unsupported", - "desk_local": true, - "detail_type": null - } - ], - "enums": { - "WorkstreamStage": [ - "shaping", - "building", - "done" - ], - "WorkstreamAutonomy": [ - "propose", - "small", - "off" - ], - "WorkstreamActor": [ - "you", - "agent" - ], - "WorkstreamRefKind": [ - "collection", - "conversation", - "task", - "artifact", - "crew", - "process", - "factory_job" - ], - "WorkstreamSourceKind": [ - "chat", - "message", - "factory_job", - "page", - "url", - "suggestion" - ], - "WorkstreamPlanItemKind": [ - "job", - "draft" - ], - "WorkstreamDraftSize": [ - "S", - "M", - "L" - ], - "WorkstreamRecordKind": [ - "decision", - "question", - "goahead" - ], - "WorkstreamRecordState": [ - "proposed", - "kept", - "dropped", - "superseded" - ], - "WorkstreamGoAheadScope": [ - "job", - "wave", - "milestone" - ], - "WorkstreamGoAheadState": [ - "active", - "ended" - ], - "WorkstreamGoAheadEndReason": [ - "expired", - "revoked", - "workstream_done" - ], - "WorkstreamChangedPart": [ - "workstream", - "plan", - "pages", - "records", - "goaheads", - "jobs", - "wakes", - "sources" - ], - "WorkstreamWakeKind": [ - "morning", - "finished", - "merged", - "failed", - "needs_you" - ], - "WorkstreamWakeRunState": [ - "running", - "settled", - "failed" - ], - "WorkstreamWakeItemKind": [ - "move", - "draft", - "page", - "decision", - "question" - ], - "WorkstreamWakeItemState": [ - "applied", - "undone", - "proposed", - "dismissed" - ], - "WorkstreamSuggestionEditState": [ - "proposed", - "kept", - "dropped" - ], - "WorkstreamSourceItemKind": [ - "file", - "repo", - "issue", - "link", - "kept" - ], - "WorkstreamKeptKind": [ - "record", - "page", - "message", - "factory_job" - ] - }, - "types": { - "WorkstreamWake": { - "morning": { - "type": "boolean", - "required": true - }, - "related_issues": { - "type": "boolean", - "required": true - } - }, - "WorkstreamRef": { - "kind": { - "type": "WorkstreamRefKind", - "required": true - }, - "id": { - "type": "string", - "required": true - }, - "session_id": { - "type": "string", - "required": false - }, - "factory": { - "type": "string", - "required": false - } - }, - "WorkstreamSource": { - "kind": { - "type": "WorkstreamSourceKind", - "required": true - }, - "id": { - "type": "string", - "required": true - }, - "chat_id": { - "type": "string", - "required": false - }, - "factory": { - "type": "string", - "required": false - }, - "label": { - "type": "string", - "required": false - } - }, - "WorkstreamRow": { - "id": { - "type": "string", - "required": true - }, - "name": { - "type": "string", - "required": true - }, - "stage": { - "type": "WorkstreamStage", - "required": true - }, - "total": { - "type": "integer", - "required": true - }, - "merged": { - "type": "integer", - "required": false - }, - "needs_you": { - "type": "integer", - "required": false - }, - "spend_usd": { - "type": "number (USD)", - "required": false - }, - "budget_usd": { - "type": "number (USD)", - "required": false - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "agent_id": { - "type": "string", - "required": true - }, - "origin_chat_id": { - "type": "string", - "required": false - } - }, - "Workstream": { - "id": { - "type": "string", - "required": true - }, - "name": { - "type": "string", - "required": true - }, - "why": { - "type": "string", - "required": true - }, - "stage": { - "type": "WorkstreamStage", - "required": true - }, - "agent_id": { - "type": "string", - "required": true - }, - "origin_chat_id": { - "type": "string", - "required": false - }, - "autonomy": { - "type": "WorkstreamAutonomy", - "required": true - }, - "wake": { - "type": "WorkstreamWake", - "required": true - }, - "budget_usd": { - "type": "number (USD)", - "required": false - }, - "created_at": { - "type": "string (RFC 3339)", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "started_at": { - "type": "string (RFC 3339)", - "required": false - }, - "done_at": { - "type": "string (RFC 3339)", - "required": false - }, - "how_it_went": { - "type": "string", - "required": false - }, - "total": { - "type": "integer", - "required": true - }, - "merged": { - "type": "integer", - "required": false - }, - "needs_you": { - "type": "integer", - "required": false - }, - "spend_usd": { - "type": "number (USD)", - "required": false - } - }, - "WorkstreamPlan": { - "workstream_id": { - "type": "string", - "required": true - }, - "version": { - "type": "integer", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": false - }, - "milestones": { - "type": "WorkstreamMilestone[]", - "required": true - }, - "waves": { - "type": "WorkstreamWave[]", - "required": true - } - }, - "WorkstreamMilestone": { - "id": { - "type": "string", - "required": true - }, - "title": { - "type": "string", - "required": true - } - }, - "WorkstreamWave": { - "n": { - "type": "integer", - "required": true - }, - "title": { - "type": "string", - "required": false - }, - "milestone_id": { - "type": "string", - "required": false - }, - "auto_start": { - "type": "boolean", - "required": true - }, - "items": { - "type": "WorkstreamPlanItem[]", - "required": true - } - }, - "WorkstreamPlanItem": { - "kind": { - "type": "WorkstreamPlanItemKind", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "draft_id": { - "type": "string", - "required": false - }, - "title": { - "type": "string", - "required": false - }, - "repo": { - "type": "string", - "required": false - }, - "text": { - "type": "string", - "required": false - }, - "size": { - "type": "WorkstreamDraftSize", - "required": false - }, - "budget_usd": { - "type": "number (USD)", - "required": false - }, - "source": { - "type": "WorkstreamSource", - "required": false - } - }, - "WorkstreamPageNode": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "parent_id": { - "type": "string", - "required": false - }, - "title": { - "type": "string", - "required": true - }, - "owner": { - "type": "WorkstreamActor", - "required": true - }, - "read_by_jobs": { - "type": "boolean", - "required": true - }, - "position": { - "type": "integer", - "required": true - }, - "version": { - "type": "integer", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "updated_by": { - "type": "WorkstreamActor", - "required": true - }, - "updated_from": { - "type": "WorkstreamSource", - "required": false - }, - "deleted_at": { - "type": "string (RFC 3339)", - "required": false - } - }, - "WorkstreamPage": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "parent_id": { - "type": "string", - "required": false - }, - "title": { - "type": "string", - "required": true - }, - "owner": { - "type": "WorkstreamActor", - "required": true - }, - "read_by_jobs": { - "type": "boolean", - "required": true - }, - "position": { - "type": "integer", - "required": true - }, - "version": { - "type": "integer", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "updated_by": { - "type": "WorkstreamActor", - "required": true - }, - "updated_from": { - "type": "WorkstreamSource", - "required": false - }, - "deleted_at": { - "type": "string (RFC 3339)", - "required": false - }, - "doc": { - "type": "object", - "required": true - }, - "markdown": { - "type": "string", - "required": false - } - }, - "WorkstreamPageVersion": { - "version": { - "type": "integer", - "required": true - }, - "saved_at": { - "type": "string (RFC 3339)", - "required": true - }, - "saved_by": { - "type": "WorkstreamActor", - "required": true - }, - "from": { - "type": "WorkstreamSource", - "required": false - }, - "title": { - "type": "string", - "required": true - }, - "bytes": { - "type": "integer", - "required": true - } - }, - "WorkstreamRecord": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamRecordKind", - "required": true - }, - "text": { - "type": "string", - "required": true - }, - "state": { - "type": "WorkstreamRecordState", - "required": true - }, - "by": { - "type": "WorkstreamActor", - "required": true - }, - "source": { - "type": "WorkstreamSource", - "required": false - }, - "created_at": { - "type": "string (RFC 3339)", - "required": true - }, - "decided_at": { - "type": "string (RFC 3339)", - "required": false - }, - "supersedes": { - "type": "string", - "required": false - }, - "superseded_by": { - "type": "string", - "required": false - }, - "goahead": { - "type": "WorkstreamGoAheadProposal", - "required": false - }, - "goahead_id": { - "type": "string", - "required": false - } - }, - "WorkstreamGoAhead": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "scope": { - "type": "WorkstreamGoAheadScope", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "wave": { - "type": "integer", - "required": false - }, - "milestone_id": { - "type": "string", - "required": false - }, - "cap_usd": { - "type": "number (USD)", - "required": true - }, - "ends_at": { - "type": "string (RFC 3339)", - "required": true - }, - "state": { - "type": "WorkstreamGoAheadState", - "required": true - }, - "end_reason": { - "type": "WorkstreamGoAheadEndReason", - "required": false - }, - "spent_usd": { - "type": "number (USD)", - "required": false - }, - "granted_at": { - "type": "string (RFC 3339)", - "required": true - }, - "ended_at": { - "type": "string (RFC 3339)", - "required": false - } - }, - "WorkstreamGoAheadProposal": { - "scope": { - "type": "WorkstreamGoAheadScope", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "wave": { - "type": "integer", - "required": false - }, - "milestone_id": { - "type": "string", - "required": false - }, - "cap_usd": { - "type": "number (USD)", - "required": true - }, - "ends_at": { - "type": "string (RFC 3339)", - "required": true - } - }, - "WorkstreamSuggestion": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "page_id": { - "type": "string", - "required": true - }, - "base_version": { - "type": "integer", - "required": true - }, - "why": { - "type": "string", - "required": true - }, - "by": { - "type": "WorkstreamActor", - "required": true - }, - "source": { - "type": "WorkstreamSource", - "required": false - }, - "created_at": { - "type": "string (RFC 3339)", - "required": true - }, - "edits": { - "type": "WorkstreamSuggestionEdit[]", - "required": true - } - }, - "WorkstreamSuggestionEdit": { - "id": { - "type": "string", - "required": true - }, - "block": { - "type": "integer[]", - "required": true - }, - "offset": { - "type": "integer", - "required": true - }, - "delete": { - "type": "string", - "required": true - }, - "insert": { - "type": "string", - "required": true - }, - "block_text": { - "type": "string", - "required": true - }, - "why": { - "type": "string", - "required": true - }, - "state": { - "type": "WorkstreamSuggestionEditState", - "required": true - }, - "applies": { - "type": "boolean", - "required": false - }, - "decided_at": { - "type": "string (RFC 3339)", - "required": false - } - }, - "WorkstreamChanged": { - "id": { - "type": "string", - "required": true - }, - "parts": { - "type": "WorkstreamChangedPart[]", - "required": false - } - }, - "WorkstreamTaken": { - "factory": { - "type": "string", - "required": true - }, - "job": { - "type": "integer", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "workstream_name": { - "type": "string", - "required": true - } - }, - "WorkstreamVersionConflict": { - "id": { - "type": "string", - "required": true - }, - "version": { - "type": "integer", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "updated_by": { - "type": "WorkstreamActor", - "required": true - } - }, - "WorkstreamPatch": { - "name": { - "type": "string", - "required": false - }, - "why": { - "type": "string", - "required": false - }, - "autonomy": { - "type": "WorkstreamAutonomy", - "required": false - }, - "wake": { - "type": "WorkstreamWake", - "required": false - }, - "budget_usd": { - "type": "number (USD)", - "required": false - } - }, - "WorkstreamWakeEvent": { - "kind": { - "type": "WorkstreamWakeKind", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "label": { - "type": "string", - "required": false - } - }, - "WorkstreamWakeItem": { - "id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamWakeItemKind", - "required": true - }, - "state": { - "type": "WorkstreamWakeItemState", - "required": true - }, - "text": { - "type": "string", - "required": true - }, - "why": { - "type": "string", - "required": false - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "draft_id": { - "type": "string", - "required": false - }, - "wave": { - "type": "integer", - "required": false - }, - "page_id": { - "type": "string", - "required": false - }, - "record_id": { - "type": "string", - "required": false - }, - "budget_usd": { - "type": "number (USD)", - "required": false - }, - "note": { - "type": "string", - "required": false - }, - "applied_at": { - "type": "string (RFC 3339)", - "required": false - }, - "undone_at": { - "type": "string (RFC 3339)", - "required": false - } - }, - "WorkstreamWakeRun": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "event": { - "type": "WorkstreamWakeEvent", - "required": true - }, - "state": { - "type": "WorkstreamWakeRunState", - "required": true - }, - "started_at": { - "type": "string (RFC 3339)", - "required": true - }, - "settled_at": { - "type": "string (RFC 3339)", - "required": false - }, - "cost_usd": { - "type": "number (USD)", - "required": false - }, - "summary": { - "type": "string", - "required": false - }, - "error": { - "type": "string", - "required": false - }, - "dismissed_at": { - "type": "string (RFC 3339)", - "required": false - }, - "items": { - "type": "WorkstreamWakeItem[]", - "required": true - } - }, - "WorkstreamKeptRef": { - "kind": { - "type": "WorkstreamKeptKind", - "required": true - }, - "id": { - "type": "string", - "required": true - }, - "chat_id": { - "type": "string", - "required": false - }, - "factory": { - "type": "string", - "required": false - } - }, - "WorkstreamSourceAdd": { - "kind": { - "type": "WorkstreamSourceItemKind", - "required": true - }, - "path": { - "type": "string", - "required": false - }, - "repo": { - "type": "string", - "required": false - }, - "number": { - "type": "integer", - "required": false - }, - "url": { - "type": "string", - "required": false - }, - "ref": { - "type": "WorkstreamKeptRef", - "required": false - }, - "title": { - "type": "string", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - } - }, - "WorkstreamSourceItem": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamSourceItemKind", - "required": true - }, - "title": { - "type": "string", - "required": true - }, - "path": { - "type": "string", - "required": false - }, - "repo": { - "type": "string", - "required": false - }, - "number": { - "type": "integer", - "required": false - }, - "url": { - "type": "string", - "required": false - }, - "ref": { - "type": "WorkstreamKeptRef", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": true - }, - "proposed": { - "type": "boolean", - "required": true - }, - "why": { - "type": "string", - "required": false - }, - "added_by": { - "type": "WorkstreamActor", - "required": true - }, - "added_at": { - "type": "string (RFC 3339)", - "required": true - }, - "gone": { - "type": "boolean", - "required": false - } - }, - "WorkstreamListParams": {}, - "WorkstreamListResult": { - "workstreams": { - "type": "WorkstreamRow[]", - "required": true - } - }, - "WorkstreamGetParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamGetResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamCreateParams": { - "name": { - "type": "string", - "required": true - }, - "why": { - "type": "string", - "required": false - }, - "origin_chat_id": { - "type": "string", - "required": false - }, - "refs": { - "type": "WorkstreamRef[]", - "required": false - }, - "model": { - "type": "string", - "required": false - }, - "client_id": { - "type": "string", - "required": false - }, - "sources": { - "type": "WorkstreamSourceAdd[]", - "required": false - } - }, - "WorkstreamCreateResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamUpdateParams": { - "id": { - "type": "string", - "required": true - }, - "patch": { - "type": "WorkstreamPatch", - "required": true - } - }, - "WorkstreamUpdateResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamMarkDoneParams": { - "id": { - "type": "string", - "required": true - }, - "how_it_went": { - "type": "string", - "required": false - }, - "archive_chats": { - "type": "boolean", - "required": false - } - }, - "WorkstreamMarkDoneResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamReopenParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamReopenResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamPlanGetParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamPlanGetResult": { - "plan": { - "type": "WorkstreamPlan", - "required": true - } - }, - "WorkstreamPlanSetParams": { - "id": { - "type": "string", - "required": true - }, - "base_version": { - "type": "integer", - "required": true - }, - "milestones": { - "type": "WorkstreamMilestone[]", - "required": true - }, - "waves": { - "type": "WorkstreamWave[]", - "required": true - } - }, - "WorkstreamPlanSetResult": { - "plan": { - "type": "WorkstreamPlan", - "required": true - } - }, - "WorkstreamPageListParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "include_deleted": { - "type": "boolean", - "required": false - } - }, - "WorkstreamPageListResult": { - "pages": { - "type": "WorkstreamPageNode[]", - "required": true - }, - "deleted": { - "type": "WorkstreamPageNode[]", - "required": false - } - }, - "WorkstreamPageGetParams": { - "id": { - "type": "string", - "required": true - }, - "markdown": { - "type": "boolean", - "required": false - }, - "version": { - "type": "integer", - "required": false - } - }, - "WorkstreamPageGetResult": { - "page": { - "type": "WorkstreamPage", - "required": true - } - }, - "WorkstreamPageSaveParams": { - "id": { - "type": "string", - "required": true - }, - "doc": { - "type": "object", - "required": true - }, - "base_version": { - "type": "integer", - "required": true - }, - "source": { - "type": "WorkstreamSource", - "required": false - } - }, - "WorkstreamPageSaveResult": { - "page": { - "type": "WorkstreamPage", - "required": true - } - }, - "WorkstreamPageCreateParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "parent_id": { - "type": "string", - "required": false - }, - "after_id": { - "type": "string", - "required": false - }, - "title": { - "type": "string", - "required": true - }, - "owner": { - "type": "WorkstreamActor", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - }, - "doc": { - "type": "object", - "required": false - }, - "source": { - "type": "WorkstreamSource", - "required": false - } - }, - "WorkstreamPageCreateResult": { - "page": { - "type": "WorkstreamPage", - "required": true - } - }, - "WorkstreamPageUpdateParams": { - "id": { - "type": "string", - "required": true - }, - "title": { - "type": "string", - "required": false - }, - "owner": { - "type": "WorkstreamActor", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - } - }, - "WorkstreamPageUpdateResult": { - "page": { - "type": "WorkstreamPageNode", - "required": true - } - }, - "WorkstreamPageMoveParams": { - "id": { - "type": "string", - "required": true - }, - "parent_id": { - "type": "string", - "required": false - }, - "after_id": { - "type": "string", - "required": false - } - }, - "WorkstreamPageMoveResult": { - "page": { - "type": "WorkstreamPageNode", - "required": true - } - }, - "WorkstreamPageDeleteParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamPageDeleteResult": { - "deleted": { - "type": "string[]", - "required": true - } - }, - "WorkstreamPageHistoryParams": { - "id": { - "type": "string", - "required": true - }, - "limit": { - "type": "integer", - "required": false - } - }, - "WorkstreamPageHistoryResult": { - "versions": { - "type": "WorkstreamPageVersion[]", - "required": true - } - }, - "WorkstreamPageRestoreParams": { - "id": { - "type": "string", - "required": true - }, - "version": { - "type": "integer", - "required": false - }, - "base_version": { - "type": "integer", - "required": false - } - }, - "WorkstreamPageRestoreResult": { - "page": { - "type": "WorkstreamPage", - "required": true - } - }, - "WorkstreamRecordListParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamRecordKind", - "required": false - }, - "state": { - "type": "WorkstreamRecordState", - "required": false - }, - "include_goaheads": { - "type": "boolean", - "required": false - } - }, - "WorkstreamRecordListResult": { - "records": { - "type": "WorkstreamRecord[]", - "required": true - } - }, - "WorkstreamRecordProposeParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamRecordKind", - "required": true - }, - "text": { - "type": "string", - "required": true - }, - "source": { - "type": "WorkstreamSource", - "required": false - }, - "kept": { - "type": "boolean", - "required": false - }, - "goahead": { - "type": "WorkstreamGoAheadProposal", - "required": false - } - }, - "WorkstreamRecordProposeResult": { - "record": { - "type": "WorkstreamRecord", - "required": true - } - }, - "WorkstreamRecordKeepParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamRecordKeepResult": { - "record": { - "type": "WorkstreamRecord", - "required": true - } - }, - "WorkstreamRecordDropParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamRecordDropResult": { - "record": { - "type": "WorkstreamRecord", - "required": true - } - }, - "WorkstreamRecordSupersedeParams": { - "id": { - "type": "string", - "required": true - }, - "text": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamRecordKind", - "required": false - }, - "source": { - "type": "WorkstreamSource", - "required": false - } - }, - "WorkstreamRecordSupersedeResult": { - "record": { - "type": "WorkstreamRecord", - "required": true - }, - "superseded": { - "type": "WorkstreamRecord", - "required": true - } - }, - "WorkstreamGoAheadListParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "ended": { - "type": "boolean", - "required": false - } - }, - "WorkstreamGoAheadListResult": { - "goaheads": { - "type": "WorkstreamGoAhead[]", - "required": true - } - }, - "WorkstreamGoAheadGrantParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "scope": { - "type": "WorkstreamGoAheadScope", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "wave": { - "type": "integer", - "required": false - }, - "milestone_id": { - "type": "string", - "required": false - }, - "cap_usd": { - "type": "number (USD)", - "required": true - }, - "ends_at": { - "type": "string (RFC 3339)", - "required": true - } - }, - "WorkstreamGoAheadGrantResult": { - "goahead": { - "type": "WorkstreamGoAhead", - "required": true - } - }, - "WorkstreamGoAheadRevokeParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamGoAheadRevokeResult": { - "goahead": { - "type": "WorkstreamGoAhead", - "required": true - } - }, - "WorkstreamWakeListParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "limit": { - "type": "integer", - "required": false - } - }, - "WorkstreamWakeListResult": { - "wakes": { - "type": "WorkstreamWakeRun[]", - "required": true - } - }, - "WorkstreamWakeApplyParams": { - "id": { - "type": "string", - "required": true - }, - "item_id": { - "type": "string", - "required": false - } - }, - "WorkstreamWakeApplyResult": { - "wake": { - "type": "WorkstreamWakeRun", - "required": true - } - }, - "WorkstreamWakeUndoParams": { - "id": { - "type": "string", - "required": true - }, - "item_id": { - "type": "string", - "required": false - } - }, - "WorkstreamWakeUndoResult": { - "wake": { - "type": "WorkstreamWakeRun", - "required": true - } - }, - "WorkstreamWakeNotNowParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamWakeNotNowResult": { - "wake": { - "type": "WorkstreamWakeRun", - "required": true - } - }, - "WorkstreamSuggestionListParams": { - "page_id": { - "type": "string", - "required": true - } - }, - "WorkstreamSuggestionListResult": { - "suggestions": { - "type": "WorkstreamSuggestion[]", - "required": true - } - }, - "WorkstreamSuggestionKeepParams": { - "id": { - "type": "string", - "required": true - }, - "edit_id": { - "type": "string", - "required": false - } - }, - "WorkstreamSuggestionKeepResult": { - "page": { - "type": "WorkstreamPage", - "required": true - }, - "suggestion": { - "type": "WorkstreamSuggestion", - "required": true - } - }, - "WorkstreamSuggestionDropParams": { - "id": { - "type": "string", - "required": true - }, - "edit_id": { - "type": "string", - "required": false - } - }, - "WorkstreamSuggestionDropResult": { - "suggestion": { - "type": "WorkstreamSuggestion", - "required": true - } - }, - "WorkstreamSourceListParams": { - "workstream_id": { - "type": "string", - "required": true - } - }, - "WorkstreamSourceListResult": { - "sources": { - "type": "WorkstreamSourceItem[]", - "required": true - } - }, - "WorkstreamSourceAddParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamSourceItemKind", - "required": true - }, - "path": { - "type": "string", - "required": false - }, - "repo": { - "type": "string", - "required": false - }, - "number": { - "type": "integer", - "required": false - }, - "url": { - "type": "string", - "required": false - }, - "ref": { - "type": "WorkstreamKeptRef", - "required": false - }, - "title": { - "type": "string", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - } - }, - "WorkstreamSourceAddResult": { - "source": { - "type": "WorkstreamSourceItem", - "required": true - }, - "already": { - "type": "boolean", - "required": true - } - }, - "WorkstreamSourceUpdateParams": { - "id": { - "type": "string", - "required": true - }, - "title": { - "type": "string", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - }, - "keep": { - "type": "boolean", - "required": false - } - }, - "WorkstreamSourceUpdateResult": { - "source": { - "type": "WorkstreamSourceItem", - "required": true - } - }, - "WorkstreamSourceRemoveParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamSourceRemoveResult": { - "source": { - "type": "WorkstreamSourceItem", - "required": true - } - } - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/extract.py b/cmd/codeaf/testdata/workstreams-wire/extract.py deleted file mode 100644 index 546b10b286..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/extract.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Extract wire shapes (types, enums, methods, examples) from the workstreams wire contract. - -usage: python3 -I extract.py -Writes contract.json (index, enums, errors, types) and one .json per method/event -with its examples. No prose is copied. -""" -import json, os, re, sys - -src, out = sys.argv[1], sys.argv[2] -lines = open(src, encoding="utf-8").read().split("\n") -end = next(i for i, l in enumerate(lines) if l.startswith("## Page document format")) -lines = lines[:end] - -TICK = re.compile(r"`([^`]*)`") - -def table_at(i): - """rows of the markdown table starting at or after line i (skip blank lines).""" - while i < len(lines) and not lines[i].startswith("|"): - i += 1 - rows = [] - while i < len(lines) and lines[i].startswith("|"): - cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] - rows.append(cells) - i += 1 - return rows[2:] # drop header + separator - -def fields(rows): - out = {} - for cells in rows: - if cells[0] in ("—", "-"): - continue - name = TICK.fullmatch(cells[0]).group(1) - typ = TICK.fullmatch(cells[1]).group(1) - req = cells[2] - assert req in ("yes", "no"), cells - out[name] = {"type": typ, "required": req == "yes"} - return out - -types, enums, errors, methods, events = {}, {}, [], [], [] -method_info = {} -examples = {} -section = None # H2 -h3 = None -i = 0 -while i < len(lines): - l = lines[i] - if l.startswith("## "): - section, h3 = l[3:].strip(), None - elif l.startswith("### "): - h3 = TICK.search(l).group(1) - if section == "Shared shapes": - types[h3] = fields(table_at(i + 1)) - elif section == "Methods": - methods.append(h3) - method_info[h3] = {"method": h3, "errors": []} - elif section == "Shared shapes" and l.startswith("| type | words |"): - for cells in table_at(i): - enums[TICK.fullmatch(cells[0]).group(1)] = TICK.findall(cells[1]) - elif section == "Method index" and l.startswith("| event | data |"): - for cells in table_at(i): - events.append({"event": TICK.fullmatch(cells[0]).group(1), "data_type": TICK.fullmatch(cells[1]).group(1)}) - elif section == "Method index" and l.startswith("| method | params | result |"): - index = [TICK.fullmatch(c[0]).group(1) for c in table_at(i)] - elif section == "Error codes" and l.startswith("| code |"): - for cells in table_at(i): - m = re.match(r"`(\w+)`( \(desk-local\))?", cells[0]) - det = re.match(r"`(Workstream\w+)`", cells[2]) - errors.append({"code": m.group(1), "desk_local": bool(m.group(2)), "detail_type": det.group(1) if det else None}) - m = re.match(r"^(Params|Result) \(`(\w+)`\):", l) - if m and section == "Methods": - types[m.group(2)] = fields(table_at(i + 1)) - method_info[h3][m.group(1).lower() + "_type"] = m.group(2) - if l.startswith("Errors:") and section == "Methods": - method_info[h3]["errors"] = TICK.findall(l) - m = re.match(r"^```json (\S+) (params|result|error|data)$", l) - if m: - j = i + 1 - while lines[j] != "```": - j += 1 - body = json.loads("\n".join(lines[i + 1:j])) - examples.setdefault(m.group(1), {}).setdefault(m.group(2), []).append(body) - i = j - i += 1 - -assert index == methods, (index, methods) -os.makedirs(out, exist_ok=True) -contract = {"methods": methods, "events": events, "errors": errors, "enums": enums, "types": types} -def dump(path, v): - with open(os.path.join(out, path), "w", encoding="utf-8") as f: - json.dump(v, f, indent=2, ensure_ascii=False) - f.write("\n") -dump("contract.json", contract) -for name in methods: - info = dict(method_info[name]) - info["examples"] = examples.pop(name) - dump(name + ".json", info) -for ev in events: - dump(ev["event"] + ".json", {"event": ev["event"], "data_type": ev["data_type"], "examples": examples.pop(ev["event"])}) -# collection examples the doc carries for the factory_job kind -for name, ex in sorted(examples.items()): - dump(name + ".json", {"method": name, "examples": ex}) - print("extra:", name, sorted(ex)) -print(len(methods), "methods;", len(types), "types;", len(enums), "enums;", len(errors), "error codes") diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json b/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json deleted file mode 100644 index 08fd83ecc5..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "method": "goahead.grant", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamGoAheadGrantParams", - "result_type": "WorkstreamGoAheadGrantResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "scope": "job", - "factory": "f_local", - "job": 1107, - "cap_usd": 40.0, - "ends_at": "2026-11-03T14:05:00Z" - } - ], - "result": [ - { - "goahead": { - "id": "ga_0c44", - "workstream_id": "ws_8f2c1a", - "scope": "job", - "factory": "f_local", - "job": 1107, - "cap_usd": 40.0, - "ends_at": "2026-11-03T14:05:00Z", - "state": "active", - "granted_at": "2026-11-02T14:05:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.list.json b/cmd/codeaf/testdata/workstreams-wire/goahead.list.json deleted file mode 100644 index 7a6fe79d3c..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/goahead.list.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "method": "goahead.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamGoAheadListParams", - "result_type": "WorkstreamGoAheadListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "ended": true - } - ], - "result": [ - { - "goaheads": [ - { - "id": "ga_0c44", - "workstream_id": "ws_8f2c1a", - "scope": "job", - "factory": "f_local", - "job": 1107, - "cap_usd": 40.0, - "ends_at": "2026-11-03T14:05:00Z", - "state": "active", - "granted_at": "2026-11-02T14:05:00Z" - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json b/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json deleted file mode 100644 index db4c6ac2b4..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "method": "goahead.revoke", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamGoAheadRevokeParams", - "result_type": "WorkstreamGoAheadRevokeResult", - "examples": { - "params": [ - { - "id": "ga_0c44" - } - ], - "result": [ - { - "goahead": { - "id": "ga_0c44", - "workstream_id": "ws_8f2c1a", - "scope": "job", - "factory": "f_local", - "job": 1107, - "cap_usd": 40.0, - "ends_at": "2026-11-03T14:05:00Z", - "state": "ended", - "granted_at": "2026-11-02T14:05:00Z", - "end_reason": "revoked", - "ended_at": "2026-11-02T15:00:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.create.json b/cmd/codeaf/testdata/workstreams-wire/page.create.json deleted file mode 100644 index f9674dcc8e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.create.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "method": "page.create", - "errors": [ - "bad_params", - "not_found", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageCreateParams", - "result_type": "WorkstreamPageCreateResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "parent_id": "pg_31d0", - "title": "Metering decisions", - "owner": "you", - "read_by_jobs": false, - "doc": { - "type": "doc", - "content": [] - }, - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ], - "result": [ - { - "page": { - "id": "pg_32e1", - "workstream_id": "ws_8f2c1a", - "title": "Metering decisions", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "updated_from": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "parent_id": "pg_31d0", - "doc": { - "type": "doc", - "content": [] - } - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.delete.json b/cmd/codeaf/testdata/workstreams-wire/page.delete.json deleted file mode 100644 index f13834bec0..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.delete.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "method": "page.delete", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageDeleteParams", - "result_type": "WorkstreamPageDeleteResult", - "examples": { - "params": [ - { - "id": "pg_32e1" - } - ], - "result": [ - { - "deleted": [ - "pg_32e1" - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.get.json b/cmd/codeaf/testdata/workstreams-wire/page.get.json deleted file mode 100644 index 85cc68240a..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.get.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "method": "page.get", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageGetParams", - "result_type": "WorkstreamPageGetResult", - "examples": { - "params": [ - { - "id": "pg_31d0", - "markdown": true - }, - { - "id": "pg_31d0", - "version": 1 - } - ], - "result": [ - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - }, - "markdown": "" - } - }, - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - } - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.history.json b/cmd/codeaf/testdata/workstreams-wire/page.history.json deleted file mode 100644 index ea5e6a6ce4..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.history.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "method": "page.history", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageHistoryParams", - "result_type": "WorkstreamPageHistoryResult", - "examples": { - "params": [ - { - "id": "pg_31d0", - "limit": 50 - } - ], - "result": [ - { - "versions": [ - { - "version": 2, - "saved_at": "2026-11-02T15:00:00Z", - "saved_by": "agent", - "from": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "title": "Overview", - "bytes": 27 - }, - { - "version": 1, - "saved_at": "2026-11-02T14:05:00Z", - "saved_by": "you", - "title": "Overview", - "bytes": 27 - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.list.json b/cmd/codeaf/testdata/workstreams-wire/page.list.json deleted file mode 100644 index 6238f1105b..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.list.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "method": "page.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageListParams", - "result_type": "WorkstreamPageListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a" - }, - { - "workstream_id": "ws_8f2c1a", - "include_deleted": true - } - ], - "result": [ - { - "pages": [ - { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you" - } - ] - }, - { - "pages": [ - { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you" - } - ], - "deleted": [ - { - "id": "pg_33f2", - "workstream_id": "ws_8f2c1a", - "parent_id": "pg_31d0", - "title": "Old metering notes", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 2, - "updated_at": "2026-11-02T15:10:00Z", - "updated_by": "you", - "deleted_at": "2026-11-02T15:20:00Z" - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.move.json b/cmd/codeaf/testdata/workstreams-wire/page.move.json deleted file mode 100644 index 2079f5087e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.move.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "method": "page.move", - "errors": [ - "bad_params", - "not_found", - "cycle", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageMoveParams", - "result_type": "WorkstreamPageMoveResult", - "examples": { - "params": [ - { - "id": "pg_32e1", - "parent_id": "pg_31d0" - }, - { - "id": "pg_32e1", - "parent_id": "pg_31d0", - "after_id": "pg_548c" - } - ], - "result": [ - { - "page": { - "id": "pg_32e1", - "workstream_id": "ws_8f2c1a", - "title": "Metering decisions", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "parent_id": "pg_31d0" - } - }, - { - "page": { - "id": "pg_32e1", - "workstream_id": "ws_8f2c1a", - "title": "Metering decisions", - "owner": "you", - "read_by_jobs": false, - "position": 1, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "parent_id": "pg_31d0" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.restore.json b/cmd/codeaf/testdata/workstreams-wire/page.restore.json deleted file mode 100644 index 37da1c2804..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.restore.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "method": "page.restore", - "errors": [ - "bad_params", - "not_found", - "version_conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageRestoreParams", - "result_type": "WorkstreamPageRestoreResult", - "examples": { - "params": [ - { - "id": "pg_31d0", - "version": 1, - "base_version": 2 - }, - { - "id": "pg_33f2" - } - ], - "result": [ - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 3, - "updated_at": "2026-11-02T15:00:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - } - } - }, - { - "page": { - "id": "pg_33f2", - "workstream_id": "ws_8f2c1a", - "parent_id": "pg_31d0", - "title": "Old metering notes", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 2, - "updated_at": "2026-11-02T15:30:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - } - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.save.json b/cmd/codeaf/testdata/workstreams-wire/page.save.json deleted file mode 100644 index 51a913faff..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.save.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "method": "page.save", - "errors": [ - "bad_params", - "not_found", - "version_conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageSaveParams", - "result_type": "WorkstreamPageSaveResult", - "examples": { - "params": [ - { - "id": "pg_31d0", - "doc": { - "type": "doc", - "content": [] - }, - "base_version": 1 - }, - { - "id": "pg_31d0", - "doc": { - "type": "doc", - "content": [] - }, - "base_version": 2, - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ], - "result": [ - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 2, - "updated_at": "2026-11-02T15:00:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - } - } - }, - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 3, - "updated_at": "2026-11-02T15:10:00Z", - "updated_by": "you", - "updated_from": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "doc": { - "type": "doc", - "content": [] - } - } - } - ], - "error": [ - { - "code": "version_conflict", - "message": "the page changed since it was read", - "detail": { - "id": "pg_31d0", - "version": 2, - "updated_at": "2026-11-02T15:00:00Z", - "updated_by": "agent" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.update.json b/cmd/codeaf/testdata/workstreams-wire/page.update.json deleted file mode 100644 index 1a63b0e925..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.update.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "method": "page.update", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageUpdateParams", - "result_type": "WorkstreamPageUpdateResult", - "examples": { - "params": [ - { - "id": "pg_32e1", - "title": "Metering decisions", - "owner": "agent", - "read_by_jobs": true - } - ], - "result": [ - { - "page": { - "id": "pg_32e1", - "workstream_id": "ws_8f2c1a", - "title": "Metering decisions", - "owner": "agent", - "read_by_jobs": true, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T15:00:00Z", - "updated_by": "you", - "parent_id": "pg_31d0" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.drop.json b/cmd/codeaf/testdata/workstreams-wire/record.drop.json deleted file mode 100644 index 4af5590af0..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.drop.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "method": "record.drop", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordDropParams", - "result_type": "WorkstreamRecordDropResult", - "examples": { - "params": [ - { - "id": "rec_7a1e" - } - ], - "result": [ - { - "record": { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "dropped", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "decided_at": "2026-11-02T15:00:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.keep.json b/cmd/codeaf/testdata/workstreams-wire/record.keep.json deleted file mode 100644 index 9d11ab970d..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.keep.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "method": "record.keep", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordKeepParams", - "result_type": "WorkstreamRecordKeepResult", - "examples": { - "params": [ - { - "id": "rec_7a1e" - }, - { - "id": "rec_5c20" - } - ], - "result": [ - { - "record": { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "kept", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "decided_at": "2026-11-02T15:00:00Z" - } - }, - { - "record": { - "id": "rec_5c20", - "workstream_id": "ws_8f2c1a", - "kind": "goahead", - "text": "Wave 1 is small; let it run without asking.", - "state": "kept", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "decided_at": "2026-11-02T15:00:00Z", - "goahead": { - "scope": "wave", - "wave": 1, - "cap_usd": 40.0, - "ends_at": "2026-11-09T23:59:59Z" - }, - "goahead_id": "ga_7d31" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.list.json b/cmd/codeaf/testdata/workstreams-wire/record.list.json deleted file mode 100644 index 5ba63ccd82..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.list.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "method": "record.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordListParams", - "result_type": "WorkstreamRecordListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "kind": "question", - "state": "proposed" - }, - { - "workstream_id": "ws_8f2c1a", - "include_goaheads": true - } - ], - "result": [ - { - "records": [ - { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "proposed", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z" - } - ] - }, - { - "records": [ - { - "id": "rec_5c20", - "workstream_id": "ws_8f2c1a", - "kind": "goahead", - "text": "Wave 1 is small; let it run without asking.", - "state": "proposed", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "goahead": { - "scope": "wave", - "wave": 1, - "cap_usd": 40.0, - "ends_at": "2026-11-09T23:59:59Z" - } - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.propose.json b/cmd/codeaf/testdata/workstreams-wire/record.propose.json deleted file mode 100644 index a5c4c8f2e5..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.propose.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "method": "record.propose", - "errors": [ - "bad_params", - "not_found", - "conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordProposeParams", - "result_type": "WorkstreamRecordProposeResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "kept": false - }, - { - "workstream_id": "ws_8f2c1a", - "kind": "goahead", - "text": "Wave 1 is small; let it run without asking.", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "goahead": { - "scope": "wave", - "wave": 1, - "cap_usd": 40.0, - "ends_at": "2026-11-09T23:59:59Z" - } - } - ], - "result": [ - { - "record": { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "proposed", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z" - } - }, - { - "record": { - "id": "rec_5c20", - "workstream_id": "ws_8f2c1a", - "kind": "goahead", - "text": "Wave 1 is small; let it run without asking.", - "state": "proposed", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "goahead": { - "scope": "wave", - "wave": 1, - "cap_usd": 40.0, - "ends_at": "2026-11-09T23:59:59Z" - } - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.supersede.json b/cmd/codeaf/testdata/workstreams-wire/record.supersede.json deleted file mode 100644 index 953a35f799..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.supersede.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "method": "record.supersede", - "errors": [ - "bad_params", - "not_found", - "conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordSupersedeParams", - "result_type": "WorkstreamRecordSupersedeResult", - "examples": { - "params": [ - { - "id": "rec_7a1e", - "text": "Retries do not count toward usage.", - "kind": "decision", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ], - "result": [ - { - "record": { - "id": "rec_91bf", - "workstream_id": "ws_8f2c1a", - "kind": "decision", - "text": "Retries do not count toward usage.", - "state": "kept", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T15:00:00Z", - "decided_at": "2026-11-02T15:00:00Z", - "supersedes": "rec_7a1e" - }, - "superseded": { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "superseded", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "decided_at": "2026-11-02T15:00:00Z", - "superseded_by": "rec_91bf" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.add.json b/cmd/codeaf/testdata/workstreams-wire/source.add.json deleted file mode 100644 index fc70f80f17..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/source.add.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "method": "source.add", - "errors": [ - "bad_params", - "not_found", - "conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSourceAddParams", - "result_type": "WorkstreamSourceAddResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "kind": "file", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true - }, - { - "workstream_id": "ws_8f2c1a", - "kind": "link", - "url": "HTTPS://Docs.Stripe.com/billing/subscriptions/usage-based", - "title": "Usage-based billing guide" - } - ], - "result": [ - { - "source": { - "id": "src_2b71", - "workstream_id": "ws_8f2c1a", - "kind": "file", - "title": "usage-pricing.md", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:05:00Z" - }, - "already": false - }, - { - "source": { - "id": "src_2b75", - "workstream_id": "ws_8f2c1a", - "kind": "link", - "title": "Usage-based billing guide", - "url": "https://docs.stripe.com/billing/subscriptions/usage-based", - "read_by_jobs": false, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:40:00Z" - }, - "already": false - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.list.json b/cmd/codeaf/testdata/workstreams-wire/source.list.json deleted file mode 100644 index 3251c7924e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/source.list.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "method": "source.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSourceListParams", - "result_type": "WorkstreamSourceListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a" - } - ], - "result": [ - { - "sources": [ - { - "id": "src_2b71", - "workstream_id": "ws_8f2c1a", - "kind": "file", - "title": "usage-pricing.md", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:05:00Z" - }, - { - "id": "src_2b72", - "workstream_id": "ws_8f2c1a", - "kind": "issue", - "title": "acme/billing#412", - "repo": "acme/billing", - "number": 412, - "read_by_jobs": true, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:06:00Z" - }, - { - "id": "src_2b73", - "workstream_id": "ws_8f2c1a", - "kind": "kept", - "title": "Show the recorded usage.", - "ref": { - "kind": "record", - "id": "rec_7a1e" - }, - "read_by_jobs": false, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:20:00Z" - }, - { - "id": "src_2b74", - "workstream_id": "ws_8f2c1a", - "kind": "link", - "title": "docs.stripe.com/billing/subscriptions/usage-based", - "url": "https://docs.stripe.com/billing/subscriptions/usage-based", - "read_by_jobs": false, - "proposed": true, - "why": "The metering rules the plan follows.", - "added_by": "agent", - "added_at": "2026-11-02T14:30:00Z" - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.remove.json b/cmd/codeaf/testdata/workstreams-wire/source.remove.json deleted file mode 100644 index 141efdd06b..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/source.remove.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "method": "source.remove", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSourceRemoveParams", - "result_type": "WorkstreamSourceRemoveResult", - "examples": { - "params": [ - { - "id": "src_2b71" - } - ], - "result": [ - { - "source": { - "id": "src_2b71", - "workstream_id": "ws_8f2c1a", - "kind": "file", - "title": "usage-pricing.md", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:05:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.update.json b/cmd/codeaf/testdata/workstreams-wire/source.update.json deleted file mode 100644 index 2946814af3..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/source.update.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "method": "source.update", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSourceUpdateParams", - "result_type": "WorkstreamSourceUpdateResult", - "examples": { - "params": [ - { - "id": "src_2b74", - "keep": true, - "read_by_jobs": true - } - ], - "result": [ - { - "source": { - "id": "src_2b74", - "workstream_id": "ws_8f2c1a", - "kind": "link", - "title": "docs.stripe.com/billing/subscriptions/usage-based", - "url": "https://docs.stripe.com/billing/subscriptions/usage-based", - "read_by_jobs": true, - "proposed": false, - "why": "The metering rules the plan follows.", - "added_by": "agent", - "added_at": "2026-11-02T14:30:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json b/cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json deleted file mode 100644 index ecc34844f7..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "method": "suggestion.drop", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSuggestionDropParams", - "result_type": "WorkstreamSuggestionDropResult", - "examples": { - "params": [ - { - "id": "sg_7c41", - "edit_id": "e2" - }, - { - "id": "sg_7c41" - } - ], - "result": [ - { - "suggestion": { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "proposed", - "applies": true - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "dropped", - "decided_at": "2026-11-02T15:32:00Z" - } - ] - } - }, - { - "suggestion": { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "dropped", - "decided_at": "2026-11-02T15:33:00Z" - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "dropped", - "decided_at": "2026-11-02T15:32:00Z" - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json b/cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json deleted file mode 100644 index 4e26e07957..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json +++ /dev/null @@ -1,203 +0,0 @@ -{ - "method": "suggestion.keep", - "errors": [ - "bad_params", - "not_found", - "version_conflict", - "conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSuggestionKeepParams", - "result_type": "WorkstreamSuggestionKeepResult", - "examples": { - "params": [ - { - "id": "sg_7c41", - "edit_id": "e1" - }, - { - "id": "sg_7c41" - } - ], - "result": [ - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 4, - "updated_at": "2026-11-02T15:30:00Z", - "updated_by": "you", - "updated_from": { - "kind": "suggestion", - "id": "sg_7c41", - "label": "The meter runs hourly since api#1107" - }, - "doc": { - "type": "doc", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Meter usage every hour." - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Bill at month end." - } - ] - } - ] - } - }, - "suggestion": { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "kept", - "decided_at": "2026-11-02T15:30:00Z" - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "proposed", - "applies": true - } - ] - } - }, - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 5, - "updated_at": "2026-11-02T15:31:00Z", - "updated_by": "you", - "updated_from": { - "kind": "suggestion", - "id": "sg_7c41", - "label": "api#1107 moved metering to hourly" - }, - "doc": { - "type": "doc", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Meter usage every hour." - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Invoices stay monthly. Bill at month end." - } - ] - } - ] - } - }, - "suggestion": { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "kept", - "decided_at": "2026-11-02T15:30:00Z" - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "kept", - "decided_at": "2026-11-02T15:31:00Z" - } - ] - } - } - ], - "error": [ - { - "code": "version_conflict", - "message": "the page changed and this edit no longer applies", - "detail": { - "id": "pg_31d0", - "version": 4, - "updated_at": "2026-11-02T15:25:00Z", - "updated_by": "you" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/suggestion.list.json b/cmd/codeaf/testdata/workstreams-wire/suggestion.list.json deleted file mode 100644 index 42d173d3c1..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/suggestion.list.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "method": "suggestion.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSuggestionListParams", - "result_type": "WorkstreamSuggestionListResult", - "examples": { - "params": [ - { - "page_id": "pg_31d0" - } - ], - "result": [ - { - "suggestions": [ - { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "proposed", - "applies": true - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "proposed", - "applies": true - } - ] - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.apply.json b/cmd/codeaf/testdata/workstreams-wire/wake.apply.json deleted file mode 100644 index 5c1b061376..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/wake.apply.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "method": "wake.apply", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "too_large", - "unknown_method" - ], - "params_type": "WorkstreamWakeApplyParams", - "result_type": "WorkstreamWakeApplyResult", - "examples": { - "params": [ - { - "id": "wake_11", - "item_id": "wi_decision" - } - ], - "result": [ - { - "wake": { - "id": "wake_11", - "workstream_id": "ws_8f2c1a", - "event": { - "kind": "merged", - "factory": "f_local", - "job": 1102, - "label": "api#1102 merged" - }, - "state": "settled", - "started_at": "2026-11-03T14:00:00Z", - "settled_at": "2026-11-03T14:00:02Z", - "cost_usd": 0.04, - "summary": "Review the rollout.", - "items": [ - { - "id": "wi_decision", - "kind": "decision", - "state": "applied", - "text": "Kept decision: Roll out after review.", - "record_id": "rec_wake", - "applied_at": "2026-11-03T14:05:00Z" - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.list.json b/cmd/codeaf/testdata/workstreams-wire/wake.list.json deleted file mode 100644 index bea5259804..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/wake.list.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "method": "wake.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamWakeListParams", - "result_type": "WorkstreamWakeListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "limit": 20 - } - ], - "result": [ - { - "wakes": [ - { - "id": "wake_11", - "workstream_id": "ws_8f2c1a", - "event": { - "kind": "merged", - "factory": "f_local", - "job": 1102, - "label": "api#1102 merged" - }, - "state": "settled", - "started_at": "2026-11-03T14:00:00Z", - "settled_at": "2026-11-03T14:00:02Z", - "cost_usd": 0.04, - "summary": "Applied: moved it to wave 3; updated Where it stands. Keep the rollout decision and consider a small follow-up job.", - "items": [ - { - "id": "wi_move", - "kind": "move", - "state": "applied", - "text": "Moved f_local#1102 to wave 3.", - "factory": "f_local", - "job": 1102, - "wave": 3, - "applied_at": "2026-11-03T14:00:02Z" - }, - { - "id": "wi_decision", - "kind": "decision", - "state": "proposed", - "text": "Keep decision: Roll out after review.", - "why": "The merge is ready for review." - }, - { - "id": "wi_draft", - "kind": "draft", - "state": "proposed", - "text": "Add draft: Check rollout.", - "wave": 3, - "budget_usd": 3.0 - } - ] - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json b/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json deleted file mode 100644 index dbea46ce44..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "method": "wake.not_now", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamWakeNotNowParams", - "result_type": "WorkstreamWakeNotNowResult", - "examples": { - "params": [ - { - "id": "wake_11" - } - ], - "result": [ - { - "wake": { - "id": "wake_11", - "workstream_id": "ws_8f2c1a", - "event": { - "kind": "merged", - "factory": "f_local", - "job": 1102, - "label": "api#1102 merged" - }, - "state": "settled", - "started_at": "2026-11-03T14:00:00Z", - "settled_at": "2026-11-03T14:00:02Z", - "cost_usd": 0.04, - "summary": "Review the rollout.", - "dismissed_at": "2026-11-03T14:07:00Z", - "items": [ - { - "id": "wi_decision", - "kind": "decision", - "state": "dismissed", - "text": "Keep decision: Roll out after review." - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.undo.json b/cmd/codeaf/testdata/workstreams-wire/wake.undo.json deleted file mode 100644 index 399751245e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/wake.undo.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "method": "wake.undo", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "too_large", - "unknown_method" - ], - "params_type": "WorkstreamWakeUndoParams", - "result_type": "WorkstreamWakeUndoResult", - "examples": { - "params": [ - { - "id": "wake_11", - "item_id": "wi_decision" - } - ], - "result": [ - { - "wake": { - "id": "wake_11", - "workstream_id": "ws_8f2c1a", - "event": { - "kind": "merged", - "factory": "f_local", - "job": 1102, - "label": "api#1102 merged" - }, - "state": "settled", - "started_at": "2026-11-03T14:00:00Z", - "settled_at": "2026-11-03T14:00:02Z", - "cost_usd": 0.04, - "summary": "Review the rollout.", - "items": [ - { - "id": "wi_decision", - "kind": "decision", - "state": "undone", - "text": "Keep decision: Roll out after review.", - "record_id": "rec_wake", - "applied_at": "2026-11-03T14:05:00Z", - "undone_at": "2026-11-03T14:06:00Z" - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json b/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json deleted file mode 100644 index 6709273c39..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "event": "workstream.changed", - "data_type": "WorkstreamChanged", - "examples": { - "data": [ - { - "id": "ws_8f2c1a", - "parts": [ - "workstream", - "plan", - "pages", - "records", - "goaheads", - "jobs", - "wakes", - "sources" - ] - }, - { - "id": "ws_8f2c1a" - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.create.json b/cmd/codeaf/testdata/workstreams-wire/workstream.create.json deleted file mode 100644 index 85e5e9e4ac..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.create.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "method": "workstream.create", - "errors": [ - "bad_params", - "not_found", - "conflict", - "in_other_workstream", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamCreateParams", - "result_type": "WorkstreamCreateResult", - "examples": { - "params": [ - { - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "origin_chat_id": "conv_5b9e", - "refs": [ - { - "kind": "factory_job", - "id": "1107", - "factory": "f_local" - } - ], - "model": "openai/gpt-5.2", - "client_id": "create_684a", - "sources": [ - { - "kind": "repo", - "repo": "acme/billing", - "read_by_jobs": true - }, - { - "kind": "file", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true - } - ] - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "stage": "shaping", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "propose", - "wake": { - "morning": true, - "related_issues": false - }, - "created_at": "2026-11-02T14:05:00Z", - "updated_at": "2026-11-02T14:05:00Z", - "total": 1 - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.get.json b/cmd/codeaf/testdata/workstreams-wire/workstream.get.json deleted file mode 100644 index cc8f857e22..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.get.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "method": "workstream.get", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamGetParams", - "result_type": "WorkstreamGetResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a" - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "stage": "building", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "propose", - "wake": { - "morning": true, - "related_issues": false - }, - "budget_usd": 100.0, - "created_at": "2026-11-02T13:00:00Z", - "updated_at": "2026-11-02T14:05:00Z", - "started_at": "2026-11-02T14:05:00Z", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5 - } - }, - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "", - "stage": "shaping", - "agent_id": "conv_8d22", - "autonomy": "propose", - "wake": { - "morning": false, - "related_issues": false - }, - "created_at": "2026-11-02T14:05:00Z", - "updated_at": "2026-11-02T14:05:00Z", - "total": 0, - "merged": 0, - "needs_you": 0, - "spend_usd": 0.0 - } - } - ], - "error": [ - { - "code": "not_found", - "message": "workstream not found" - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.list.json b/cmd/codeaf/testdata/workstreams-wire/workstream.list.json deleted file mode 100644 index 23ca14dc02..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.list.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "method": "workstream.list", - "errors": [ - "busy", - "unknown_method" - ], - "params_type": "WorkstreamListParams", - "result_type": "WorkstreamListResult", - "examples": { - "params": [ - {} - ], - "result": [ - { - "workstreams": [ - { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "stage": "building", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5, - "budget_usd": 100.0, - "updated_at": "2026-11-02T14:05:00Z", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e" - } - ] - }, - { - "workstreams": [ - { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "stage": "shaping", - "total": 1, - "updated_at": "2026-11-02T14:05:00Z", - "agent_id": "conv_8d22" - } - ] - } - ], - "error": [ - { - "code": "unknown_method", - "message": "workstreams: not implemented yet" - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json b/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json deleted file mode 100644 index bae070bfbf..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "method": "workstream.mark_done", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamMarkDoneParams", - "result_type": "WorkstreamMarkDoneResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a", - "how_it_went": "Metering shipped.", - "archive_chats": true - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "stage": "done", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "propose", - "wake": { - "morning": true, - "related_issues": false - }, - "budget_usd": 100.0, - "created_at": "2026-11-02T13:00:00Z", - "updated_at": "2026-11-02T15:00:00Z", - "started_at": "2026-11-02T14:05:00Z", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5, - "done_at": "2026-11-02T15:00:00Z", - "how_it_went": "Metering shipped." - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json deleted file mode 100644 index b5e457560e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "method": "workstream.plan_get", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPlanGetParams", - "result_type": "WorkstreamPlanGetResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a" - } - ], - "result": [ - { - "plan": { - "workstream_id": "ws_8f2c1a", - "version": 0, - "milestones": [], - "waves": [] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json deleted file mode 100644 index 548f20ff07..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "method": "workstream.plan_set", - "errors": [ - "bad_params", - "not_found", - "conflict", - "version_conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPlanSetParams", - "result_type": "WorkstreamPlanSetResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a", - "base_version": 0, - "milestones": [ - { - "id": "ms_usage", - "title": "Recorded usage" - } - ], - "waves": [ - { - "n": 1, - "title": "Metering", - "milestone_id": "ms_usage", - "auto_start": false, - "items": [ - { - "kind": "job", - "factory": "f_local", - "job": 1107 - }, - { - "kind": "draft", - "draft_id": "draft_418c", - "title": "Expose usage totals", - "repo": "acme/billing", - "text": "Show the recorded usage.", - "size": "M", - "budget_usd": 25.0, - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ] - } - ] - } - ], - "result": [ - { - "plan": { - "workstream_id": "ws_8f2c1a", - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "milestones": [ - { - "id": "ms_usage", - "title": "Recorded usage" - } - ], - "waves": [ - { - "n": 1, - "title": "Metering", - "milestone_id": "ms_usage", - "auto_start": false, - "items": [ - { - "kind": "job", - "factory": "f_local", - "job": 1107 - }, - { - "kind": "draft", - "draft_id": "draft_418c", - "title": "Expose usage totals", - "repo": "acme/billing", - "text": "Show the recorded usage.", - "size": "M", - "budget_usd": 25.0, - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ] - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json b/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json deleted file mode 100644 index 8ad474a157..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "method": "workstream.reopen", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamReopenParams", - "result_type": "WorkstreamReopenResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a" - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "stage": "building", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "propose", - "wake": { - "morning": true, - "related_issues": false - }, - "budget_usd": 100.0, - "created_at": "2026-11-02T13:00:00Z", - "updated_at": "2026-11-02T15:00:00Z", - "started_at": "2026-11-02T14:05:00Z", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5 - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.update.json b/cmd/codeaf/testdata/workstreams-wire/workstream.update.json deleted file mode 100644 index b9543ee5a1..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.update.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "method": "workstream.update", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamUpdateParams", - "result_type": "WorkstreamUpdateResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a", - "patch": { - "why": "", - "autonomy": "small", - "wake": { - "morning": false, - "related_issues": true - }, - "budget_usd": 0.0 - } - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "", - "stage": "building", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "small", - "wake": { - "morning": false, - "related_issues": true - }, - "created_at": "2026-11-02T13:00:00Z", - "updated_at": "2026-11-02T14:05:00Z", - "started_at": "2026-11-02T14:05:00Z", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5 - } - } - ] - } -} diff --git a/docs/changes/unreleased/2263-app-workstreams-contract.md b/docs/changes/unreleased/2263-app-workstreams-contract.md deleted file mode 100644 index 7a31edbb2c..0000000000 --- a/docs/changes/unreleased/2263-app-workstreams-contract.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -kind: added -title: codeaf app registers the workstream methods (contract only; they answer not implemented yet) -pr: 2263 -surface: [app, docs] -invalidates: [] ---- - -The 25 workstream, page, record and go-ahead methods and `workstream.changed` exist on the wire with their types, answering `unknown_method` with `workstreams: not implemented yet`. -Hello carries no `workstreams` feature until the store lands. diff --git a/docs/changes/unreleased/2270-app-workstreams-store.md b/docs/changes/unreleased/2270-app-workstreams-store.md deleted file mode 100644 index 1966231869..0000000000 --- a/docs/changes/unreleased/2270-app-workstreams-store.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -kind: added -title: codeaf app serves persistent workstreams with pages, records and go-aheads -pr: 2270 -surface: [app] -invalidates: - - Workstream methods previously answered unknown_method; they now serve persistent state and announce the workstreams feature. ---- - -Workstreams share collection identity, preserve page history, version plans, -and keep decisions, questions and bounded go-aheads in SQLite. Creating one -opens one bare agent without a first model turn; a repeated client id reuses it. -Factory jobs belong to at most one workstream, and changes notify watching apps. -List/get derive stage and numbers from bounded Factory reads and recorded agent -spend. Unknown readings are omitted; measured zero remains present. Mark done -ends active go-aheads and can archive member chats while keeping the agent record. -Go-aheads and wave auto-start settings are stored; Factory enforcement and -automatic starts are separate work. - -Update, mark done and reopen results include the same available live numbers as -get. Single-line fields reject control characters; prose permits newline and -tab only. Repeating mark done preserves the original done time and report. - -Faults while reading a queue release concurrent readers, cache an unavailable -result and permit retries after the cache window. Update, mark done and reopen -persist their first observed start, return the refreshed row and announce one -combined change. diff --git a/docs/changes/unreleased/2277-app-workstream-page-markdown.md b/docs/changes/unreleased/2277-app-workstream-page-markdown.md deleted file mode 100644 index a9d24c601a..0000000000 --- a/docs/changes/unreleased/2277-app-workstream-page-markdown.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -kind: internal -title: Workstream pages render from editor JSON to markdown in internal/workstream/pagedoc -pr: 2277 -surface: [app] -invalidates: [] ---- - -`pagedoc.Markdown` renders a page's editor JSON by the page document format rules, the same bytes as the desktop app's renderer. Nothing calls it yet; the Factory plan step that reads pages for job briefs will. diff --git a/docs/changes/unreleased/2288-workstreams-wire-conformance.md b/docs/changes/unreleased/2288-workstreams-wire-conformance.md deleted file mode 100644 index 1aad7e6fc0..0000000000 --- a/docs/changes/unreleased/2288-workstreams-wire-conformance.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -kind: internal -title: Workstream methods are checked against the wire contract's examples -pr: 2288 -surface: [app] -invalidates: [] ---- diff --git a/docs/changes/unreleased/2289-app-workstream-agent-tools.md b/docs/changes/unreleased/2289-app-workstream-agent-tools.md deleted file mode 100644 index 365cd05003..0000000000 --- a/docs/changes/unreleased/2289-app-workstream-agent-tools.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -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/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md b/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md deleted file mode 100644 index 7a5cda50b7..0000000000 --- a/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -kind: added -title: Workstream pages support version reads, restorable deletion and title-based read defaults -pr: 2290 -surface: [app] -invalidates: - - page.delete used to destroy pages and their history; it now marks pages deleted and restorable while retaining saved versions. - - page.create used to default read_by_jobs to false for every title; it now defaults to true for Spec and Decisions unless explicitly overridden. ---- - -`page.get` accepts a saved version and renders that version's document when -markdown is requested. Deleted pages remain readable, but leave the live tree -and refuse edits and history reads. `page.list` with `include_deleted: true` -adds deletion roots, newest first, including an empty list when none remain. - -`page.restore` with only an id restores a deleted page and the descendants -removed by the same deletion, preserving saved versions, nesting and order. -It appends under its live old parent or at the root. Restoring an old version -of a live page still requires both version parameters and creates a new version. - -Creation compares trimmed titles without regard to case when choosing Read by -jobs. Explicit choices win and renames keep the existing setting; the initial -Overview remains off. Deleted pages remain stored until their collection is -deleted. diff --git a/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md b/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md index 43ebe1f0cd..f99f06de54 100644 --- a/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md +++ b/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md @@ -1,6 +1,6 @@ --- kind: internal -title: The cmd/codeaf touched leg gets a 25-minute timeout; the conformance busy case waits 200 ms +title: The cmd/codeaf touched leg gets a 25-minute timeout pr: 2296 surface: [build] invalidates: [] diff --git a/docs/changes/unreleased/2297-app-workstreams-wire-v02.md b/docs/changes/unreleased/2297-app-workstreams-wire-v02.md deleted file mode 100644 index cde1cbc7bd..0000000000 --- a/docs/changes/unreleased/2297-app-workstreams-wire-v02.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -kind: added -title: Workstream drafts and pages carry sources and kept proposals grant go-aheads -pr: 2297 -surface: [app] -invalidates: - - The agent's go-ahead proposals used to be decision records with a text prefix and are now goahead records. - - record.list without a kind used to return every record and now leaves goahead records out unless asked. - - collections.db files from earlier workstreams builds are now refused because schema 3 changed in place. ---- - -The wire contract adds draft size, budget and source, source attribution on page -creation and saves, and agent and origin conversation IDs on workstream rows. -Draft fields round-trip through plans; job items refuse them. Page history keeps -the source of each version, and a save without one clears the current source. - -Go-ahead records carry structured proposals. Keeping one grants exactly once in -the same transaction after checking the current target, end and workstream state. -Dropping it revokes its active grant; keeping it again grants a new one. Go-ahead -records cannot be superseded. Marking done ends grants without changing records. - -The agent uses these structured proposals, with end dates at most 89 UTC days -ahead, and stamps added draft jobs with a validated source. Agent reads include -go-ahead records; ordinary record lists include them only when requested. diff --git a/docs/changes/unreleased/2303-workstreams-stability-fixes.md b/docs/changes/unreleased/2303-workstreams-stability-fixes.md deleted file mode 100644 index a42d7db78e..0000000000 --- a/docs/changes/unreleased/2303-workstreams-stability-fixes.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -kind: fixed -title: A workstream's collection cannot be deleted by any writer; chat resume skips per-chat database reads -pr: 2303 -surface: [app, chat] -invalidates: [] ---- - -An engine that opened the collections database before another engine -migrated it could delete a workstream's collection and with it the -workstream's pages, history, records, go-aheads and plan. The database now -refuses that delete itself. Resuming a chat no longer opens the collections -database once for every saved conversation; it asks only about empty ones. diff --git a/docs/changes/unreleased/2310-workstreams-size-limits.md b/docs/changes/unreleased/2310-workstreams-size-limits.md deleted file mode 100644 index 468087fb53..0000000000 --- a/docs/changes/unreleased/2310-workstreams-size-limits.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -kind: fixed -title: Workstream plans, pages and records have size limits; agent Spec pages are read by jobs -pr: 2310 -surface: [app] -invalidates: [] ---- - -A plan's draft text is at most 80,000 bytes; a plan holds at most 500 -milestones, 500 waves and 5,000 items in 4 MiB; a workstream holds at most -5,000 pages (deleted included) and 5,000 records. Past a limit the app gets -`too_large` and nothing changes. Pages the workstream agent creates titled -Spec or Decisions are read by jobs, as pages the person creates are. diff --git a/docs/changes/unreleased/2318-app-workstream-suggestions.md b/docs/changes/unreleased/2318-app-workstream-suggestions.md deleted file mode 100644 index faf048b112..0000000000 --- a/docs/changes/unreleased/2318-app-workstream-suggestions.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -kind: added -title: Workstream agents suggest edits on your pages for you to keep or drop -pr: 2318 -surface: [app] -invalidates: - - "The agent could not act on the person's pages at all; it now suggests edits there that the person keeps or drops." - - "collections.db previously wrote schema 5 for Sources; its first successful write now upgrades it to schema 6, which engines built before this change refuse." ---- - -The agent's `suggest_page_edit` tool proposes changes on a person's page without -editing it. Keep or drop individual edits or all proposed edits through the wire -contract. Each keep saves one page version attributed to the person and its -suggestion; dropping leaves the page unchanged. Stale edits refuse atomically, -while remaining edits in an unchanged block move with kept text. - -The Suggestions addition (v0.5) adds the suggestion methods to the wire contract. -The first successful database write upgrades schema-5 files from the Sources -build to schema 6, adding two suggestion tables and preserving wake and source -rows. Schemas 1–4 apply each missing migration in order. Reads leave older files -unchanged; a failed write rolls back migration too. -Engines built before this change refuse schema-6 collections databases with -the existing unsupported-database error. diff --git a/docs/changes/unreleased/2319-workstreams-watching-agent.md b/docs/changes/unreleased/2319-workstreams-watching-agent.md deleted file mode 100644 index 137499e3c5..0000000000 --- a/docs/changes/unreleased/2319-workstreams-watching-agent.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -kind: added -title: Workstream agents wake for morning slots and member job events with undoable changes -pr: 2319 -surface: [app, engine] -invalidates: [] ---- - -Workstreams can produce one costed, ranked batch per morning slot or delivered -member-job event. Propose only keeps suggestions; Small changes can edit the -plan and agent pages, with Apply, Undo applied and Not now for each card. -Wakes never start Factory jobs or write the person's pages. Recorded wake costs -count toward workstream spend. Daily caps bound background calls. - -The additive wire contract is v0.3, with four wake methods and a wakes change -part. Storage migrates additively from schema 3 to 4 on the first write. -Related-issue wakes are not built. The job event hook awaits the Factory event -source; queue reads do not trigger it. - -Morning wakes consider only today's local 08:00 slot; opening before it never -catches up yesterday. Undo preserves later draft edits and page metadata -changes, and removes a wake-created wave only while it is unchanged and empty. -A wake keeps only the first page suggestion per existing page or new-page title. diff --git a/docs/changes/unreleased/2320-app-workstream-sources.md b/docs/changes/unreleased/2320-app-workstream-sources.md deleted file mode 100644 index f737b72118..0000000000 --- a/docs/changes/unreleased/2320-app-workstream-sources.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -kind: added -title: Keep sources and agent source proposals in app workstreams -pr: 2320 -surface: [app] -invalidates: - - Collections database files now become schema 5 on their first successful write; an engine built before this change refuses them, leaving collections unavailable there and all data intact. - - Workstream inputs can now be kept as separate sources instead of adding Repos or Files lines to why. ---- - -Workstreams keep file, repo, issue, link and kept-work pointers through the wire -contract's v0.4 source methods, including atomic initial sources on creation. The -agent reads sources and proposes additions for the person to keep or drop. -Removing a source leaves its target intact. Sources are separate from collection -members, with at most 2,000 per workstream; done workstreams refuse source writes. - -Agent source reads refuse final-component links, including links planted after -a file was added, while allowing intermediate directory links. Create retries -return the original workstream even after an initial source disappears. Invalid -UTF-8 source request bytes are refused before decoding, and links obey the -2,048-byte bound both before and after URL normalization. - -Schemas 3 and 4 remain readable without migration. The first successful write adds -the sources table and advances to schema 5 atomically, preserving existing rows. -Schema 3 first gains the wake tables; schema 4 already has them and keeps every wake. -Older engines refuse that newer database with their existing unsupported-database -error; nothing is lost. Source indexing and jobs consuming Read by jobs selections -are separate follow-ups. diff --git a/docs/changes/unreleased/2334-client-tools.md b/docs/changes/unreleased/2334-client-tools.md deleted file mode 100644 index b08d04aa68..0000000000 --- a/docs/changes/unreleased/2334-client-tools.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -kind: added -title: Apps can attach their own tools to a conversation, and collections keep blank conversations -pr: 2334 -surface: [app, chat] -invalidates: - - A never-spoken conversation was removed at launch unless a team referenced it; a conversation any collection references is now kept as well. ---- - -`codeaf app` clients can attach named sets of tools to a conversation with -`agents.tools_set`. The model sees them after every built-in tool, for as long -as that client stays connected. Each call reaches the client as one -`agents.tool_call` event, and the client answers it with `agents.tool_result`. -A disconnect, the deadline (30 seconds by default, at most 120) or the end of the -turn answers the model with an error that says the outcome is unknown. Late or -repeated answers are ignored. A conversation holds at most 4 sets and 64 tools; -built-in tool names are refused, and child tasks never receive client tools. The -hello carries `agents.client_tools`. Conversations held by a remote engine are -refused for now. - -Launch cleanup, the blank-chat sweep and the background sweep now keep a -never-spoken conversation that any collection references. An unreadable -collections database keeps it too. Opening such a conversation by id after a -restart restores its saved details. diff --git a/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md b/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md new file mode 100644 index 0000000000..ea69b436b4 --- /dev/null +++ b/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md @@ -0,0 +1,29 @@ +--- +kind: added +title: Collections hold Factory jobs and keep blank conversations, and apps attach client tools +pr: 2336 +surface: [app, chat] +invalidates: + - The collections database becomes schema 7 on its first successful write; an engine built before this change refuses it, leaving collections unavailable there and data intact. + - A never-spoken conversation was removed at launch unless a team referenced it; a conversation any collection references is now kept as well. +--- + +**Factory jobs in collections.** A membership can name a Factory job: kind +`factory_job` with its `factory`. `codeaf collections add|remove|find` take +`--factory`. In the app, `collections.open` shows each job's title, status and +repository from one bounded, briefly shared read per Factory; a job that is gone +or unreadable keeps its row and says so. One job may sit in several collections. + +**Client tools.** An app attaches sets of tools to one conversation +(`agents.tools_set`, hello feature `agents.client_tools`). They follow the +built-in tools while that connection lasts. The engine sends every call as an +`agents.tool_call` event and waits for `agents.tool_result`; a lost connection, +the turn ending, or the deadline (default 30 s, ceiling 120 s) gives the model +an error saying the outcome is unknown, and a late or second answer is dropped. +Limits: 4 sets, 64 tools, no built-in names, never inherited by child tasks. +Remote-held conversations are refused for now. + +**Kept conversations.** Every cleanup path (launch, blank-chat sweep, background +sweep) now spares a never-spoken conversation that some collection holds, and an +unreadable collections file counts as holding it. After a restart, opening it +by id brings back its saved details. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index bdd16a7a14..948c863319 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -224,15 +224,14 @@ engine retry: Collection refs include `factory_job`, with a canonical positive decimal job `id` and a required `factory` field. The pair addresses the same job as `factory.job`. -Other ref kinds have no `factory`. Collection results carry `workstream: true` -when their id is a workstream. `collections.delete` refuses these collections -with `bad_params` and the exact message `mark a workstream done instead`; -duplicating one makes a plain collection. A job can belong to only one workstream: -adding a job held by another returns `in_other_workstream` with factory, integer -job, workstream_id and workstream_name detail, and changes nothing in the batch. -Membership changes and renames send `workstream.changed` as well as -`collection.changed`. Factory job targets carry the recorded title, state word -and repository; an unavailable Factory produces an `unknown` target and status. +Other ref kinds have no `factory`. Several collections can reference the same job. +Factory job targets carry the recorded title, state word and repository; an +unavailable Factory produces an `unknown` target and status. A missing job +produces a `deleted` target. Resolution uses one bounded batch across nested +collections: each distinct job is asked once, with a three-second deadline, +up to eight concurrent Factory queue reads and 20 missing-job fallbacks. Queue +reads, including unavailable results, share a ten-second cache of at most +32 factories. A failed queue read never triggers per-job fallbacks. ## competence methods @@ -920,14 +919,6 @@ continue to apply; no method, parameter or reply field is added. | `files.read` | `{agent, limit, offset, path}` | `FileReadResult` | | `files.stat` | `{agent, paths}` | `FilesResult` | -## goahead methods - -| method | params | result | -|---|---|---| -| `goahead.grant` | `in.WorkstreamGoAheadGrantParams` | `WorkstreamGoAheadGrantResult` | -| `goahead.list` | `in.WorkstreamGoAheadListParams` | `WorkstreamGoAheadListResult` | -| `goahead.revoke` | `in.WorkstreamGoAheadRevokeParams` | `WorkstreamGoAheadRevokeResult` | - ## harness methods | method | params | result | @@ -1060,20 +1051,6 @@ $0.10 goes out as `0.1`. | `orders.runs` | `{id, limit}` | `RunsResult` | | `orders.stop` | `in.orderAt` | `OrderResult` | -## page methods - -| method | params | result | -|---|---|---| -| `page.create` | `in.WorkstreamPageCreateParams` | `WorkstreamPageCreateResult` | -| `page.delete` | `in.WorkstreamPageDeleteParams` | `WorkstreamPageDeleteResult` | -| `page.get` | `in.WorkstreamPageGetParams` | `WorkstreamPageGetResult` | -| `page.history` | `in.WorkstreamPageHistoryParams` | `WorkstreamPageHistoryResult` | -| `page.list` | `in.WorkstreamPageListParams` | `WorkstreamPageListResult` | -| `page.move` | `in.WorkstreamPageMoveParams` | `WorkstreamPageMoveResult` | -| `page.restore` | `in.WorkstreamPageRestoreParams` | `WorkstreamPageRestoreResult` | -| `page.save` | `in.WorkstreamPageSaveParams` | `WorkstreamPageSaveResult` | -| `page.update` | `in.WorkstreamPageUpdateParams` | `WorkstreamPageUpdateResult` | - ## ping methods | method | params | result | @@ -1135,16 +1112,6 @@ $0.10 goes out as `0.1`. | `quick.promote` | `in.IDParams` | `taskResult` | | `quick.undo` | `in.IDParams` | `Empty` | -## record methods - -| method | params | result | -|---|---|---| -| `record.drop` | `in.WorkstreamRecordDropParams` | `WorkstreamRecordDropResult` | -| `record.keep` | `in.WorkstreamRecordKeepParams` | `WorkstreamRecordKeepResult` | -| `record.list` | `in.WorkstreamRecordListParams` | `WorkstreamRecordListResult` | -| `record.propose` | `in.WorkstreamRecordProposeParams` | `WorkstreamRecordProposeResult` | -| `record.supersede` | `in.WorkstreamRecordSupersedeParams` | `WorkstreamRecordSupersedeResult` | - ## render methods | method | params | result | @@ -1301,20 +1268,6 @@ role pins and checked as the other pins are. |---|---|---| | `slash.catalog` | `{}` | `SlashCatalogResult` | -## source methods - -| method | params | result | -|---|---|---| -| `source.add` | `in.WorkstreamSourceAddParams` | `WorkstreamSourceAddResult` | -| `source.list` | `in.WorkstreamSourceListParams` | `WorkstreamSourceListResult` | -| `source.remove` | `in.WorkstreamSourceRemoveParams` | `WorkstreamSourceRemoveResult` | -| `source.update` | `in.WorkstreamSourceUpdateParams` | `WorkstreamSourceUpdateResult` | - -A workstream's sources: files, repos, issues, links and things kept from its work. Each is a -pointer in its own table, never a collection member; removing one never touches what it names. -The engine's workstream agent only proposes sources; the person keeps or drops them. Served with -the hello feature `workstream_sources`. - ## One engine on a home @@ -1331,14 +1284,6 @@ the hello feature `workstream_sources`. | `spend.report` | `in.SpendReportParams` | `SpendReportResult` | | `spend.today` | `{}` | `SpendResult` | -## suggestion methods - -| method | params | result | -|---|---|---| -| `suggestion.drop` | `in.WorkstreamSuggestionDropParams` | `WorkstreamSuggestionDropResult` | -| `suggestion.keep` | `in.WorkstreamSuggestionKeepParams` | `WorkstreamSuggestionKeepResult` | -| `suggestion.list` | `in.WorkstreamSuggestionListParams` | `WorkstreamSuggestionListResult` | - ## sync methods | method | params | result | @@ -1382,34 +1327,12 @@ the hello feature `workstream_sources`. | `tools.output` | `in.toolCallParams` | `LinesNextLineTotalResult` | | `tools.unfollow` | `in.toolCallParams` | `Empty` | -## wake methods - -| method | params | result | -|---|---|---| -| `wake.apply` | `in.WorkstreamWakeApplyParams` | `WorkstreamWakeApplyResult` | -| `wake.list` | `in.WorkstreamWakeListParams` | `WorkstreamWakeListResult` | -| `wake.not_now` | `in.WorkstreamWakeNotNowParams` | `WorkstreamWakeNotNowResult` | -| `wake.undo` | `in.WorkstreamWakeUndoParams` | `WorkstreamWakeUndoResult` | - ## work methods | method | params | result | |---|---|---| | `work.graph` | `in.GraphRequest` | `GraphResult` | -## workstream methods - -| method | params | result | -|---|---|---| -| `workstream.create` | `in.WorkstreamCreateParams` | `WorkstreamCreateResult` | -| `workstream.get` | `in.WorkstreamGetParams` | `WorkstreamGetResult` | -| `workstream.list` | `in.WorkstreamListParams` | `WorkstreamListResult` | -| `workstream.mark_done` | `in.WorkstreamMarkDoneParams` | `WorkstreamMarkDoneResult` | -| `workstream.plan_get` | `in.WorkstreamPlanGetParams` | `WorkstreamPlanGetResult` | -| `workstream.plan_set` | `in.WorkstreamPlanSetParams` | `WorkstreamPlanSetResult` | -| `workstream.reopen` | `in.WorkstreamReopenParams` | `WorkstreamReopenResult` | -| `workstream.update` | `in.WorkstreamUpdateParams` | `WorkstreamUpdateResult` | - ## Events | event | data | @@ -1466,7 +1389,6 @@ the hello feature `workstream_sources`. | `turn.thinking` | `TurnThinking` | | `turn.thinking_done` | `TurnThinkingDone` | | `turn.tool` | `TurnTool` | -| `workstream.changed` | `WorkstreamChanged` | ## Slash catalogue commands and forms @@ -1649,40 +1571,6 @@ the state file), so on upgrade every agent starts read, and a new agent starts read because it has said nothing. Engines that serve it announce `agents.mark_seen` in `hello.features`. -## Workstreams (v0 proposal) - -The Workstreams plan, WP-1, serves 25 methods backed by the collections SQLite -store and announces `workstreams` in `hello.features`. Older engines without -this feature must not be called; their methods may answer `unknown_method`. -Pages, version history, records, plans, settings and go-aheads are engine-owned -SQLite state. Markdown is a page export. Every committed change sends one -`workstream.changed` for its id and changed parts; unchanged writes emit nothing. -List/get may persist and announce the first observed job leaving Backlog. -Creation checks refs and ownership before opening one bare agent conversation, -with no first message or model turn. Repeating a nonempty client_id returns the -first workstream. Origin and agent conversations are collection members. -Mark done ends active go-aheads, and optional chat archiving excludes the agent's -record. Reopen keeps the workstream in Building. Recorded Factory facts and agent -spend feed the numbers; unavailable readings remain absent, while measured zero -is present. Queue reads have a three-second call deadline and a ten-second cache -of at most 32 factories. Distinct factories are read concurrently, up to eight -at a time; at most 20 missing jobs get individual reads per call. Further jobs -remain unknown. A Factory connection that cannot be read stays unknown. -Update, mark_done and reopen results use the same bounded, cached facts and agent -spend as get. They persist the first observed start and return the refreshed row, -combining the observation with the mutation's single `workstream.changed` event. -A fault while reading a queue releases waiting readers and caches an unavailable -result for the normal window before a retry. Repeating mark_done preserves the -original done_at and how_it_went. -Client IDs, page/milestone/wave/draft titles and source labels reject control -characters. Why, how_it_went, record text and draft text allow newline and tab -only; other control characters return `bad_params`. Names use collection rules. -Go-aheads are stored with scope, cap and end bounds; expiry is derived on read. -Factory enforcement and automatic wave starts remain separate capabilities. -A workstream is a collection, sharing its id, naming rules and membership methods. -Its jobs use `factory_job` refs with `factory`; a job in another workstream is refused -as `in_other_workstream`. Versioned page and plan writes refuse stale bases with `version_conflict`. - ## Errands (`agents.errand`) An errand is one short, detached, metered model call with the app's own prompt diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go index 4f0d750963..afb5e92008 100644 --- a/internal/appwire/collections.go +++ b/internal/appwire/collections.go @@ -81,7 +81,6 @@ type Member struct { // about a reference, whether it directly holds it. type Collection struct { ID string `json:"id"` - Workstream bool `json:"workstream,omitempty"` Name string `json:"name"` CreatedAt *time.Time `json:"created_at,omitempty"` Count int `json:"count"` @@ -250,11 +249,7 @@ func withFeature(hello Hello, feature string) Hello { // plainText is non-empty text of at most limit bytes with no control // characters and no surrounding space. func plainText(s string, limit int) bool { - return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && singleLineText(s) -} - -func singleLineText(s string) bool { - return strings.IndexFunc(s, unicode.IsControl) < 0 + return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0 } // CheckRef refuses a malformed reference as bad_params. adding refuses the diff --git a/internal/appwire/collections_factory_job_test.go b/internal/appwire/collections_factory_job_test.go index 1a888f545a..be9fec713d 100644 --- a/internal/appwire/collections_factory_job_test.go +++ b/internal/appwire/collections_factory_job_test.go @@ -32,10 +32,4 @@ func TestPartACollectionFactoryJobRefs(t *testing.T) { if err != nil || !strings.Contains(string(raw), `"factory":"f"`) { t.Fatalf("factory: %s %v", raw, err) } - for _, flag := range []bool{false, true} { - raw, err := json.Marshal(Collection{ID: "c", Name: "Name", Workstream: flag}) - if err != nil || strings.Contains(string(raw), `"workstream":true`) != flag { - t.Fatalf("flag: %s %v", raw, err) - } - } } diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 32669f483d..636a4487dc 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -197,7 +197,6 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello.Contract = Contract hello = withFeature(hello, FeatureAgentEffortRungs) hello = withCollectionsFeature(hello, engine) - hello = withWorkstreamsFeature(hello, engine) hello = withMessageReadsFeature(hello, engine) hello = withWorkGraphFeature(hello, engine) hello = withSlashCatalogFeature(hello, engine) diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go deleted file mode 100644 index 2d6f277d91..0000000000 --- a/internal/appwire/workstreams.go +++ /dev/null @@ -1,748 +0,0 @@ -// Workstreams v0 (the Workstreams plan, WP-0) is a proposal until reviewed. -// The Suggestions addition (v0.5) adds suggested edits on person-owned pages. -// PROTOCOL.md, "workstream methods", is the wire index. -package appwire - -import ( - "encoding/json" - "reflect" - "time" -) - -const ( - FeatureWorkstreams = "workstreams" - FeatureWorkstreamSources = "workstream_sources" - EventWorkstreamChanged = "workstream.changed" - CodeInOtherWorkstream = "in_other_workstream" - CodeVersionConflict = "version_conflict" -) - -// ErrWorkstreamsNotYet is the capability refusal for a shapes-only engine. -var ErrWorkstreamsNotYet = Errorf(CodeUnknownMethod, "workstreams: not implemented yet") - -// WorkstreamMethods follows the contract index order. -var WorkstreamMethods = []string{ - "workstream.list", - "workstream.get", - "workstream.create", - "workstream.update", - "workstream.mark_done", - "workstream.reopen", - "workstream.plan_get", - "workstream.plan_set", - "page.list", - "page.get", - "page.save", - "page.create", - "page.update", - "page.move", - "page.delete", - "page.history", - "page.restore", - "record.list", - "record.propose", - "record.keep", - "record.drop", - "record.supersede", - "goahead.list", - "goahead.grant", - "goahead.revoke", - "wake.list", - "wake.apply", - "wake.undo", - "wake.not_now", - "suggestion.list", - "suggestion.keep", - "suggestion.drop", - "source.list", - "source.add", - "source.update", - "source.remove", -} - -// Optional numbers and booleans use pointers so known zero and false survive. -// Optional times use omitzero; required lists must be initialized even when empty. -// Numeric counts, versions, positions, job numbers and waves are non-negative. -type WorkstreamWake struct { - Morning bool `json:"morning"` - RelatedIssues bool `json:"related_issues"` -} - -type WorkstreamRef struct { - Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,factory_job"` - ID string `json:"id"` - SessionID *string `json:"session_id,omitempty"` - Factory *string `json:"factory,omitempty"` -} - -type WorkstreamSource struct { - Kind string `json:"kind" enum:"chat,message,factory_job,page,url,suggestion"` - ID string `json:"id"` - ChatID *string `json:"chat_id,omitempty"` - Factory *string `json:"factory,omitempty"` - Label *string `json:"label,omitempty"` -} - -type WorkstreamRow struct { - ID string `json:"id"` - Name string `json:"name"` - Stage string `json:"stage" enum:"shaping,building,done"` - Total int `json:"total"` - Merged *int `json:"merged,omitempty"` - NeedsYou *int `json:"needs_you,omitempty"` - SpendUSD *float64 `json:"spend_usd,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - AgentID string `json:"agent_id"` - OriginChatID *string `json:"origin_chat_id,omitempty"` -} - -type Workstream struct { - ID string `json:"id"` - Name string `json:"name"` - Why string `json:"why"` - Stage string `json:"stage" enum:"shaping,building,done"` - AgentID string `json:"agent_id"` - OriginChatID *string `json:"origin_chat_id,omitempty"` - Autonomy string `json:"autonomy" enum:"propose,small,off"` - Wake WorkstreamWake `json:"wake"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - StartedAt time.Time `json:"started_at,omitzero"` - DoneAt time.Time `json:"done_at,omitzero"` - HowItWent *string `json:"how_it_went,omitempty"` - Total int `json:"total"` - Merged *int `json:"merged,omitempty"` - NeedsYou *int `json:"needs_you,omitempty"` - SpendUSD *float64 `json:"spend_usd,omitempty"` -} - -type WorkstreamPlan struct { - WorkstreamID string `json:"workstream_id"` - Version int `json:"version"` - UpdatedAt time.Time `json:"updated_at,omitzero"` - Milestones []WorkstreamMilestone `json:"milestones"` - Waves []WorkstreamWave `json:"waves"` -} - -type WorkstreamMilestone struct { - ID string `json:"id"` - Title string `json:"title"` -} - -type WorkstreamWave struct { - N int `json:"n"` - Title *string `json:"title,omitempty"` - MilestoneID *string `json:"milestone_id,omitempty"` - AutoStart bool `json:"auto_start"` - Items []WorkstreamPlanItem `json:"items"` -} - -type WorkstreamPlanItem struct { - Kind string `json:"kind" enum:"job,draft"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - DraftID *string `json:"draft_id,omitempty"` - Title *string `json:"title,omitempty"` - Repo *string `json:"repo,omitempty"` - Text *string `json:"text,omitempty"` - Size *string `json:"size,omitempty" enum:"S,M,L"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - Source *WorkstreamSource `json:"source,omitempty"` -} - -type WorkstreamPageNode struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - ParentID *string `json:"parent_id,omitempty"` - Title string `json:"title"` - Owner string `json:"owner" enum:"you,agent"` - ReadByJobs bool `json:"read_by_jobs"` - Position int `json:"position"` - Version int `json:"version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy string `json:"updated_by" enum:"you,agent"` - UpdatedFrom *WorkstreamSource `json:"updated_from,omitempty"` - DeletedAt time.Time `json:"deleted_at,omitzero"` -} - -type WorkstreamPage struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - ParentID *string `json:"parent_id,omitempty"` - Title string `json:"title"` - Owner string `json:"owner" enum:"you,agent"` - ReadByJobs bool `json:"read_by_jobs"` - Position int `json:"position"` - Version int `json:"version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy string `json:"updated_by" enum:"you,agent"` - UpdatedFrom *WorkstreamSource `json:"updated_from,omitempty"` - DeletedAt time.Time `json:"deleted_at,omitzero"` - Doc json.RawMessage `json:"doc"` - Markdown *string `json:"markdown,omitempty"` -} - -type WorkstreamPageVersion struct { - Version int `json:"version"` - SavedAt time.Time `json:"saved_at"` - SavedBy string `json:"saved_by" enum:"you,agent"` - From *WorkstreamSource `json:"from,omitempty"` - Title string `json:"title"` - Bytes int `json:"bytes"` -} - -type WorkstreamRecord struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"decision,question,goahead"` - Text string `json:"text"` - State string `json:"state" enum:"proposed,kept,dropped,superseded"` - By string `json:"by" enum:"you,agent"` - Source *WorkstreamSource `json:"source,omitempty"` - CreatedAt time.Time `json:"created_at"` - DecidedAt time.Time `json:"decided_at,omitzero"` - Supersedes *string `json:"supersedes,omitempty"` - SupersededBy *string `json:"superseded_by,omitempty"` - GoAhead *WorkstreamGoAheadProposal `json:"goahead,omitempty"` - GoAheadID *string `json:"goahead_id,omitempty"` -} - -type WorkstreamGoAheadProposal struct { - Scope string `json:"scope" enum:"job,wave,milestone"` - 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"` - EndsAt time.Time `json:"ends_at"` -} - -type WorkstreamGoAhead struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Scope string `json:"scope" enum:"job,wave,milestone"` - 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"` - EndsAt time.Time `json:"ends_at"` - State string `json:"state" enum:"active,ended"` - EndReason *string `json:"end_reason,omitempty" enum:"expired,revoked,workstream_done"` - SpentUSD *float64 `json:"spent_usd,omitempty"` - GrantedAt time.Time `json:"granted_at"` - EndedAt time.Time `json:"ended_at,omitzero"` -} - -type WorkstreamChanged struct { - ID string `json:"id"` - Parts []string `json:"parts,omitempty" enum:"workstream,plan,pages,records,goaheads,jobs,wakes,sources"` -} - -type WorkstreamSuggestion struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - PageID string `json:"page_id"` - BaseVersion int `json:"base_version"` - Why string `json:"why"` - By string `json:"by" enum:"you,agent"` - Source *WorkstreamSource `json:"source,omitempty"` - CreatedAt time.Time `json:"created_at"` - Edits []WorkstreamSuggestionEdit `json:"edits"` -} - -type WorkstreamSuggestionEdit struct { - ID string `json:"id"` - Block []int `json:"block"` - Offset int `json:"offset"` - Delete string `json:"delete"` - Insert string `json:"insert"` - BlockText string `json:"block_text"` - Why string `json:"why"` - State string `json:"state" enum:"proposed,kept,dropped"` - Applies *bool `json:"applies,omitempty"` - DecidedAt time.Time `json:"decided_at,omitzero"` -} - -type WorkstreamSuggestionListParams struct { - PageID string `json:"page_id"` -} -type WorkstreamSuggestionListResult struct { - Suggestions []WorkstreamSuggestion `json:"suggestions"` -} -type WorkstreamSuggestionKeepParams struct { - ID string `json:"id"` - EditID *string `json:"edit_id,omitempty"` -} -type WorkstreamSuggestionKeepResult struct { - Page WorkstreamPage `json:"page"` - Suggestion WorkstreamSuggestion `json:"suggestion"` -} -type WorkstreamSuggestionDropParams struct { - ID string `json:"id"` - EditID *string `json:"edit_id,omitempty"` -} -type WorkstreamSuggestionDropResult struct { - Suggestion WorkstreamSuggestion `json:"suggestion"` -} - -type WorkstreamTaken struct { - Factory string `json:"factory"` - Job int `json:"job"` - WorkstreamID string `json:"workstream_id"` - WorkstreamName string `json:"workstream_name"` -} - -type WorkstreamVersionConflict struct { - ID string `json:"id"` - Version int `json:"version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy string `json:"updated_by" enum:"you,agent"` -} - -type WorkstreamPatch struct { - Name *string `json:"name,omitempty"` - Why *string `json:"why,omitempty"` - Autonomy *string `json:"autonomy,omitempty" enum:"propose,small,off"` - Wake *WorkstreamWake `json:"wake,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` -} - -type WorkstreamListParams struct{} - -type WorkstreamListResult struct { - Workstreams []WorkstreamRow `json:"workstreams"` -} - -type WorkstreamGetParams struct { - ID string `json:"id"` -} - -type WorkstreamGetResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamCreateParams struct { - Name string `json:"name"` - Why *string `json:"why,omitempty"` - OriginChatID *string `json:"origin_chat_id,omitempty"` - Refs []WorkstreamRef `json:"refs,omitempty"` - Model *string `json:"model,omitempty"` - ClientID *string `json:"client_id,omitempty"` - Sources []WorkstreamSourceAdd `json:"sources,omitempty"` -} - -type WorkstreamCreateResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamUpdateParams struct { - ID string `json:"id"` - Patch WorkstreamPatch `json:"patch"` -} - -type WorkstreamUpdateResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamMarkDoneParams struct { - ID string `json:"id"` - HowItWent *string `json:"how_it_went,omitempty"` - ArchiveChats *bool `json:"archive_chats,omitempty"` -} - -type WorkstreamMarkDoneResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamReopenParams struct { - ID string `json:"id"` -} - -type WorkstreamReopenResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamPlanGetParams struct { - ID string `json:"id"` -} - -type WorkstreamPlanGetResult struct { - Plan WorkstreamPlan `json:"plan"` -} - -type WorkstreamPlanSetParams struct { - ID string `json:"id"` - BaseVersion int `json:"base_version"` - Milestones []WorkstreamMilestone `json:"milestones"` - Waves []WorkstreamWave `json:"waves"` -} - -type WorkstreamPlanSetResult struct { - Plan WorkstreamPlan `json:"plan"` -} - -type WorkstreamPageListParams struct { - WorkstreamID string `json:"workstream_id"` - IncludeDeleted *bool `json:"include_deleted,omitempty"` -} - -type WorkstreamPageListResult struct { - Pages []WorkstreamPageNode `json:"pages"` - Deleted []WorkstreamPageNode `json:"deleted,omitzero"` -} - -type WorkstreamPageGetParams struct { - ID string `json:"id"` - Markdown *bool `json:"markdown,omitempty"` - Version *int `json:"version,omitempty"` -} - -type WorkstreamPageGetResult struct { - Page WorkstreamPage `json:"page"` -} - -type WorkstreamPageSaveParams struct { - ID string `json:"id"` - Doc json.RawMessage `json:"doc"` - BaseVersion int `json:"base_version"` - Source *WorkstreamSource `json:"source,omitempty"` -} - -type WorkstreamPageSaveResult struct { - Page WorkstreamPage `json:"page"` -} - -type WorkstreamPageCreateParams struct { - WorkstreamID string `json:"workstream_id"` - ParentID *string `json:"parent_id,omitempty"` - AfterID *string `json:"after_id,omitempty"` - Title string `json:"title"` - Owner *string `json:"owner,omitempty" enum:"you,agent"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` - Doc json.RawMessage `json:"doc,omitempty"` - Source *WorkstreamSource `json:"source,omitempty"` -} - -type WorkstreamPageCreateResult struct { - Page WorkstreamPage `json:"page"` -} - -type WorkstreamPageUpdateParams struct { - ID string `json:"id"` - Title *string `json:"title,omitempty"` - Owner *string `json:"owner,omitempty" enum:"you,agent"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` -} - -type WorkstreamPageUpdateResult struct { - Page WorkstreamPageNode `json:"page"` -} - -type WorkstreamPageMoveParams struct { - ID string `json:"id"` - ParentID *string `json:"parent_id,omitempty"` - AfterID *string `json:"after_id,omitempty"` -} - -type WorkstreamPageMoveResult struct { - Page WorkstreamPageNode `json:"page"` -} - -type WorkstreamPageDeleteParams struct { - ID string `json:"id"` -} - -type WorkstreamPageDeleteResult struct { - Deleted []string `json:"deleted"` -} - -type WorkstreamPageHistoryParams struct { - ID string `json:"id"` - Limit *int `json:"limit,omitempty"` -} - -type WorkstreamPageHistoryResult struct { - Versions []WorkstreamPageVersion `json:"versions"` -} - -type WorkstreamPageRestoreParams struct { - ID string `json:"id"` - Version *int `json:"version,omitempty"` - BaseVersion *int `json:"base_version,omitempty"` -} - -type WorkstreamPageRestoreResult struct { - Page WorkstreamPage `json:"page"` -} - -type WorkstreamRecordListParams struct { - WorkstreamID string `json:"workstream_id"` - Kind *string `json:"kind,omitempty" enum:"decision,question,goahead"` - State *string `json:"state,omitempty" enum:"proposed,kept,dropped,superseded"` - IncludeGoAheads *bool `json:"include_goaheads,omitempty"` -} - -type WorkstreamRecordListResult struct { - Records []WorkstreamRecord `json:"records"` -} - -type WorkstreamRecordProposeParams struct { - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"decision,question,goahead"` - Text string `json:"text"` - Source *WorkstreamSource `json:"source,omitempty"` - Kept *bool `json:"kept,omitempty"` - GoAhead *WorkstreamGoAheadProposal `json:"goahead,omitempty"` -} - -type WorkstreamRecordProposeResult struct { - Record WorkstreamRecord `json:"record"` -} - -type WorkstreamRecordKeepParams struct { - ID string `json:"id"` -} - -type WorkstreamRecordKeepResult struct { - Record WorkstreamRecord `json:"record"` -} - -type WorkstreamRecordDropParams struct { - ID string `json:"id"` -} - -type WorkstreamRecordDropResult struct { - Record WorkstreamRecord `json:"record"` -} - -type WorkstreamRecordSupersedeParams struct { - ID string `json:"id"` - Text string `json:"text"` - Kind *string `json:"kind,omitempty" enum:"decision,question"` - Source *WorkstreamSource `json:"source,omitempty"` -} - -type WorkstreamRecordSupersedeResult struct { - Record WorkstreamRecord `json:"record"` - Superseded WorkstreamRecord `json:"superseded"` -} - -type WorkstreamGoAheadListParams struct { - WorkstreamID string `json:"workstream_id"` - Ended *bool `json:"ended,omitempty"` -} - -type WorkstreamGoAheadListResult struct { - GoAheads []WorkstreamGoAhead `json:"goaheads"` -} - -type WorkstreamGoAheadGrantParams struct { - WorkstreamID string `json:"workstream_id"` - Scope string `json:"scope" enum:"job,wave,milestone"` - 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"` - EndsAt time.Time `json:"ends_at"` -} - -type WorkstreamGoAheadGrantResult struct { - GoAhead WorkstreamGoAhead `json:"goahead"` -} - -type WorkstreamGoAheadRevokeParams struct { - ID string `json:"id"` -} - -type WorkstreamGoAheadRevokeResult struct { - GoAhead WorkstreamGoAhead `json:"goahead"` -} - -type WorkstreamWakeEvent struct { - Kind string `json:"kind" enum:"morning,finished,merged,failed,needs_you"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - Label *string `json:"label,omitempty"` -} - -type WorkstreamWakeItem struct { - ID string `json:"id"` - Kind string `json:"kind" enum:"move,draft,page,decision,question"` - State string `json:"state" enum:"applied,undone,proposed,dismissed"` - Text string `json:"text"` - Why *string `json:"why,omitempty"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - DraftID *string `json:"draft_id,omitempty"` - Wave *int `json:"wave,omitempty"` - PageID *string `json:"page_id,omitempty"` - RecordID *string `json:"record_id,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - Note *string `json:"note,omitempty"` - AppliedAt time.Time `json:"applied_at,omitzero"` - UndoneAt time.Time `json:"undone_at,omitzero"` -} - -type WorkstreamWakeRun struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Event WorkstreamWakeEvent `json:"event"` - State string `json:"state" enum:"running,settled,failed"` - StartedAt time.Time `json:"started_at"` - SettledAt time.Time `json:"settled_at,omitzero"` - CostUSD *float64 `json:"cost_usd,omitempty"` - Summary *string `json:"summary,omitempty"` - Error *string `json:"error,omitempty"` - DismissedAt time.Time `json:"dismissed_at,omitzero"` - Items []WorkstreamWakeItem `json:"items"` -} - -type WorkstreamWakeListParams struct { - WorkstreamID string `json:"workstream_id"` - Limit *int `json:"limit,omitempty"` -} -type WorkstreamWakeListResult struct { - Wakes []WorkstreamWakeRun `json:"wakes"` -} -type WorkstreamWakeApplyParams struct { - ID string `json:"id"` - ItemID *string `json:"item_id,omitempty"` -} -type WorkstreamWakeApplyResult struct { - Wake WorkstreamWakeRun `json:"wake"` -} -type WorkstreamWakeUndoParams struct { - ID string `json:"id"` - ItemID *string `json:"item_id,omitempty"` -} -type WorkstreamWakeUndoResult struct { - Wake WorkstreamWakeRun `json:"wake"` -} -type WorkstreamWakeNotNowParams struct { - ID string `json:"id"` -} -type WorkstreamWakeNotNowResult struct { - Wake WorkstreamWakeRun `json:"wake"` -} - -// Workstreamer is the optional engine half of the workstreams contract. -// A stub implements the shapes but returns ErrWorkstreamsNotYet and serves false. -type Workstreamer interface { - ServesWorkstreams() bool - WorkstreamList(WorkstreamListParams) (WorkstreamListResult, error) - WorkstreamGet(WorkstreamGetParams) (WorkstreamGetResult, error) - WorkstreamCreate(WorkstreamCreateParams) (WorkstreamCreateResult, error) - WorkstreamUpdate(WorkstreamUpdateParams) (WorkstreamUpdateResult, error) - WorkstreamMarkDone(WorkstreamMarkDoneParams) (WorkstreamMarkDoneResult, error) - WorkstreamReopen(WorkstreamReopenParams) (WorkstreamReopenResult, error) - WorkstreamPlanGet(WorkstreamPlanGetParams) (WorkstreamPlanGetResult, error) - WorkstreamPlanSet(WorkstreamPlanSetParams) (WorkstreamPlanSetResult, error) - WorkstreamPageList(WorkstreamPageListParams) (WorkstreamPageListResult, error) - WorkstreamPageGet(WorkstreamPageGetParams) (WorkstreamPageGetResult, error) - WorkstreamPageSave(WorkstreamPageSaveParams) (WorkstreamPageSaveResult, error) - WorkstreamPageCreate(WorkstreamPageCreateParams) (WorkstreamPageCreateResult, error) - WorkstreamPageUpdate(WorkstreamPageUpdateParams) (WorkstreamPageUpdateResult, error) - WorkstreamPageMove(WorkstreamPageMoveParams) (WorkstreamPageMoveResult, error) - WorkstreamPageDelete(WorkstreamPageDeleteParams) (WorkstreamPageDeleteResult, error) - WorkstreamPageHistory(WorkstreamPageHistoryParams) (WorkstreamPageHistoryResult, error) - WorkstreamPageRestore(WorkstreamPageRestoreParams) (WorkstreamPageRestoreResult, error) - WorkstreamRecordList(WorkstreamRecordListParams) (WorkstreamRecordListResult, error) - WorkstreamRecordPropose(WorkstreamRecordProposeParams) (WorkstreamRecordProposeResult, error) - WorkstreamRecordKeep(WorkstreamRecordKeepParams) (WorkstreamRecordKeepResult, error) - WorkstreamRecordDrop(WorkstreamRecordDropParams) (WorkstreamRecordDropResult, error) - WorkstreamRecordSupersede(WorkstreamRecordSupersedeParams) (WorkstreamRecordSupersedeResult, error) - WorkstreamGoAheadList(WorkstreamGoAheadListParams) (WorkstreamGoAheadListResult, error) - WorkstreamGoAheadGrant(WorkstreamGoAheadGrantParams) (WorkstreamGoAheadGrantResult, error) - WorkstreamGoAheadRevoke(WorkstreamGoAheadRevokeParams) (WorkstreamGoAheadRevokeResult, error) - WorkstreamWakeList(WorkstreamWakeListParams) (WorkstreamWakeListResult, error) - WorkstreamWakeApply(WorkstreamWakeApplyParams) (WorkstreamWakeApplyResult, error) - WorkstreamWakeUndo(WorkstreamWakeUndoParams) (WorkstreamWakeUndoResult, error) - WorkstreamWakeNotNow(WorkstreamWakeNotNowParams) (WorkstreamWakeNotNowResult, error) - WorkstreamSuggestionList(WorkstreamSuggestionListParams) (WorkstreamSuggestionListResult, error) - WorkstreamSuggestionKeep(WorkstreamSuggestionKeepParams) (WorkstreamSuggestionKeepResult, error) - WorkstreamSuggestionDrop(WorkstreamSuggestionDropParams) (WorkstreamSuggestionDropResult, error) - WorkstreamSourceList(WorkstreamSourceListParams) (WorkstreamSourceListResult, error) - WorkstreamSourceAdd(WorkstreamSourceAddParams) (WorkstreamSourceAddResult, error) - WorkstreamSourceUpdate(WorkstreamSourceUpdateParams) (WorkstreamSourceUpdateResult, error) - WorkstreamSourceRemove(WorkstreamSourceRemoveParams) (WorkstreamSourceRemoveResult, error) -} - -// workstreamCall preserves the params/result types used by the schema generator. -// Unsupported engines retain their capability refusal before parameter checks. -func workstreamCall[P interface{ Check() error }, R any](call func(Workstreamer, P) (R, error)) method { - m := typed(func(e Engine, p P) (*R, error) { - w, ok := e.(Workstreamer) - if !ok { - return nil, Errorf(CodeUnknownMethod, "this engine does not serve workstreams") - } - if !w.ServesWorkstreams() { - return nil, ErrWorkstreamsNotYet - } - if err := p.Check(); err != nil { - return nil, err - } - result, err := call(w, p) - return &result, err - }) - delegate := m.call - m.call = func(e Engine, raw json.RawMessage) (any, error) { - if w, ok := e.(Workstreamer); ok && w.ServesWorkstreams() { - if err := workstreamRequired(raw, m.params); err != nil { - return nil, err - } - } - return delegate(e, raw) - } - return m -} - -func init() { - methods["workstream.list"] = workstreamCall(Workstreamer.WorkstreamList) - methods["workstream.get"] = workstreamCall(Workstreamer.WorkstreamGet) - methods["workstream.create"] = workstreamCall(Workstreamer.WorkstreamCreate) - methods["workstream.update"] = workstreamCall(Workstreamer.WorkstreamUpdate) - methods["workstream.mark_done"] = workstreamCall(Workstreamer.WorkstreamMarkDone) - methods["workstream.reopen"] = workstreamCall(Workstreamer.WorkstreamReopen) - methods["workstream.plan_get"] = workstreamCall(Workstreamer.WorkstreamPlanGet) - methods["workstream.plan_set"] = workstreamCall(Workstreamer.WorkstreamPlanSet) - methods["page.list"] = workstreamCall(Workstreamer.WorkstreamPageList) - methods["page.get"] = workstreamCall(Workstreamer.WorkstreamPageGet) - methods["page.save"] = workstreamCall(Workstreamer.WorkstreamPageSave) - methods["page.create"] = workstreamCall(Workstreamer.WorkstreamPageCreate) - methods["page.update"] = workstreamCall(Workstreamer.WorkstreamPageUpdate) - methods["page.move"] = workstreamCall(Workstreamer.WorkstreamPageMove) - methods["page.delete"] = workstreamCall(Workstreamer.WorkstreamPageDelete) - methods["page.history"] = workstreamCall(Workstreamer.WorkstreamPageHistory) - methods["page.restore"] = workstreamCall(Workstreamer.WorkstreamPageRestore) - methods["record.list"] = workstreamCall(Workstreamer.WorkstreamRecordList) - methods["record.propose"] = workstreamCall(Workstreamer.WorkstreamRecordPropose) - methods["record.keep"] = workstreamCall(Workstreamer.WorkstreamRecordKeep) - methods["record.drop"] = workstreamCall(Workstreamer.WorkstreamRecordDrop) - methods["record.supersede"] = workstreamCall(Workstreamer.WorkstreamRecordSupersede) - methods["goahead.list"] = workstreamCall(Workstreamer.WorkstreamGoAheadList) - methods["goahead.grant"] = workstreamCall(Workstreamer.WorkstreamGoAheadGrant) - methods["goahead.revoke"] = workstreamCall(Workstreamer.WorkstreamGoAheadRevoke) - methods["wake.list"] = workstreamCall(Workstreamer.WorkstreamWakeList) - methods["wake.apply"] = workstreamCall(Workstreamer.WorkstreamWakeApply) - methods["wake.undo"] = workstreamCall(Workstreamer.WorkstreamWakeUndo) - methods["wake.not_now"] = workstreamCall(Workstreamer.WorkstreamWakeNotNow) - methods["suggestion.list"] = workstreamCall(Workstreamer.WorkstreamSuggestionList) - methods["suggestion.keep"] = workstreamCall(Workstreamer.WorkstreamSuggestionKeep) - methods["suggestion.drop"] = workstreamCall(Workstreamer.WorkstreamSuggestionDrop) - methods["source.list"] = workstreamCall(Workstreamer.WorkstreamSourceList) - methods["source.add"] = workstreamCall(Workstreamer.WorkstreamSourceAdd) - methods["source.update"] = workstreamCall(Workstreamer.WorkstreamSourceUpdate) - methods["source.remove"] = workstreamCall(Workstreamer.WorkstreamSourceRemove) - eventShapes[EventWorkstreamChanged] = reflect.TypeFor[WorkstreamChanged]() -} - -func withWorkstreamsFeature(hello Hello, e Engine) Hello { - if w, ok := e.(Workstreamer); ok && w.ServesWorkstreams() { - return withFeature(withFeature(hello, FeatureWorkstreams), FeatureWorkstreamSources) - } - return hello -} diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go deleted file mode 100644 index 1991f2ba91..0000000000 --- a/internal/appwire/workstreams_check.go +++ /dev/null @@ -1,450 +0,0 @@ -package appwire - -import ( - "bytes" - "encoding/json" - "math" - "reflect" - "strings" - "time" - "unicode" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// workstreamRequired distinguishes a missing required zero-valued field from -// an explicit zero, including plan version 0 and false wake flags. Editor JSON -// stays opaque; unknown additive fields stay ignored by the typed decoder. -func workstreamRequired(raw json.RawMessage, shape reflect.Type) error { - if shape == reflect.TypeFor[json.RawMessage]() || shape == reflect.TypeFor[time.Time]() { - return nil - } - if shape.Kind() == reflect.Pointer { - shape = shape.Elem() - } - switch shape.Kind() { - case reflect.Struct: - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { - return Errorf(CodeBadParams, "workstream params must be an object") - } - for i := 0; i < shape.NumField(); i++ { - field := shape.Field(i) - tag := field.Tag.Get("json") - name, _, _ := strings.Cut(tag, ",") - value, exists := fields[name] - if !exists { - if !strings.Contains(tag, ",omitempty") { - return Errorf(CodeBadParams, "%s is required", name) - } - continue - } - if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - return Errorf(CodeBadParams, "%s must not be null", name) - } - if err := workstreamRequired(value, field.Type); err != nil { - return err - } - } - case reflect.Slice: - var values []json.RawMessage - if err := json.Unmarshal(raw, &values); err != nil { - return Errorf(CodeBadParams, "expected an array") - } - for _, value := range values { - if err := workstreamRequired(value, shape.Elem()); err != nil { - return err - } - } - } - return nil -} - -func workstreamID(id string) error { - if !utf8.ValidString(id) || !plainText(id, collectionIDMax) { - return Errorf(CodeBadParams, "a workstream target needs a nonempty id without surrounding space or control characters") - } - return nil -} - -func workstreamOptionalID(id *string) error { - if id == nil { - return nil - } - return workstreamID(*id) -} - -func workstreamText(text string, min, max int, field string) error { - if !utf8.ValidString(text) || len(text) < min || len(text) > max || (min > 0 && strings.TrimSpace(text) == "") { - return Errorf(CodeBadParams, "%s is %d to %d bytes", field, min, max) - } - if strings.IndexFunc(text, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\t' }) >= 0 { - return Errorf(CodeBadParams, "%s allows only newline and tab control characters", field) - } - return nil -} - -func workstreamLine(text string, min, max int, field string) error { - if !singleLineText(text) { - return Errorf(CodeBadParams, "%s must not contain control characters", field) - } - return workstreamText(text, min, max, field) -} - -func workstreamOptionalLine(text *string, min, max int, field string) error { - if text == nil { - return nil - } - return workstreamLine(*text, min, max, field) -} - -func workstreamOptionalText(text *string, min, max int, field string) error { - if text == nil { - return nil - } - return workstreamText(*text, min, max, field) -} - -func workstreamEnum(word string, words ...string) error { - for _, allowed := range words { - if word == allowed { - return nil - } - } - return Errorf(CodeBadParams, "invalid workstream value %q", word) -} - -func workstreamOptionalEnum(word *string, words ...string) error { - if word == nil { - return nil - } - return workstreamEnum(*word, words...) -} - -// workstreamChecks keeps each family of checks ordered and returns the first refusal. -func workstreamChecks(checks ...error) error { - for _, err := range checks { - if err != nil { - return err - } - } - return nil -} - -func workstreamDoc(doc json.RawMessage, required bool) error { - if len(doc) > workstream.DocMax { - return Errorf(CodeTooLarge, "encoded document exceeds 1 MiB") - } - if len(doc) == 0 && !required { - return nil - } - trimmed := bytes.TrimSpace(doc) - if !utf8.Valid(doc) || !json.Valid(doc) || len(trimmed) == 0 || trimmed[0] != '{' { - return Errorf(CodeBadParams, "doc must be an editor JSON object") - } - return nil -} - -func (p WorkstreamRef) Check() error { - if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.SessionID), workstreamOptionalID(p.Factory)); err != nil { - return err - } - if p.Factory != nil && p.Kind != RefFactoryJob { - return Errorf(CodeBadParams, "only a factory_job reference has a factory") - } - if p.SessionID != nil && p.Kind != RefTask && p.Kind != RefProcess { - return Errorf(CodeBadParams, "only a task or process reference has a session_id") - } - return CheckRef(Ref{Kind: p.Kind, ID: p.ID, SessionID: workstreamString(p.SessionID), Factory: workstreamString(p.Factory)}, true) -} - -func workstreamString(p *string) string { - if p == nil { - return "" - } - return *p -} - -func (p WorkstreamSource) Check() error { - if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Label, 0, 200, "source label")); err != nil { - return err - } - if p.Kind != "message" && p.ChatID != nil || p.Kind != "factory_job" && p.Factory != nil { - return Errorf(CodeBadParams, "chat_id is only for message and factory is only for factory_job") - } - switch p.Kind { - case "suggestion": - return Errorf(CodeBadParams, "suggestion sources are written only by suggestion.keep") - case "chat", "page", "url": - case "message": - if p.ChatID == nil { - return Errorf(CodeBadParams, "message source requires chat_id") - } - if err := workstreamID(*p.ChatID); err != nil { - return err - } - case "factory_job": - if err := CheckRef(Ref{Kind: RefFactoryJob, ID: p.ID, Factory: workstreamString(p.Factory)}, true); err != nil { - return err - } - default: - return Errorf(CodeBadParams, "unknown source kind %q", p.Kind) - } - return workstreamChecks(workstreamOptionalID(p.ChatID), workstreamOptionalID(p.Factory)) -} - -func workstreamSource(p *WorkstreamSource) error { - if p == nil { - return nil - } - return p.Check() -} - -func (p WorkstreamSuggestionListParams) Check() error { return workstreamID(p.PageID) } -func (p WorkstreamSuggestionKeepParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.EditID)) -} -func (p WorkstreamSuggestionDropParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.EditID)) -} - -func (p WorkstreamListParams) Check() error { return nil } -func (p WorkstreamGetParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamReopenParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamPlanGetParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamPageListParams) Check() error { return workstreamID(p.WorkstreamID) } -func (p WorkstreamPageGetParams) Check() error { - if p.Version != nil && *p.Version < 1 { - return Errorf(CodeBadParams, "version must be positive") - } - return workstreamID(p.ID) -} -func (p WorkstreamPageDeleteParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamRecordKeepParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamRecordDropParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamGoAheadRevokeParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamGoAheadListParams) Check() error { return workstreamID(p.WorkstreamID) } - -func (p WorkstreamCreateParams) Check() error { - if !utf8.ValidString(p.Name) { - return Errorf(CodeBadParams, "name must be valid UTF-8") - } - if _, err := collectionName(p.Name); err != nil { - return err - } - if err := workstreamChecks(workstreamOptionalText(p.Why, 0, 4000, "why"), workstreamOptionalLine(p.ClientID, 0, 128, "client_id"), workstreamOptionalID(p.OriginChatID), workstreamOptionalID(p.Model)); err != nil { - return err - } - if len(p.Refs) > CollectionRefsMax { - return Errorf(CodeBadParams, "refs takes at most %d references", CollectionRefsMax) - } - for _, r := range p.Refs { - if err := r.Check(); err != nil { - return err - } - } - if len(p.Sources) > workstream.CreateSourcesMax { - return Errorf(CodeBadParams, "create takes at most %d sources", workstream.CreateSourcesMax) - } - return nil -} - -func (p WorkstreamUpdateParams) Check() error { - if p.Patch.Name != nil { - if !utf8.ValidString(*p.Patch.Name) { - return Errorf(CodeBadParams, "name must be valid UTF-8") - } - if _, err := collectionName(*p.Patch.Name); err != nil { - return err - } - } - if p.Patch.BudgetUSD != nil && (math.IsNaN(*p.Patch.BudgetUSD) || math.IsInf(*p.Patch.BudgetUSD, 0) || *p.Patch.BudgetUSD < 0) { - return Errorf(CodeBadParams, "budget_usd must be finite and nonnegative") - } - return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.Patch.Why, 0, 4000, "why"), workstreamOptionalEnum(p.Patch.Autonomy, "propose", "small", "off")) -} -func (p WorkstreamMarkDoneParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.HowItWent, 0, 4000, "how_it_went")) -} - -func (p WorkstreamPlanSetParams) Check() error { - if err := workstreamID(p.ID); err != nil { - return err - } - if p.BaseVersion < 0 || p.Milestones == nil || p.Waves == nil { - return Errorf(CodeBadParams, "plan requires nonnegative base_version and milestones and waves arrays") - } - if len(p.Milestones) > workstream.PlanMilestonesMax { - return Errorf(CodeTooLarge, "a plan holds at most %d milestones", workstream.PlanMilestonesMax) - } - if len(p.Waves) > workstream.PlanWavesMax { - return Errorf(CodeTooLarge, "a plan holds at most %d waves", workstream.PlanWavesMax) - } - items := 0 - for _, w := range p.Waves { - items += len(w.Items) - if items > workstream.PlanItemsMax { - return Errorf(CodeTooLarge, "a plan holds at most %d items", workstream.PlanItemsMax) - } - } - for _, m := range p.Milestones { - if m.ID != "" { - if err := workstreamID(m.ID); err != nil { - return err - } - } - if err := workstreamLine(m.Title, 1, 200, "milestone title"); err != nil { - return err - } - } - last := 0 - for _, w := range p.Waves { - if w.N <= last || w.Items == nil { - return Errorf(CodeBadParams, "waves must be positive, unique, ascending and have items arrays") - } - last = w.N - if err := workstreamChecks(workstreamOptionalID(w.MilestoneID), workstreamOptionalLine(w.Title, 0, 200, "wave title")); err != nil { - return err - } - for _, item := range w.Items { - if err := workstreamDraftFields(item); err != nil { - return err - } - if item.Job != nil && *item.Job <= 0 { - return Errorf(CodeBadParams, "plan job must be positive") - } - if err := workstreamChecks(workstreamOptionalID(item.Factory), workstreamOptionalID(item.DraftID)); err != nil { - return err - } - switch item.Kind { - case "job": - if item.Job == nil || *item.Job <= 0 || item.Factory == nil { - return Errorf(CodeBadParams, "plan job requires factory and positive job") - } - if err := workstreamID(*item.Factory); err != nil { - return err - } - case "draft": - if item.DraftID == nil || item.Title == nil { - return Errorf(CodeBadParams, "draft requires draft_id and title") - } - if err := workstreamChecks(workstreamID(*item.DraftID), workstreamLine(*item.Title, 1, 200, "draft title")); err != nil { - return err - } - if item.Text != nil { - // Control characters stay bad_params whatever the length. - if err := workstreamText(*item.Text, 0, len(*item.Text), "draft text"); err != nil { - return err - } - if len(*item.Text) > workstream.PlanDraftTextMax { - return Errorf(CodeTooLarge, "draft text must be at most %d bytes", workstream.PlanDraftTextMax) - } - } - default: - return Errorf(CodeBadParams, "unknown plan item kind %q", item.Kind) - } - } - } - return nil -} - -func (p WorkstreamPageSaveParams) Check() error { - if p.BaseVersion < 0 { - return Errorf(CodeBadParams, "base_version must be nonnegative") - } - return workstreamChecks(workstreamID(p.ID), workstreamDoc(p.Doc, true), workstreamSource(p.Source)) -} -func (p WorkstreamPageCreateParams) Check() error { - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID), workstreamLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent"), workstreamDoc(p.Doc, false), workstreamSource(p.Source)) -} -func (p WorkstreamPageUpdateParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent")) -} -func (p WorkstreamPageMoveParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID)) -} -func (p WorkstreamPageHistoryParams) Check() error { - _, err := bounded("limit", p.Limit, 1, 200, 50) - return workstreamChecks(workstreamID(p.ID), err) -} -func (p WorkstreamPageRestoreParams) Check() error { - if (p.Version == nil) != (p.BaseVersion == nil) { - return Errorf(CodeBadParams, "version and base_version must be supplied together") - } - if p.Version != nil && (*p.Version < 1 || *p.BaseVersion < 0) { - return Errorf(CodeBadParams, "restore requires positive version and nonnegative base_version") - } - return workstreamID(p.ID) -} -func (p WorkstreamRecordListParams) Check() error { - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalEnum(p.Kind, "decision", "question", "goahead"), workstreamOptionalEnum(p.State, "proposed", "kept", "dropped", "superseded")) -} -func (p WorkstreamRecordProposeParams) Check() error { - if (p.Kind == "goahead") != (p.GoAhead != nil) || p.Kind == "goahead" && p.Kept != nil && *p.Kept { - return Errorf(CodeBadParams, "goahead is required only for kind goahead, which cannot be proposed kept") - } - if p.GoAhead != nil { - if err := p.GoAhead.Check(); err != nil { - return err - } - } - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamEnum(p.Kind, "decision", "question", "goahead"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) -} -func (p WorkstreamRecordSupersedeParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalEnum(p.Kind, "decision", "question"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) -} -func (p WorkstreamGoAheadGrantParams) Check() error { - if err := workstreamID(p.WorkstreamID); err != nil { - return err - } - return (WorkstreamGoAheadProposal{Scope: p.Scope, Factory: p.Factory, Job: p.Job, Wave: p.Wave, MilestoneID: p.MilestoneID, CapUSD: p.CapUSD, EndsAt: p.EndsAt}).Check() -} - -func (p WorkstreamGoAheadProposal) Check() error { - if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || p.EndsAt.IsZero() { - return Errorf(CodeBadParams, "go-ahead requires cap_usd greater than 0 and at most %d, and ends_at", workspace.WorkstreamGoAheadCapMax) - } - switch p.Scope { - case "job": - if p.Factory == nil || p.Job == nil || *p.Job <= 0 || p.Wave != nil || p.MilestoneID != nil { - return Errorf(CodeBadParams, "job scope requires factory and job only") - } - return workstreamID(*p.Factory) - case "wave": - if p.Wave == nil || *p.Wave <= 0 || p.Factory != nil || p.Job != nil || p.MilestoneID != nil { - return Errorf(CodeBadParams, "wave scope requires wave only") - } - case "milestone": - if p.MilestoneID == nil || p.Factory != nil || p.Job != nil || p.Wave != nil { - return Errorf(CodeBadParams, "milestone scope requires milestone_id only") - } - return workstreamID(*p.MilestoneID) - default: - return Errorf(CodeBadParams, "unknown go-ahead scope %q", p.Scope) - } - return nil -} - -func workstreamDraftFields(item WorkstreamPlanItem) error { - if item.Kind != "draft" && (item.Size != nil || item.BudgetUSD != nil || item.Source != nil) { - return Errorf(CodeBadParams, "size, budget_usd and source are allowed only on drafts") - } - if item.BudgetUSD != nil && (math.IsNaN(*item.BudgetUSD) || math.IsInf(*item.BudgetUSD, 0) || *item.BudgetUSD <= 0 || *item.BudgetUSD > workspace.WorkstreamGoAheadCapMax) { - return Errorf(CodeBadParams, "draft budget_usd must be greater than 0 and at most %d", workspace.WorkstreamGoAheadCapMax) - } - return workstreamChecks(workstreamOptionalEnum(item.Size, "S", "M", "L"), workstreamSource(item.Source)) -} - -func (p WorkstreamWakeListParams) Check() error { - _, err := bounded("limit", p.Limit, 1, 100, 20) - return workstreamChecks(workstreamID(p.WorkstreamID), err) -} -func (p WorkstreamWakeApplyParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ItemID)) -} -func (p WorkstreamWakeUndoParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ItemID)) -} -func (p WorkstreamWakeNotNowParams) Check() error { return workstreamID(p.ID) } diff --git a/internal/appwire/workstreams_check_test.go b/internal/appwire/workstreams_check_test.go deleted file mode 100644 index 0437394f32..0000000000 --- a/internal/appwire/workstreams_check_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package appwire - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func TestStabEM4PlanBoundsBeforeDelegation(t *testing.T) { - draftID, title := "draft", "Draft" - draft := WorkstreamPlanItem{Kind: "draft", DraftID: &draftID, Title: &title} - for _, tc := range []struct { - name string - edit func(*WorkstreamPlanSetParams) - code string - }{ - {"draft text", func(p *WorkstreamPlanSetParams) { - text := strings.Repeat("x", workstream.PlanDraftTextMax+1) - p.Waves[0].Items[0].Text = &text - }, CodeTooLarge}, - {"milestones", func(p *WorkstreamPlanSetParams) { - p.Milestones = make([]WorkstreamMilestone, workstream.PlanMilestonesMax+1) - for i := range p.Milestones { - p.Milestones[i] = WorkstreamMilestone{ID: fmt.Sprintf("m%d", i), Title: "Milestone"} - } - }, CodeTooLarge}, - {"waves", func(p *WorkstreamPlanSetParams) { - p.Waves = make([]WorkstreamWave, workstream.PlanWavesMax+1) - for i := range p.Waves { - p.Waves[i] = WorkstreamWave{N: i + 1, Items: []WorkstreamPlanItem{}} - } - }, CodeTooLarge}, - {"items", func(p *WorkstreamPlanSetParams) { - p.Waves[0].Items = make([]WorkstreamPlanItem, workstream.PlanItemsMax+1) - for i := range p.Waves[0].Items { - p.Waves[0].Items[i] = draft - } - }, CodeTooLarge}, - {"control characters", func(p *WorkstreamPlanSetParams) { - text := "before\x00after" - p.Waves[0].Items[0].Text = &text - }, CodeBadParams}, - {"control characters past the text bound", func(p *WorkstreamPlanSetParams) { - text := strings.Repeat("x", workstream.PlanDraftTextMax) + "\x00" - p.Waves[0].Items[0].Text = &text - }, CodeBadParams}, - {"bad draft id", func(p *WorkstreamPlanSetParams) { - id := " invalid " - p.Waves[0].Items[0].DraftID = &id - }, CodeBadParams}, - {"at text bound", func(p *WorkstreamPlanSetParams) { - text := strings.Repeat("x", workstream.PlanDraftTextMax) - p.Waves[0].Items[0].Text = &text - }, CodeUnknownMethod}, - } { - t.Run(tc.name, func(t *testing.T) { - params := WorkstreamPlanSetParams{ID: "w", Milestones: []WorkstreamMilestone{}, Waves: []WorkstreamWave{{N: 1, Items: []WorkstreamPlanItem{draft}}}} - tc.edit(¶ms) - raw, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - _, err = methods["workstream.plan_set"].call(fake, raw) - wire, ok := err.(*Error) - if !ok || wire.Code != tc.code { - t.Fatalf("got %v, want %s", err, tc.code) - } - }) - } -} - -// B1: malformed requests are refused at the real typed dispatch door, before -// the serving fake (which always answers unknown_method) can receive them. -func TestWorkstreamBoundsBeforeDelegation(t *testing.T) { - cases := []struct{ method, params, code string }{ - {"workstream.get", `{}`, CodeBadParams}, - {"workstream.create", `{"name":""}`, CodeBadParams}, - {"workstream.create", `{"name":"x","client_id":"` + strings.Repeat("a", 129) + `"}`, CodeBadParams}, - {"workstream.create", `{"name":"x","why":"` + strings.Repeat("a", 4001) + `"}`, CodeBadParams}, - {"workstream.create", `{"name":"x","refs":[{"kind":"factory_job","id":"01","factory":"f"}]}`, CodeBadParams}, - {"workstream.create", `{"name":"x","refs":[` + strings.Repeat(`{"kind":"conversation","id":"c"},`, 200) + `{"kind":"conversation","id":"c"}]}`, CodeBadParams}, - {"workstream.update", `{"id":"x"}`, CodeBadParams}, - {"workstream.update", `{"id":"x","patch":{"autonomy":"auto"}}`, CodeBadParams}, - {"workstream.update", `{"id":"x","patch":{"wake":{"morning":false}}}`, CodeBadParams}, - {"workstream.mark_done", `{"id":"x","how_it_went":"` + strings.Repeat("a", 4001) + `"}`, CodeBadParams}, - {"workstream.plan_set", `{"id":"x","milestones":[],"waves":[]}`, CodeBadParams}, - {"workstream.plan_set", `{"id":"x","base_version":-1,"milestones":[],"waves":[]}`, CodeBadParams}, - {"workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":0,"auto_start":false,"items":[]}]}`, CodeBadParams}, - {"page.create", `{"workstream_id":"x","title":"x","owner":"other"}`, CodeBadParams}, - {"page.create", `{"workstream_id":"x","title":"x","doc":[]}`, CodeBadParams}, - {"page.update", `{"id":"x","title":""}`, CodeBadParams}, - {"page.update", `{"id":"x","title":"` + strings.Repeat("t", 201) + `"}`, CodeBadParams}, - {"page.save", `{"id":"x","base_version":1}`, CodeBadParams}, - {"page.save", `{"id":"x","base_version":1,"doc":{"text":"` + strings.Repeat("a", 1<<20) + `"}}`, CodeTooLarge}, - {"page.move", `{"id":"x","parent_id":""}`, CodeBadParams}, - {"page.history", `{"id":"x","limit":0}`, CodeBadParams}, - {"page.history", `{"id":"x","limit":201}`, CodeBadParams}, - {"page.restore", `{"id":"x","version":0,"base_version":1}`, CodeBadParams}, - // WP-F1 (a), (b): version reads are positive and restore versions are paired. - {"page.get", `{"id":"x","version":0}`, CodeBadParams}, - {"page.get", `{"id":"x","version":-1}`, CodeBadParams}, - {"page.restore", `{"id":"x","version":1}`, CodeBadParams}, - {"page.restore", `{"id":"x","base_version":1}`, CodeBadParams}, - {"page.restore", `{"id":"x","version":1,"base_version":-1}`, CodeBadParams}, - {"record.list", `{"workstream_id":"x","state":"active"}`, CodeBadParams}, - {"record.propose", `{"workstream_id":"x","kind":"decision","text":""}`, CodeBadParams}, - {"record.propose", `{"workstream_id":"x","kind":"decision","text":"` + strings.Repeat("t", 2001) + `"}`, CodeBadParams}, - {"record.propose", `{"workstream_id":"x","kind":"question","text":"x","source":{"kind":"message","id":"m"}}`, CodeBadParams}, - {"record.supersede", `{"id":"x","text":"x","kind":"other"}`, CodeBadParams}, - {"goahead.grant", `{"workstream_id":"x","scope":"wave","wave":1,"ends_at":"2099-01-01T00:00:00Z"}`, CodeBadParams}, - {"goahead.grant", `{"workstream_id":"x","scope":"wave","wave":1,"cap_usd":10001,"ends_at":"2099-01-01T00:00:00Z"}`, CodeBadParams}, - } - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for _, tc := range cases { - t.Run(tc.method+tc.params[:min(len(tc.params), 55)], func(t *testing.T) { - _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) - wire, ok := err.(*Error) - if !ok || wire.Code != tc.code { - t.Fatalf("got %v, want %s", err, tc.code) - } - }) - } -} - -func TestWorkstreamBoundsPreserveExplicitZeroAndFalse(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for method, params := range map[string]string{ - "workstream.plan_set": `{"id":"x","base_version":0,"milestones":[],"waves":[]}`, - "workstream.update": `{"id":"x","patch":{"why":"","budget_usd":0,"wake":{"morning":false,"related_issues":false}}}`, - "page.create": `{"workstream_id":"x","title":"x","read_by_jobs":false}`, - // WP-F1 (a), (b): additive params, including id-only restore, reach dispatch. - "page.get": `{"id":"x","version":1}`, - "page.list": `{"workstream_id":"x","include_deleted":true}`, - "page.restore": `{"id":"x"}`, - } { - _, err := methods[method].call(fake, json.RawMessage(params)) - if err != ErrWorkstreamsNotYet { - t.Fatalf("%s did not reach the fake: %v", method, err) - } - } -} - -func TestWorkstreamControlCharactersBeforeDelegation(t *testing.T) { - cases := []struct { - name, method, params string - prose bool - }{ - {"name", "workstream.create", `{"name":$text}`, false}, - {"rename", "workstream.update", `{"id":"x","patch":{"name":$text}}`, false}, - {"client_id", "workstream.create", `{"name":"x","client_id":$text}`, false}, - {"page title", "page.create", `{"workstream_id":"x","title":$text}`, false}, - {"updated page title", "page.update", `{"id":"x","title":$text}`, false}, - {"milestone title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[{"id":"","title":$text}],"waves":[]}`, false}, - {"wave title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"title":$text,"auto_start":false,"items":[]}]}`, false}, - {"draft title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":$text}]}]}`, false}, - {"source label", "record.propose", `{"workstream_id":"x","kind":"decision","text":"x","source":{"kind":"chat","id":"c","label":$text}}`, false}, - {"replacement source label", "record.supersede", `{"id":"x","text":"x","source":{"kind":"chat","id":"c","label":$text}}`, false}, - {"why", "workstream.create", `{"name":"x","why":$text}`, true}, - {"updated why", "workstream.update", `{"id":"x","patch":{"why":$text}}`, true}, - {"how_it_went", "workstream.mark_done", `{"id":"x","how_it_went":$text}`, true}, - {"record text", "record.propose", `{"workstream_id":"x","kind":"question","text":$text}`, true}, - {"replacement text", "record.supersede", `{"id":"x","text":$text}`, true}, - {"draft text", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft","text":$text}]}]}`, true}, - } - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for _, tc := range cases { - for control := rune(0); control <= 0x9f; control++ { - if control > 0x1f && control < 0x7f { - continue - } - t.Run(fmt.Sprintf("%s/U+%04X", tc.name, control), func(t *testing.T) { - value, err := json.Marshal("before" + string(control) + "after") - if err != nil { - t.Fatal(err) - } - params := strings.ReplaceAll(tc.params, "$text", string(value)) - _, err = methods[tc.method].call(fake, json.RawMessage(params)) - if tc.prose && (control == '\n' || control == '\t') { - if err != ErrWorkstreamsNotYet { - t.Fatalf("allowed prose did not reach the fake: %v", err) - } - return - } - wire, ok := err.(*Error) - if !ok || wire.Code != CodeBadParams { - t.Fatalf("got %v, want %s before delegation", err, CodeBadParams) - } - }) - } - } -} - -func TestWorkstreamTextFieldsPreserveAllowedContent(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for _, tc := range []struct{ method, params string }{ - {"workstream.create", `{"name":"Launch","client_id":"","why":""}`}, - {"workstream.create", `{"name":"Launch","client_id":" retry ","why":"first\n\tsecond"}`}, - {"page.create", `{"workstream_id":"x","title":" Résumé 界 "}`}, - {"record.propose", `{"workstream_id":"x","kind":"question","text":"first\n\tsecond","source":{"kind":"chat","id":"c","label":" Label "}}`}, - {"workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"title":"","auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":" Draft ","text":""}]}]}`}, - } { - t.Run(tc.method+tc.params, func(t *testing.T) { - _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) - if err != ErrWorkstreamsNotYet { - t.Fatalf("allowed content did not reach the fake: %v", err) - } - }) - } -} diff --git a/internal/appwire/workstreams_fields_v02_test.go b/internal/appwire/workstreams_fields_v02_test.go deleted file mode 100644 index 3436aaff90..0000000000 --- a/internal/appwire/workstreams_fields_v02_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package appwire - -import ( - "encoding/json" - "math" - "strings" - "testing" -) - -// WP-F2 (a), (b), (d), (f): typed dispatch refuses malformed additive fields. -func TestWorkstreamV02ParamsBeforeDelegation(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for _, fields := range []string{`"size":"XL"`, `"size":""`, `"budget_usd":0`, `"budget_usd":-1`, `"budget_usd":10000.01`, `"source":{"kind":"message","id":"m"}`} { - params := `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft",` + fields + `}]}]}` - _, err := methods["workstream.plan_set"].call(fake, json.RawMessage(params)) - workstreamV02BadParams(t, err) - } - for _, fields := range []string{`"size":"S"`, `"size":""`, `"budget_usd":1`, `"budget_usd":0`, `"source":{"kind":"chat","id":"c"}`} { - params := `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"job","factory":"f","job":1,` + fields + `}]}]}` - _, err := methods["workstream.plan_set"].call(fake, json.RawMessage(params)) - workstreamV02BadParams(t, err) - } - proposal := `{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"2026-11-04T00:00:00Z"}` - for _, tc := range []struct{ method, params string }{ - {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why"}`}, - {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why","kept":true,"goahead":` + proposal + `}`}, - {"record.propose", `{"workstream_id":"w","kind":"decision","text":"Why","goahead":` + proposal + `}`}, - {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why","goahead":{"scope":"wave","wave":1,"cap_usd":1}}`}, - {"record.supersede", `{"id":"r","kind":"goahead","text":"Why"}`}, - {"page.save", `{"id":"p","base_version":1,"doc":{},"source":{"kind":"message","id":"m"}}`}, - {"page.create", `{"workstream_id":"w","title":"Page","source":{"kind":"chat","id":"c","factory":"f"}}`}, - {"page.create", `{"workstream_id":"w","title":"Page","source":{"kind":"page","id":"p","chat_id":"c"}}`}, - } { - _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) - workstreamV02BadParams(t, err) - } - for _, value := range []float64{math.NaN(), math.Inf(1)} { - id, title := "d", "Draft" - p := WorkstreamPlanSetParams{ID: "w", Milestones: []WorkstreamMilestone{}, Waves: []WorkstreamWave{{N: 1, Items: []WorkstreamPlanItem{{Kind: "draft", DraftID: &id, Title: &title, BudgetUSD: &value}}}}} - workstreamV02BadParams(t, p.Check()) - } -} - -func workstreamV02BadParams(t *testing.T, err error) { - t.Helper() - wire, ok := err.(*Error) - if !ok || wire.Code != CodeBadParams { - t.Fatalf("want bad_params, got %v", err) - } -} - -// WP-F2 (a), (b), (d), (f): each valid additive field reaches the typed delegate. -func TestWorkstreamV02ParamsReachDelegation(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - source := `{"kind":"message","id":"m","chat_id":"c","label":"Scope"}` - for method, params := range map[string]string{ - "workstream.plan_set": `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft","size":"M","budget_usd":10000,"source":` + source + `}]}]}`, - "page.save": `{"id":"p","doc":{},"base_version":1,"source":` + source + `}`, - "page.create": `{"workstream_id":"w","title":"Page","source":` + source + `}`, - "record.list": `{"workstream_id":"w","kind":"goahead","include_goaheads":false}`, - "record.propose": `{"workstream_id":"w","kind":"goahead","text":"Why","kept":false,"goahead":{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"2026-11-04T00:00:00Z"}}`, - } { - _, err := methods[method].call(fake, json.RawMessage(params)) - if err != ErrWorkstreamsNotYet { - t.Fatalf("%s: %v", method, err) - } - } - for _, kind := range []string{"chat", "message", "factory_job", "page", "url"} { - p := WorkstreamSource{Kind: kind, ID: "7"} - chat, factory := "chat", "factory" - if kind == "message" { - p.ChatID = &chat - } - if kind == "factory_job" { - p.Factory = &factory - } - if err := p.Check(); err != nil { - t.Fatalf("%s: %v", kind, err) - } - label := strings.Repeat("x", 201) - p.Label = &label - workstreamV02BadParams(t, p.Check()) - } -} diff --git a/internal/appwire/workstreams_sources.go b/internal/appwire/workstreams_sources.go deleted file mode 100644 index 9fb97e8888..0000000000 --- a/internal/appwire/workstreams_sources.go +++ /dev/null @@ -1,146 +0,0 @@ -package appwire - -import ( - "encoding/json" - "time" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -type WorkstreamKeptRef struct { - Kind string `json:"kind" enum:"record,page,message,factory_job"` - ID string `json:"id"` - ChatID *string `json:"chat_id,omitempty"` - Factory *string `json:"factory,omitempty"` -} - -type WorkstreamSourceAdd struct { - Kind string `json:"kind" enum:"file,repo,issue,link,kept"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *WorkstreamKeptRef `json:"ref,omitempty"` - Title *string `json:"title,omitempty"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` -} - -type WorkstreamSourceItem struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"file,repo,issue,link,kept"` - Title string `json:"title"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *WorkstreamKeptRef `json:"ref,omitempty"` - ReadByJobs bool `json:"read_by_jobs"` - Proposed bool `json:"proposed"` - Why *string `json:"why,omitempty"` - AddedBy string `json:"added_by" enum:"you,agent"` - AddedAt time.Time `json:"added_at"` - Gone *bool `json:"gone,omitempty"` -} - -type WorkstreamSourceListParams struct { - WorkstreamID string `json:"workstream_id"` -} -type WorkstreamSourceListResult struct { - Sources []WorkstreamSourceItem `json:"sources"` -} - -type WorkstreamSourceAddParams struct { - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"file,repo,issue,link,kept"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *WorkstreamKeptRef `json:"ref,omitempty"` - Title *string `json:"title,omitempty"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` -} -type WorkstreamSourceAddResult struct { - Source WorkstreamSourceItem `json:"source"` - Already bool `json:"already"` -} - -type WorkstreamSourceUpdateParams struct { - ID string `json:"id"` - Title *string `json:"title,omitempty"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` - Keep *bool `json:"keep,omitempty"` -} -type WorkstreamSourceUpdateResult struct { - Source WorkstreamSourceItem `json:"source"` -} -type WorkstreamSourceRemoveParams struct { - ID string `json:"id"` -} -type WorkstreamSourceRemoveResult struct { - Source WorkstreamSourceItem `json:"source"` -} - -// Check raw source bytes before the JSON decoder can replace invalid UTF-8. -func workstreamDecodeSource(raw []byte, target any) error { - if !utf8.Valid(raw) { - return Errorf(CodeBadParams, "source fields must be valid UTF-8") - } - return json.Unmarshal(raw, target) -} - -func (p *WorkstreamSourceAdd) UnmarshalJSON(raw []byte) error { - type plain WorkstreamSourceAdd - return workstreamDecodeSource(raw, (*plain)(p)) -} - -func (p *WorkstreamSourceAddParams) UnmarshalJSON(raw []byte) error { - type plain WorkstreamSourceAddParams - return workstreamDecodeSource(raw, (*plain)(p)) -} - -func (p *WorkstreamSourceUpdateParams) UnmarshalJSON(raw []byte) error { - type plain WorkstreamSourceUpdateParams - return workstreamDecodeSource(raw, (*plain)(p)) -} - -// SourceAdd preserves optional field presence for the domain's kind checks. -func (p WorkstreamSourceAdd) SourceAdd() workstream.SourceAdd { - out := workstream.SourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Title: p.Title} - if p.ReadByJobs != nil { - out.ReadByJobs = *p.ReadByJobs - } - if p.Ref != nil { - out.Ref = &workstream.KeptRef{Kind: p.Ref.Kind, ID: p.Ref.ID, ChatID: p.Ref.ChatID, Factory: p.Ref.Factory} - } - return out -} - -func (p WorkstreamSourceAddParams) SourceAdd() workstream.SourceAdd { - return (WorkstreamSourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Ref: p.Ref, Title: p.Title, ReadByJobs: p.ReadByJobs}).SourceAdd() -} - -func (p WorkstreamSourceListParams) Check() error { return workstreamID(p.WorkstreamID) } -func (p WorkstreamSourceRemoveParams) Check() error { return workstreamID(p.ID) } - -func (p WorkstreamSourceAddParams) Check() error { - if err := workstreamID(p.WorkstreamID); err != nil { - return err - } - if err := workstream.ValidateSourceAdd(p.SourceAdd()); err != nil { - return Errorf(CodeBadParams, "%s", err) - } - return nil -} - -func (p WorkstreamSourceUpdateParams) Check() error { - if err := workstreamID(p.ID); err != nil { - return err - } - if err := workstream.ValidateSourcePatch(workstream.SourcePatch{Title: p.Title, ReadByJobs: p.ReadByJobs, Keep: p.Keep}); err != nil { - return Errorf(CodeBadParams, "%s", err) - } - return nil -} diff --git a/internal/appwire/workstreams_sources_test.go b/internal/appwire/workstreams_sources_test.go deleted file mode 100644 index 7804081326..0000000000 --- a/internal/appwire/workstreams_sources_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package appwire - -import ( - "encoding/json" - "reflect" - "testing" -) - -// WP-12 wire capability: both features follow the same hook and remain absent -// for stub/plain engines. Optional fields omit while required false flags stay. -func TestWorkstreamSourcesFeatureAndRequiredBooleans(t *testing.T) { - for _, serves := range []bool{false, true} { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: serves} - hello := withWorkstreamsFeature(withWorkstreamsFeature(Hello{}, fake), fake) - want := []string(nil) - if serves { - want = []string{FeatureWorkstreams, FeatureWorkstreamSources} - } - if !reflect.DeepEqual(hello.Features, want) { - t.Fatalf("features: %v", hello.Features) - } - } - raw, err := json.Marshal(WorkstreamSourceItem{ID: "source", WorkstreamID: "stream", Kind: "link", Title: "Example", AddedBy: "you"}) - if err != nil { - t.Fatal(err) - } - var fields map[string]any - if err := json.Unmarshal(raw, &fields); err != nil { - t.Fatal(err) - } - for _, key := range []string{"read_by_jobs", "proposed"} { - if value, exists := fields[key]; !exists || value != false { - t.Fatalf("required false missing: %s", raw) - } - } - for _, key := range []string{"path", "repo", "number", "url", "ref", "why", "gone"} { - if _, exists := fields[key]; exists { - t.Fatalf("absent optional present: %s", raw) - } - } -} - -// WP-12 (b): typed dispatch rejects missing/null fields before reaching the -// serving engine, including inside a kept ref and create's sources array. -func TestWorkstreamSourcesRequiredFieldsOnWire(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - call := serveOver(t, fake) - for _, tc := range []struct{ method, params string }{ - {"source.list", `{}`}, - {"source.add", `{"workstream_id":"w","kind":"link","url":null}`}, - {"source.add", `{"workstream_id":"w","kind":"kept","ref":{"kind":"message","chat_id":"c"}}`}, - {"source.update", `{"id":"s","keep":null}`}, - {"source.remove", `{"id":null}`}, - {"workstream.create", `{"name":"Sources","sources":[{"path":"x"}]}`}, - {"workstream.create", `{"name":"Sources","sources":null}`}, - } { - frame := call(`{"id":1,"method":"` + tc.method + `","params":` + tc.params + `}`) - refusal, _ := frame["error"].(map[string]any) - if refusal["code"] != CodeBadParams { - t.Fatalf("%s: %v", tc.method, frame) - } - } -} diff --git a/internal/appwire/workstreams_test.go b/internal/appwire/workstreams_test.go deleted file mode 100644 index 1b51487466..0000000000 --- a/internal/appwire/workstreams_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package appwire - -import ( - "encoding/json" - "fmt" - "slices" - "strings" - "testing" -) - -// fakeWorkstreamer exercises V4 without a store, Factory or engine binary. -type fakeWorkstreamer struct { - *fakeEngine - serves bool -} - -var _ Workstreamer = (*fakeWorkstreamer)(nil) - -func (f *fakeWorkstreamer) ServesWorkstreams() bool { return f.serves } - -func (f *fakeWorkstreamer) WorkstreamList(WorkstreamListParams) (WorkstreamListResult, error) { - return WorkstreamListResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamGet(WorkstreamGetParams) (WorkstreamGetResult, error) { - return WorkstreamGetResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamCreate(WorkstreamCreateParams) (WorkstreamCreateResult, error) { - return WorkstreamCreateResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamUpdate(WorkstreamUpdateParams) (WorkstreamUpdateResult, error) { - return WorkstreamUpdateResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamMarkDone(WorkstreamMarkDoneParams) (WorkstreamMarkDoneResult, error) { - return WorkstreamMarkDoneResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamReopen(WorkstreamReopenParams) (WorkstreamReopenResult, error) { - return WorkstreamReopenResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPlanGet(WorkstreamPlanGetParams) (WorkstreamPlanGetResult, error) { - return WorkstreamPlanGetResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPlanSet(WorkstreamPlanSetParams) (WorkstreamPlanSetResult, error) { - return WorkstreamPlanSetResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageList(WorkstreamPageListParams) (WorkstreamPageListResult, error) { - return WorkstreamPageListResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageGet(WorkstreamPageGetParams) (WorkstreamPageGetResult, error) { - return WorkstreamPageGetResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageSave(WorkstreamPageSaveParams) (WorkstreamPageSaveResult, error) { - return WorkstreamPageSaveResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageCreate(WorkstreamPageCreateParams) (WorkstreamPageCreateResult, error) { - return WorkstreamPageCreateResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageUpdate(WorkstreamPageUpdateParams) (WorkstreamPageUpdateResult, error) { - return WorkstreamPageUpdateResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageMove(WorkstreamPageMoveParams) (WorkstreamPageMoveResult, error) { - return WorkstreamPageMoveResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageDelete(WorkstreamPageDeleteParams) (WorkstreamPageDeleteResult, error) { - return WorkstreamPageDeleteResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageHistory(WorkstreamPageHistoryParams) (WorkstreamPageHistoryResult, error) { - return WorkstreamPageHistoryResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageRestore(WorkstreamPageRestoreParams) (WorkstreamPageRestoreResult, error) { - return WorkstreamPageRestoreResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordList(WorkstreamRecordListParams) (WorkstreamRecordListResult, error) { - return WorkstreamRecordListResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordPropose(WorkstreamRecordProposeParams) (WorkstreamRecordProposeResult, error) { - return WorkstreamRecordProposeResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordKeep(WorkstreamRecordKeepParams) (WorkstreamRecordKeepResult, error) { - return WorkstreamRecordKeepResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordDrop(WorkstreamRecordDropParams) (WorkstreamRecordDropResult, error) { - return WorkstreamRecordDropResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordSupersede(WorkstreamRecordSupersedeParams) (WorkstreamRecordSupersedeResult, error) { - return WorkstreamRecordSupersedeResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamGoAheadList(WorkstreamGoAheadListParams) (WorkstreamGoAheadListResult, error) { - return WorkstreamGoAheadListResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamGoAheadGrant(WorkstreamGoAheadGrantParams) (WorkstreamGoAheadGrantResult, error) { - return WorkstreamGoAheadGrantResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamGoAheadRevoke(WorkstreamGoAheadRevokeParams) (WorkstreamGoAheadRevokeResult, error) { - return WorkstreamGoAheadRevokeResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamSourceList(WorkstreamSourceListParams) (WorkstreamSourceListResult, error) { - return WorkstreamSourceListResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSourceAdd(WorkstreamSourceAddParams) (WorkstreamSourceAddResult, error) { - return WorkstreamSourceAddResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSourceUpdate(WorkstreamSourceUpdateParams) (WorkstreamSourceUpdateResult, error) { - return WorkstreamSourceUpdateResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSourceRemove(WorkstreamSourceRemoveParams) (WorkstreamSourceRemoveResult, error) { - return WorkstreamSourceRemoveResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSuggestionList(WorkstreamSuggestionListParams) (WorkstreamSuggestionListResult, error) { - return WorkstreamSuggestionListResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSuggestionKeep(WorkstreamSuggestionKeepParams) (WorkstreamSuggestionKeepResult, error) { - return WorkstreamSuggestionKeepResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSuggestionDrop(WorkstreamSuggestionDropParams) (WorkstreamSuggestionDropResult, error) { - return WorkstreamSuggestionDropResult{}, ErrWorkstreamsNotYet -} - -func workstreamHelloHasFeature(hello map[string]any) bool { - for _, feature := range features(hello) { - if feature == FeatureWorkstreams { - return true - } - } - return false -} - -// V1, WP-12, WP-6d: the contract index and dispatch table name the same 36 methods. -func TestEveryWorkstreamMethodIsRegistered(t *testing.T) { - if len(WorkstreamMethods) != 36 { - t.Fatalf("contract has %d methods, want 36", len(WorkstreamMethods)) - } - known := make(map[string]bool, len(WorkstreamMethods)) - served := Methods() - for _, name := range WorkstreamMethods { - if known[name] { - t.Errorf("duplicate contract method %s", name) - } - known[name] = true - if !slices.Contains(served, name) { - t.Errorf("method not registered: %s", name) - } - } - for _, name := range served { - for _, prefix := range []string{"workstream.", "page.", "record.", "goahead.", "wake.", "source.", "suggestion."} { - if strings.HasPrefix(name, prefix) && !known[name] { - t.Errorf("registered method not in contract: %s", name) - } - } - } - if !slices.Contains(Events(), EventWorkstreamChanged) { - t.Error("workstream.changed is not registered") - } -} - -// V4: every registered stub refuses on the wire, without claiming the feature. -func TestWorkstreamMethodsAnswerNotYet(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}} - w := startWireOver(t, fake, fake.fakeEngine) - if workstreamHelloHasFeature(w.next()) { - t.Fatal("the stub claims workstreams") - } - for i, name := range WorkstreamMethods { - t.Run(name, func(t *testing.T) { - w.send(fmt.Sprintf(`{"id":%d,"method":%q,"params":{}}`, i+1, name)) - got := w.next() - if got["id"] != float64(i+1) { - t.Fatal("response id does not match request") - } - err, ok := got["error"].(map[string]any) - if !ok { - t.Fatal("stub response has no error") - } - if err["code"] != CodeUnknownMethod { - t.Errorf("code = %v, want unknown_method", err["code"]) - } - if err["message"] != "workstreams: not implemented yet" { - t.Errorf("message = %v", err["message"]) - } - }) - } -} - -// V4: both interface support and ServesWorkstreams are required for hello. -func TestWorkstreamsFeatureFollowsTheEngine(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - w := startWireOver(t, fake, fake.fakeEngine) - if !workstreamHelloHasFeature(w.next()) { - t.Fatal("serving engine does not announce workstreams") - } - plain := startWire(t, &fakeEngine{}) - if workstreamHelloHasFeature(plain.next()) { - t.Fatal("plain engine claims workstreams") - } - plain.send(`{"id":1,"method":"workstream.list","params":{}}`) - got := plain.next() - err, ok := got["error"].(map[string]any) - if !ok || err["code"] != CodeUnknownMethod { - t.Fatal("plain engine does not answer unknown_method") - } - if err["message"] != "this engine does not serve workstreams" { - t.Errorf("plain engine message = %v", err["message"]) - } - hello := withWorkstreamsFeature(Hello{}, fake) - hello = withWorkstreamsFeature(hello, fake) - if len(hello.Features) != 2 || hello.Features[0] != FeatureWorkstreams || hello.Features[1] != FeatureWorkstreamSources { - t.Fatal("feature hook must add workstreams and sources exactly once") - } -} - -// V1: typed handlers and event registration expose the complete contract schema. -func TestWorkstreamSchemaNamesEveryMethod(t *testing.T) { - out, err := SchemaJSON() - if err != nil { - t.Fatal(err) - } - var doc struct { - Methods map[string]json.RawMessage `json:"methods"` - Events map[string]json.RawMessage `json:"events"` - Defs map[string]struct { - Properties map[string]map[string]any `json:"properties"` - } `json:"$defs"` - } - if err := json.Unmarshal(out, &doc); err != nil { - t.Fatal(err) - } - for _, name := range WorkstreamMethods { - if doc.Methods[name] == nil { - t.Errorf("schema lacks %s", name) - } - } - if doc.Events[EventWorkstreamChanged] == nil { - t.Error("schema lacks workstream.changed") - } - words, _ := doc.Defs["Workstream"].Properties["stage"]["enum"].([]any) - if len(words) != 3 { - t.Fatalf("Workstream.stage has %d words, want 3", len(words)) - } - for i, want := range []string{"shaping", "building", "done"} { - if words[i] != want { - t.Errorf("Workstream.stage word %d = %v, want %s", i, words[i], want) - } - } -} - -func (f *fakeWorkstreamer) WorkstreamWakeList(WorkstreamWakeListParams) (WorkstreamWakeListResult, error) { - return WorkstreamWakeListResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamWakeApply(WorkstreamWakeApplyParams) (WorkstreamWakeApplyResult, error) { - return WorkstreamWakeApplyResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamWakeUndo(WorkstreamWakeUndoParams) (WorkstreamWakeUndoResult, error) { - return WorkstreamWakeUndoResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamWakeNotNow(WorkstreamWakeNotNowParams) (WorkstreamWakeNotNowResult, error) { - return WorkstreamWakeNotNowResult{}, ErrWorkstreamsNotYet -} - -func TestWakeJ_ParameterBounds(t *testing.T) { - for _, p := range []interface{ Check() error }{WorkstreamWakeListParams{}, WorkstreamWakeApplyParams{}, WorkstreamWakeUndoParams{}, WorkstreamWakeNotNowParams{}, WorkstreamWakeListParams{WorkstreamID: "w", Limit: workstreamWireInt(101)}, WorkstreamWakeApplyParams{ID: "w", ItemID: workstreamWireString("")}} { - if p.Check() == nil { - t.Errorf("accepted %+v", p) - } - } - for _, p := range []interface{ Check() error }{WorkstreamWakeListParams{WorkstreamID: "w"}, WorkstreamWakeApplyParams{ID: "w"}, WorkstreamWakeUndoParams{ID: "w"}, WorkstreamWakeNotNowParams{ID: "w"}} { - if err := p.Check(); err != nil { - t.Error(err) - } - } -} -func workstreamWireInt(n int) *int { return &n } -func workstreamWireString(s string) *string { return &s } diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index c52f36b408..4816a949d2 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -110,11 +110,9 @@ different jobs. Use the same `--factory` with `find` and `remove`. Other kinds refuse a factory ID. `show` prints `factory_job factory `; `--json` includes the `factory` field. -A plain collection can reference a job alongside any other records. A job can -belong to only one workstream collection. Adding a job already in another -workstream answers `that job belongs to another workstream` and leaves every -reference in that add unchanged. These commands retain references without asking -the Factory whether the job is available and never start a job. +A collection can reference a job alongside any other records, and several +collections can hold the same job. These commands retain references without +asking the Factory whether the job is available and never start a job. ## Deleting or duplicating a collection @@ -125,226 +123,12 @@ referenced is deleted, moved or stopped. `codeaf collections duplicate ` makes a new collection holding the same references, in the same order; the new name follows the same uniqueness rule as `create`. -A workstream collection refuses deletion with `mark a workstream done instead`. -Duplicating one makes a plain collection with the same references; it creates no -workstream and claims no jobs. -## Workstreams in an app client: stages and agent chats +## Collections database versions and older engines -An app client can create a workstream backed by a collection with the same ID. -It includes one agent conversation, an optional origin chat, and the references -you choose. Creation opens the agent without sending a first message or running -a model. Repeating a nonempty client ID returns the original workstream. -Names follow collection naming rules; the client ID is at most 128 bytes and a -create takes at most 200 explicit references. - -A workstream starts in Shaping. Observing a member job queued, running, needing -you or done changes it to Building, which persists even if jobs return to Backlog. -Only marking the workstream done gives Done; all jobs merged does not. Reopening -returns it to Building. Marking done can archive its member chats while keeping -the agent's record. Collection rename and membership commands use the same store. - -## Workstream pages, records, plans and go-aheads - -Pages, page history, plans, decisions, questions and settings live in SQLite. -Markdown is an export, not a second store. A new workstream has an Overview page; -it can be renamed but cannot move or be deleted. Other pages can form a tree; -deleting a page deletes its descendants. Saving a stale page or plan refuses -without changing it. Restoring a page creates a new version. Page titles take -1–200 bytes and editor documents at most 1 MiB; record text takes 1–2000 bytes. -Client IDs, titles and source labels refuse control characters. Why, how it went, -record text and draft text allow newline and tab, and refuse other control -characters. Malformed app requests return `bad_params`. - -Decisions and questions can be proposed, kept, dropped and replaced. Keep can undo a drop; -a replaced record cannot change again. Plans store milestones, ordered waves -and job or draft items. Wave auto-start is stored without starting jobs. -A plan holds at most 500 milestones, 500 waves and 5,000 items; its stored -milestones and waves together encode to at most 4 MiB of JSON. -Plans beyond these limits return `too_large` without changing anything or -sending a change event. - -A go-ahead requires a member job, a planned wave or milestone, a positive cap -at most $10,000 and an end in the future at most 90 days away. Expired grants -read as ended. Marking done ends every still-active grant; granting on a done -workstream is refused. These are stored permissions; Factory enforcement is a -separate capability. Workstream numbers use recorded job facts and agent spend. -Updating, marking done and reopening persist the first observed start and return -those numbers using the same reads as getting the workstream. -Unavailable readings stay absent; a measured zero is present. Budgets and autonomy -settings do not themselves start work. - -## What a workstream's agent can do: proposals, pages and suggested edits - -A workstream's own agent in the app has eight tools. Other conversations, tasks -and errands do not receive them. `workstream_read` reads the workstream, pages, -decisions, questions, go-ahead proposals, plan, grants and sources. 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. -Each `propose_jobs` draft's text is at most 4,000 bytes; a plan stores draft -text up to 80,000 bytes. A workstream holds at most 5,000 pages, including -deleted pages, and 5,000 records of every kind and state. - -`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 titled Spec or Decisions have Read by jobs on, ignoring -case and surrounding space; other new pages have it off. Updates keep the flag. -The agent refuses the person's pages with “That page is the person's. You write -only your own pages; suggest edits on theirs with suggest_page_edit.” 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. - -## Suggested edits on your workstream pages: keep or drop - -`suggest_page_edit` suggests 1–50 edits on one of your pages, using its version -from `workstream_read`. The agent quotes unique plain text from one paragraph, -heading, list item or code block, without Markdown marks or escapes. Each find -is 1–4,000 bytes; each replacement is at most 4,000 bytes. The suggestion and -each edit carry a reason of 1–200 bytes on one line. Only code blocks accept -line breaks in replacements. A workstream holds at most 5,000 suggestions, -including decided suggestions. Page reads include open suggestions. - -You keep or drop one edit or all proposed edits in a suggestion. Until you keep -an edit, the page is unchanged. Each keep makes one new page version attributed -to you, with the suggestion as its source; keep all still makes only one version. -Drop leaves the page and history untouched. Remaining edits in the same block -move with kept text. An unchanged block can move in the document and still apply; -changing its text makes its edits stale. A keep with a stale edit changes nothing. -Done workstreams allow reading suggestions and refuse creating, keeping or dropping them. -The suggestion source kind is written only by keeping; source parameters refuse it. - -## Workstream draft size, budget and sources - -Draft plan items accept optional size `S`, `M` or `L`, a budget greater than zero -and at most $10,000, and a source. Job plan items refuse these fields. Older -plans without them still read normally. These fields are stored without -starting work. The agent cannot set draft size or budget. `propose_jobs` gives -each added draft the agent conversation as its source, or an explicit page, -member job or HTTP(S) link. Existing items and a conflict retry keep their sources. - -App page creation and saves accept an optional source: chat, message, Factory -job, page or URL. A message source needs its chat ID; a job source needs its -Factory ID. Source labels take at most 200 bytes on one line. The source becomes -the page's `updated_from` and that version's `from` in history. A later save -without a source clears `updated_from` and has no `from` on its version. - -## Workstream go-ahead proposals, keeping and undo - -`propose_goahead` makes a proposed `goahead` record with the reason as its text -and structured scope, target, cap and end. It grants nothing. Keeping it grants -exactly one go-ahead atomically; keeping it again changes nothing. Dropping a -kept proposal revokes its still-active grant. Keeping it after that grants a -new one. Records retain the latest grant ID, including after a drop. Go-ahead -records cannot be replaced with `record.supersede`. - -The proposed cap is greater than zero and at most $10,000, including fractional -dollars such as $12.50. The agent's `ends_on` date must be after today in UTC and -at most 89 days ahead; the end is that day's 23:59:59 UTC. Proposal and keep both -check that the target exists and the end is future and within 90 days. Proposing -or keeping on a done workstream conflicts. A refused keep changes nothing. -Expiry, revocation and marking done leave a kept record kept while ending its grant. - -`record.list` returns decisions and questions by default. Use -`include_goaheads: true` for every kind, or `kind: goahead` for only go-ahead -records. The agent's record reads include go-ahead proposals and their grant IDs. - -## Watching workstreams: morning wakes, costs, Apply, Undo and Not now - -With morning wakes on, a workstream can wake once for today's 08:00 slot in -this machine's zone, while the app is open, once that slot has passed and if it -is at or after creation. Before 08:00 no morning slot is eligible; closed days -are never caught up. -The app first checks after 30 seconds, then every minute; opening late wakes -once, without replaying a backlog of missed days. Related-issue wakes are -stored as a preference but are not available yet. Job-event handling is ready -for finished, merged, failed and needs-you events; automatic delivery awaits -the Factory event source. Nothing else wakes the agent. Done or autonomy Off -stops new wakes. - -Each wake makes one model call, bounded to three minutes, and returns one card -with a short summary and up to ten ranked applied changes and proposals. Propose only applies -nothing. Small changes can move plan items to manual waves without a wave -go-ahead, add follow-up drafts with a budget below $5 to a manual wave, and -write the agent's pages. A draft stays a draft. Decisions and questions remain -proposals. The person's pages are never written by a wake. - -## Applying and undoing wake proposals: Apply, Undo applied and Not now - -Apply chooses one or all proposals, regardless of autonomy; a decision or -question becomes kept. Undo applied restores changes only while their targets -are unchanged. Updated pages restore a previous version as a new agent version; -new pages are restorably deleted. A changed target refuses a single Undo; -Undo applied leaves that item with “Changed since; not undone.” Not now -dismisses the remaining proposals. Repeating an action changes nothing. -Done workstreams refuse Apply and Undo. - -Every recorded wake cost counts toward workstream spend. Unknown costs stay -absent. At most 24 wakes per workstream and 200 per home can run per local day, -with one turn at a time. A running wake left over 15 minutes becomes failed on -the next check or list: “Stopped before it finished.” Its cost is unknown. -An unreadable answer says “The agent's answer could not be read.” Any recorded -cost still counts toward spend. - -## Workstream sources: files, repos, issues, links and kept work - -The app's workstream Sources are pointers kept with `source.add` and returned -oldest first by `source.list`. Five kinds are available: a file or folder on -this computer; a repo named `owner/name` or a local folder; an issue with its -repo and number; an HTTP(S) link; or a kept record, page, message or member -Factory job. They are separate from collection members and canvases. Removing -a source with `source.remove` removes only its pointer and leaves its target -intact. The engine does not copy or index source contents. - -File paths must exist, be absolute and already clean, with no control -characters, dot segments, doubled separators or trailing separator. Local -repos must be folders. Source strings must be valid UTF-8. Paths take at most -4,096 bytes; links at most 2,048 both as sent and after URL normalization, -with a host and no whitespace or credentials. Titles take 1–200 bytes of -trimmed text on one line; absent titles are derived from the target. Issue -numbers run from 1 to 2,147,483,647. Kept reference IDs take 1–128 bytes. -Kept records and live pages must belong to the workstream; a kept job must -be a member. A missing file or local repo gets `gone: true` when listed. - -`workstream.create` accepts up to 200 initial sources, excluding kept work. -All are checked before creation, and a refusal creates no agent or collection. -Repeating a client ID returns its first workstream without rechecking source -paths, even if a file has since disappeared. Duplicate targets keep one source. -Sources never add text to the workstream's -why. A workstream holds at most 2,000 sources, proposals included; the next -addition returns `too_large`. Done workstreams permit listing but refuse -adding, updating and removing until reopened. - -## Agent source proposals, Read by jobs and reading content - -`propose_source` asks the person to keep or drop a source, with a reason of -1–500 bytes. A proposal is marked `added_by: agent`, `proposed: true` and -`read_by_jobs: false`. `source.update` with `keep: true` keeps it; removing it -drops it. Keeping preserves the reason and author. Adding the same proposed -target as the person also keeps it. Repeating an existing target adds nothing. - -`source.update` changes the title or Read by jobs, or keeps a proposal. At -least one field is required. `keep: false` is refused, and enabling Read by -jobs on a proposal requires keeping it in the same request. A change that -alters nothing sends no event. Jobs must exclude proposals; consuming the -Read by jobs selection is the Factory plan step's responsibility. - -The workstream agent reads the list with `workstream_read` (`what: sources`) -as complete JSON, up to the 2,000-source limit. It reads one source with -`what: source` and `source_id`. A regular UTF-8 text file is read within the -existing 60,000-byte answer cap, with “The file was cut.” -when cut. Binary or unreadable files get a one-line explanation. A link in the -final path component answers “The source is a link and was not read.” Links in -intermediate folders remain usable. Folders list up to 200 entry names without -following entries. Kept pages render as Markdown and records as -text; named repos, issues, links, messages and jobs remain pointers. Reading -never writes the target. These tools remain scoped to the workstream's agent. +Opening and reading a collections database never migrate it. The first successful +write upgrades supported versions 1–6 to schema 7; a failed write leaves the file +unchanged. Existing collections and references survive the upgrade, including +Factory job references. Older engines that do not support schema 7 refuse the +database with `unsupported collections database`. Collections are unavailable +in those engines; the data remains intact. Use a compatible engine to read it. diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index 0d5885e5d1..d7a56971a3 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", "workstream_read": + "factory_floor", "factory_status": return ActionRead // Changing something that exists. @@ -234,8 +234,7 @@ 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", - "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "propose_source", "write_agent_page", "suggest_page_edit": + "remember", "propose_subharness", "factory_add", "factory_recipe", "factory_item", "build_harness", "calendar_create": return ActionCreate // Executing and waiting on what it prints. diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 2279266e72..08b56dcc05 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -693,7 +693,7 @@ const ( // role, the call site writes `callPurpose(roles.RoleX)` and no constant is // declared here — restating a role's string would be the same word in two // places, and the point of a small closed block is that what IS in it stands - // out. The purposes below are not roles at all. + // out. The two below are not roles at all. // // purposeAutomationCheck is one automation watch's judgment: does what its // look saw meet the condition? It is the ONE DELIBERATE DIVERGENCE: the call @@ -704,8 +704,6 @@ const ( // tag (issue #1010, whose spine is #928). The standing check this replaced // was tagged `standing-check`, which cmd/codeaf-replay still reads. purposeAutomationCheck callPurpose = "automation-check" - // purposeWorkstreamWake names one workstream's event turn on the sentinel ladder. - purposeWorkstreamWake callPurpose = "workstream-wake" // purposeDocument is a rung of the document reader — the model's own eyes on // a PDF the `read` tool cannot open as text. It is not a role because the // reader resolves its model from the document settings and never from the diff --git a/internal/session/inherit.go b/internal/session/inherit.go index 4128971588..ccbeb8115a 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(workstreamWithoutPage(quick.system)) + child.system = promotedSystem(quick.system) child.system = clientToolsWithoutSections(child.system) child.systemOwn = false messages[0] = textMessage("system", child.system) diff --git a/internal/session/manual_test.go b/internal/session/manual_test.go index 9e857029f9..933e98ff90 100644 --- a/internal/session/manual_test.go +++ b/internal/session/manual_test.go @@ -132,24 +132,6 @@ func TestTheManualMentionsTheDesignThreadsOwnTool(t *testing.T) { } } -// Contract (h) checks the offered belt, including its eight 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", "propose_source", "write_agent_page", "suggest_page_edit"} { - 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/programhold_test.go b/internal/session/programhold_test.go index 21a2b18493..8e580fdc27 100644 --- a/internal/session/programhold_test.go +++ b/internal/session/programhold_test.go @@ -228,8 +228,7 @@ func TestProgramHoldClassifiesEveryRegisteredChatTool(t *testing.T) { "build_harness": true, "list_harnesses": true, "list_subharnesses": true, "propose_subharness": true, "revise_design": true, "web_search": true, "web_fetch": true, - "suggest_page_edit": true, - "gmail_read": true, "gmail_search": true, "gmail_send": true, + "gmail_read": true, "gmail_search": true, "gmail_send": true, "calendar_list": true, "calendar_create": true, "slack_search": true, "slack_read_thread": true, "slack_send": true, "slack_list_channels": true, } diff --git a/internal/session/prompt.go b/internal/session/prompt.go index 0295e27912..9d2ca93d82 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -399,7 +399,6 @@ func renderSystemAt(config Config, now time.Time) string { out.WriteString(catalog) } - out.WriteString(workstreamSection(config)) out.WriteString(workerFooter(config, now)) out.WriteString(clientToolsSection(config)) return out.String() diff --git a/internal/session/questionconversation.go b/internal/session/questionconversation.go index 0533a7e9ef..3d4b1b90b3 100644 --- a/internal/session/questionconversation.go +++ b/internal/session/questionconversation.go @@ -96,7 +96,6 @@ func (a *Agent) clarifyQuestion(answer Answer) error { } a.mu.Lock() cfg := a.config - cfg.Workstream = nil cfg.ClientTools = nil cfg.Model = a.model cfg.SessionFile, cfg.Place = "", Place{} @@ -109,7 +108,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { if cfg.ApprovalPolicy == nil { cfg.ApprovalPolicy = a.config.ApprovalPolicy } - cfg.System = workstreamWithoutPage(a.system) + cfg.System = a.system cfg.System = clientToolsWithoutSections(cfg.System) if a.guardianOverride != nil { cfg.Guardian = *a.guardianOverride diff --git a/internal/session/session.go b/internal/session/session.go index 393e328ff0..9eddab8534 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2525,9 +2525,6 @@ 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 - // ClientTools is the door onto tools an app attached to this conversation. Nil is no door. ClientTools ClientToolDoor diff --git a/internal/session/toolargs_test.go b/internal/session/toolargs_test.go index 0cae4045f1..3a95f60aa6 100644 --- a/internal/session/toolargs_test.go +++ b/internal/session/toolargs_test.go @@ -489,8 +489,6 @@ 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 d9958b616e..40e6597c19 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -273,7 +273,6 @@ 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 deleted file mode 100644 index 251eab7a94..0000000000 --- a/internal/session/workstream_contract.go +++ /dev/null @@ -1,104 +0,0 @@ -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) - Source(context.Context, *workstream.Source) (*workstream.Source, error) - Plan(context.Context) (WorkstreamPlanState, error) - SetPlan(context.Context, int, WorkstreamPlanState) (WorkstreamPlanState, error) - WritePage(context.Context, WorkstreamPageWrite) (WorkstreamPageWritten, error) - ProposeSource(context.Context, WorkstreamSourceProposal) (WorkstreamSourceProposed, error) - SuggestEdits(context.Context, WorkstreamSuggestionAsk) (WorkstreamSuggested, 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; suggest edits on theirs with suggest_page_edit.") - -var ErrWorkstreamTooLarge = errors.New("That page is too large; the encoded document must be at most 1 MiB.") - -// 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"` - SourceID string `json:"source_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 -} - -type WorkstreamSuggestionAsk struct { - PageID string `json:"page_id"` - BaseVersion int `json:"base_version"` - Why string `json:"why"` - Edits []workstream.SuggestionFind `json:"edits"` -} -type WorkstreamSuggested struct { - ID, Title string - Count int -} diff --git a/internal/session/workstream_factory_test.go b/internal/session/workstream_factory_test.go deleted file mode 100644 index 4af91f6511..0000000000 --- a/internal/session/workstream_factory_test.go +++ /dev/null @@ -1,108 +0,0 @@ -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_fields_v02_test.go b/internal/session/workstream_fields_v02_test.go deleted file mode 100644 index 31ec08cf71..0000000000 --- a/internal/session/workstream_fields_v02_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package session - -import ( - "errors" - "reflect" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WP-F2 (e): the last second of day 89 fits every grant clock on today's UTC day. -func TestWorkstreamGoAheadDateEdgeIs89Days(t *testing.T) { - for _, hour := range []int{0, 12, 23} { - now := time.Date(2026, 11, 3, hour, 0, 0, 0, time.UTC) - ask := WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 1, Why: "Review"} - ask.EndsOn = now.AddDate(0, 0, 89).Format("2006-01-02") - if err := WorkstreamValidateGoAhead(ask, now); err != nil { - t.Fatal(err) - } - ask.EndsOn = now.AddDate(0, 0, 90).Format("2006-01-02") - if err := WorkstreamValidateGoAhead(ask, now); err == nil || !strings.Contains(err.Error(), "at most 89 days ahead") { - t.Fatalf("90-day edge: %v", err) - } - } - if !strings.Contains(workstreamGoAheadSchema, "at most 89 days ahead") { - t.Fatal("schema date bound drifted") - } -} - -// WP-F2 (e): resolve once before retry, stamp every added draft, preserve old items. -func TestWorkstreamDraftSourcesSurviveTheSingleRetry(t *testing.T) { - for _, source := range []*workstream.Source{nil, {Kind: "page", ID: "page", Label: "Spec"}} { - d := &workstreamFakeDoor{conflicts: 1, plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 1, Items: []workstream.PlanItem{{Kind: "draft", DraftID: "old", Title: "Old", Size: "L", BudgetUSD: 10, Source: &workstream.Source{Kind: "message", ID: "m", ChatID: "chat"}}}}}}} - before := d.plan.Waves[0] - args := `{"jobs":[{"title":"One"},{"title":"Two"}]}` - want := &workstream.Source{Kind: "chat", ID: "agent-chat"} - if source != nil { - args = `{"jobs":[{"title":"One"},{"title":"Two"}],"source":{"kind":"page","id":"page","label":"Spec"}}` - want = source - } - workstreamCallForTest(t, d, "propose_jobs", args, false) - if d.sourceCalls != 1 || len(d.saves) != 2 { - t.Fatalf("resolve/retry calls: %+v", d) - } - for _, saved := range d.saves { - if !reflect.DeepEqual(saved.Waves[0], before) { - t.Fatal("old item metadata changed") - } - wave := saved.Waves[len(saved.Waves)-1] - for _, item := range wave.Items { - if !reflect.DeepEqual(item.Source, want) || item.Size != "" || item.BudgetUSD != 0 { - t.Fatalf("draft: %+v", item) - } - } - } - } -} - -// WP-F2 (e): invalid explicit source refuses without a plan write. -func TestWorkstreamDraftSourceRefusalAddsNothing(t *testing.T) { - d := &workstreamFakeDoor{sourceError: errors.New("source page does not exist")} - workstreamCallForTest(t, d, "propose_jobs", `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"missing"}}`, true) - if d.sourceCalls != 1 || d.reads != 0 || len(d.saves) != 0 { - t.Fatalf("refusal wrote: %+v", d) - } - for _, args := range []string{ - `{"jobs":[{"title":"Draft","size":"S"}]}`, - `{"jobs":[{"title":"Draft","budget_usd":1}]}`, - `{"jobs":[{"title":"Draft"}],"source":{"kind":"message","id":"m"}}`, - `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"p","extra":true}}`, - `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"p","factory":""}}`, - } { - d := &workstreamFakeDoor{} - workstreamCallForTest(t, d, "propose_jobs", args, true) - if d.sourceCalls != 0 || d.reads != 0 || len(d.saves) != 0 { - t.Fatal("invalid source reached the door") - } - } -} - -// WP-F2 (f): the agent's read grammar accepts the structured proposal kind. -func TestWorkstreamReadAcceptsGoAheadKind(t *testing.T) { - d := &workstreamFakeDoor{read: []byte(`{"records":[]}`)} - workstreamCallForTest(t, d, "workstream_read", `{"what":"records","kind":"goahead"}`, false) - if d.readAsk.Kind != "goahead" { - t.Fatalf("kind: %+v", d.readAsk) - } -} diff --git a/internal/session/workstream_limits.go b/internal/session/workstream_limits.go deleted file mode 100644 index eb58f40c90..0000000000 --- a/internal/session/workstream_limits.go +++ /dev/null @@ -1,22 +0,0 @@ -package session - -import ( - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const workstreamLineMax = 200 - -const workstreamRecordMax = 2000 - -const workstreamDraftTextMax = 4000 - -const workstreamJobsMax = 20 - -const workstreamGoAheadCapMax = workspace.WorkstreamGoAheadCapMax - -// Ending at the UTC day's last second keeps the proposal within the grant limit. -const workstreamGoAheadDaysMax = int(workspace.WorkstreamGoAheadDurationMax/(24*time.Hour)) - 1 - -const workstreamGoAheadWhyMax = 1000 diff --git a/internal/session/workstream_schema.go b/internal/session/workstream_schema.go deleted file mode 100644 index 78d02ef99d..0000000000 --- a/internal/session/workstream_schema.go +++ /dev/null @@ -1,181 +0,0 @@ -package session - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "sort" - "strings" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -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","sources","source"]},"page_id":{"type":"string"},"source_id":{"type":"string"},"kind":{"type":"string","enum":["decision","question","goahead"]},"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"},"source":%s},"required":["jobs"],"additionalProperties":false}`, workstreamJobsMax, workstreamLineMax, workstreamLineMax, workstreamDraftTextMax, workstreamLineMax, workstreamSourceSchema) - -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","description":"YYYY-MM-DD, after today in UTC and at most %d days ahead."},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["scope","cap_usd","ends_on","why"],"additionalProperties":false}`, workstreamGoAheadCapMax, workstreamGoAheadDaysMax, 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) - -var workstreamSuggestionSchema = fmt.Sprintf(`{"type":"object","properties":{"page_id":{"type":"string","minLength":1},"base_version":{"type":"integer","minimum":1},"why":{"type":"string","minLength":1,"maxLength":%d},"edits":{"type":"array","minItems":1,"maxItems":%d,"items":{"type":"object","properties":{"find":{"type":"string","minLength":1,"maxLength":%d},"replace":{"type":"string","minLength":0,"maxLength":%d},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["find","replace","why"],"additionalProperties":false}}},"required":["page_id","base_version","why","edits"],"additionalProperties":false}`, workstream.SuggestionWhyMax, workstream.SuggestionEditsMax, workstream.SuggestionTextMax, workstream.SuggestionTextMax, workstream.SuggestionWhyMax) - -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 _, source := target.(*WorkstreamSourceProposal); source && !utf8.Valid(args) { - return nil, fmt.Errorf("source arguments must be valid UTF-8") - } - 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_sources.go b/internal/session/workstream_sources.go deleted file mode 100644 index ccb77c3705..0000000000 --- a/internal/session/workstream_sources.go +++ /dev/null @@ -1,53 +0,0 @@ -package session - -import ( - "context" - "fmt" - - "github.com/Agent-Field/codeaf/internal/exec/bare" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WorkstreamSourceReadMax lets the app bound reads before JSON encoding. -const WorkstreamSourceReadMax = workstreamReadMax - -type WorkstreamSourceProposal struct { - Kind string `json:"kind"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *workstream.KeptRef `json:"ref,omitempty"` - Title *string `json:"title,omitempty"` - Why string `json:"why"` -} - -type WorkstreamSourceProposed struct { - ID string - Already bool -} - -func (p WorkstreamSourceProposal) SourceAdd() workstream.SourceAdd { - return workstream.SourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Ref: p.Ref, Title: p.Title} -} - -var workstreamProposeSourceSchema = fmt.Sprintf(`{"type":"object","properties":{"kind":{"type":"string","enum":["file","repo","issue","link","kept"]},"path":{"type":"string","minLength":1,"maxLength":%d},"repo":{"type":"string"},"number":{"type":"integer","minimum":1,"maximum":%d},"url":{"type":"string","minLength":1,"maxLength":%d},"ref":{"type":"object","properties":{"kind":{"type":"string","enum":["record","page","message","factory_job"]},"id":{"type":"string","minLength":1,"maxLength":%d},"chat_id":{"type":"string","minLength":1,"maxLength":%d},"factory":{"type":"string","minLength":1,"maxLength":%d}},"required":["kind","id"],"additionalProperties":false},"title":{"type":"string","minLength":1,"maxLength":%d},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["kind","why"],"additionalProperties":false}`, workstream.SourcePathMax, workstream.SourceIssueMax, workstream.SourceURLMax, workstream.SourceRefMax, workstream.SourceRefMax, workstream.SourceRefMax, workstream.SourceTitleMax, workstream.SourceWhyMax) - -func workstreamSourceTool(door WorkstreamDoor) bare.Tool { - return workstreamTool("propose_source", "Propose a source with a reason. The person keeps or drops it; jobs cannot read a proposal.", workstreamProposeSourceSchema, func(ctx context.Context, ask WorkstreamSourceProposal) (string, error) { - if err := workstream.ValidateSourceWhy(ask.Why); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - if err := workstream.ValidateSourceAdd(ask.SourceAdd()); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - out, err := door.ProposeSource(ctx, ask) - if err != nil { - return "", err - } - if out.Already { - return "Source " + out.ID + " is already present. The person keeps or drops it.", nil - } - return "Proposed source " + out.ID + ". The person keeps or drops it.", nil - }) -} diff --git a/internal/session/workstream_sources_test.go b/internal/session/workstream_sources_test.go deleted file mode 100644 index 040bdcda0f..0000000000 --- a/internal/session/workstream_sources_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package session - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" -) - -// WP-12 (g): the new tool reaches only its proposal door and tells the person -// who keeps or drops it. Its strict schema refuses invented fields and nulls. -func TestWorkstreamProposeSourceToolAndStrictArguments(t *testing.T) { - d := &workstreamFakeDoor{} - got := workstreamCallForTest(t, d, "propose_source", `{"kind":"link","url":"https://example.com","why":"Read the guide."}`, false) - if !strings.Contains(got, "source-1") || !strings.Contains(got, "person keeps or drops") || len(d.sourceProposals) != 1 || len(d.proposals) != 0 || len(d.saves) != 0 || len(d.pageWrites) != 0 { - t.Fatalf("proposal: %q %+v", got, d) - } - for _, args := range []string{ - `{"kind":"link","url":"https://example.com","why":"Read it.","read_by_jobs":true}`, - `{"kind":"link","url":"https://example.com","why":"Read it.","proposed":false}`, - `{"kind":"file","path":"relative","why":"Read it."}`, - `{"kind":"link","url":"file:///etc/passwd","why":"Read it."}`, - `{"kind":"link","url":"https://example.com","why":""}`, - `{"kind":"link","url":"https://example.com","why":"` + strings.Repeat("x", 501) + `"}`, - `{"kind":"link","url":"https://example.com","why":"Read it.","title":null}`, - `{"kind":"kept","ref":{"kind":"message","id":"m","chat_id":"c","extra":true},"why":"Read it."}`, - `{"kind":"kept","ref":{"kind":"message","id":"m","id":"m","chat_id":"c"},"why":"Read it."}`, - `{"kind":"link","url":"https://example.com","url":"https://example.com","why":"Read it."}`, - } { - d := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - text, refused, err := workstreamToolForTest(t, d, "propose_source").Execute(ctx, json.RawMessage(args)) - if err != nil || !refused || !mark.RejectedBeforeRun() || d.otherCalls != 0 { - t.Fatalf("invalid proposal: %s %q %+v %v", args, text, d, err) - } - } -} - -// WP-12 Fix 1 Major 3 (b), (g): a raw invalid path is refused before the -// proposal door, even when its JSON-repaired replacement would be a real file. -func TestWorkstreamMajor3InvalidUTF8BProposeSource(t *testing.T) { - path := filepath.Join(t.TempDir(), "source-�.txt") - if err := os.WriteFile(path, []byte("Existing replacement path."), 0600); err != nil { - t.Fatal(err) - } - args, err := json.Marshal(map[string]any{"kind": "file", "path": path, "why": "Read the source."}) - if err != nil { - t.Fatal(err) - } - d := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - invalid := bytes.ReplaceAll(args, []byte("�"), []byte{0xff}) - if !bytes.Contains(invalid, []byte{0xff}) { - t.Fatal("fixture must contain a literal invalid UTF-8 byte") - } - answer, refused, err := workstreamToolForTest(t, d, "propose_source").Execute(ctx, invalid) - if err != nil || !refused || !mark.RejectedBeforeRun() || !strings.Contains(answer, "UTF-8") || d.otherCalls != 0 || len(d.sourceProposals) != 0 { - t.Fatalf("invalid raw path reached proposal door: %q %+v %v", answer, d, err) - } - workstreamCallForTest(t, d, "propose_source", string(args), false) - if len(d.sourceProposals) != 1 || *d.sourceProposals[0].Path != path { - t.Fatalf("valid replacement character proposal: %+v", d.sourceProposals) - } -} - -// WP-12 (g): sources and source are valid reads; source_id is required only -// for the latter and never permits another part's selector fields. -func TestWorkstreamSourceReadArguments(t *testing.T) { - for _, args := range []string{`{"what":"sources"}`, `{"what":"source","source_id":"source-1"}`} { - d := &workstreamFakeDoor{read: json.RawMessage(`{"sources":[]}`)} - workstreamCallForTest(t, d, "workstream_read", args, false) - if d.otherCalls != 1 { - t.Fatal("source read did not reach the door") - } - } - for _, args := range []string{`{"what":"source"}`, `{"what":"source","source_id":""}`, `{"what":"sources","source_id":"source-1"}`, `{"what":"source","source_id":"source-1","page_id":"page-1"}`, `{"what":"source","source_id":"source-1","kind":"decision"}`} { - d := &workstreamFakeDoor{} - workstreamCallForTest(t, d, "workstream_read", args, true) - if d.otherCalls != 0 { - t.Fatal("invalid source selector reached the door") - } - } -} - -// WP-12 (g): every source remains in the JSON inventory even when it exceeds -// the content-read cap; page and file content retain their existing bounds. -func TestWorkstreamSourceListKeepsEveryRowAsJSON(t *testing.T) { - items := make([]map[string]any, 2000) - for i := range items { - items[i] = map[string]any{"id": fmt.Sprintf("source-%d", i), "workstream_id": "w", "kind": "link", "title": strings.Repeat("x", 100), "url": "https://example.com", "read_by_jobs": false, "proposed": false, "added_by": "you", "added_at": "2026-11-03T12:00:00Z"} - } - raw, err := json.Marshal(map[string]any{"sources": items}) - if err != nil { - t.Fatal(err) - } - d := &workstreamFakeDoor{read: raw} - got := workstreamCallForTest(t, d, "workstream_read", `{"what":"sources"}`, false) - if len(got) <= workstreamReadMax || !json.Valid([]byte(got)) || got != string(raw) { - t.Fatal("source inventory was cut or changed") - } -} diff --git a/internal/session/workstream_suggestions_test.go b/internal/session/workstream_suggestions_test.go deleted file mode 100644 index 1ae44cd46a..0000000000 --- a/internal/session/workstream_suggestions_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WP-6d Tool, (a), (h): exact answers travel as normal tool refusals. -func TestWorkstreamSuggestionToolAnswers(t *testing.T) { - const args = `{"page_id":"page","base_version":3,"why":"Reason","edits":[{"find":"old","replace":"new","why":"Clearer"}]}` - for _, test := range []struct { - err error - want string - }{ - {nil, "Suggested 1 edit(s) on Notes (sg_1). The person keeps or drops each one; the page is unchanged until then."}, - {workstream.ErrNotPersonPage, "That page is yours; change it with write_agent_page. Suggest edits only on the person's pages."}, - {ErrWorkstreamVersion, "The page changed while you were writing. Read it again before writing."}, - {ErrWorkstreamDone, "This workstream is done; nothing was added or written."}, - {errors.New("This workstream holds too many suggestions; nothing was suggested."), "This workstream holds too many suggestions; nothing was suggested."}, - {&workstream.SuggestionTextError{Message: "Edit 1: that text is not on the page. Quote plain text from one paragraph, heading, list item or code block exactly as it reads, without markdown marks."}, "Edit 1: that text is not on the page. Quote plain text from one paragraph, heading, list item or code block exactly as it reads, without markdown marks."}, - {&workstream.SuggestionTextError{Message: "Edit 1: that text appears more than once. Quote more of it so it is unique."}, "Edit 1: that text appears more than once. Quote more of it so it is unique."}, - {&workstream.SuggestionTextError{Message: "Edit 1: replace is the same as find."}, "Edit 1: replace is the same as find."}, - {&workstream.SuggestionTextError{Message: "Edit 1: only a code block's text may contain a line break."}, "Edit 1: only a code block's text may contain a line break."}, - {&workstream.SuggestionTextError{Message: "Edits 1 and 2 touch the same text. Merge them into one edit."}, "Edits 1 and 2 touch the same text. Merge them into one edit."}, - } { - door := &workstreamFakeDoor{suggestionError: test.err} - ctx, mark := withToolRejection(context.Background()) - got, refused, err := workstreamToolForTest(t, door, "suggest_page_edit").Execute(ctx, json.RawMessage(args)) - if err != nil || refused != (test.err != nil) || got != test.want || mark.RejectedBeforeRun() || len(door.suggestionAsks) != 1 { - t.Fatalf("answer: %q refused=%v %v", got, refused, err) - } - } -} - -// WP-6d Tool: strict nested schema, nulls, required fields, integer and byte -// bounds are refused before any door. Boundary values are accepted unchanged. -func TestWorkstreamSuggestionSchemaBeforeDoor(t *testing.T) { - valid := func() map[string]any { - return map[string]any{"page_id": "page", "base_version": 1, "why": "Reason", "edits": []any{map[string]any{"find": "old", "replace": "new", "why": "Clearer"}}} - } - for _, mutate := range []func(map[string]any){ - func(p map[string]any) { p["extra"] = true }, - func(p map[string]any) { delete(p, "page_id") }, - func(p map[string]any) { p["page_id"] = "" }, - func(p map[string]any) { p["page_id"] = nil }, - func(p map[string]any) { delete(p, "base_version") }, - func(p map[string]any) { p["base_version"] = 0 }, - func(p map[string]any) { p["base_version"] = -1 }, - func(p map[string]any) { p["base_version"] = 1.5 }, - func(p map[string]any) { p["base_version"] = "1" }, - func(p map[string]any) { p["base_version"] = nil }, - func(p map[string]any) { p["why"] = "" }, - func(p map[string]any) { p["why"] = strings.Repeat("é", 101) }, - func(p map[string]any) { p["why"] = "two\nlines" }, - func(p map[string]any) { p["why"] = nil }, - func(p map[string]any) { p["edits"] = nil }, - func(p map[string]any) { p["edits"] = []any{} }, - func(p map[string]any) { p["edits"] = []any{nil} }, - func(p map[string]any) { - p["edits"] = []any{map[string]any{"find": "old", "replace": "new", "why": "Why", "source": true}} - }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["find"] = "" }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["find"] = strings.Repeat("é", 2001) }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["replace"] = strings.Repeat("é", 2001) }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["replace"] = nil }, - func(p map[string]any) { delete(p["edits"].([]any)[0].(map[string]any), "replace") }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["why"] = "" }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["why"] = strings.Repeat("x", 201) }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["why"] = "two\nlines" }, - func(p map[string]any) { - one := p["edits"].([]any)[0] - p["edits"] = make([]any, 51) - for i := range p["edits"].([]any) { - p["edits"].([]any)[i] = one - } - }, - } { - params := valid() - mutate(params) - raw, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - door := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - text, refused, err := workstreamToolForTest(t, door, "suggest_page_edit").Execute(ctx, raw) - if err != nil || !refused || !mark.RejectedBeforeRun() || door.otherCalls != 0 { - t.Fatalf("invalid params reached door: %s; %q %v", raw, text, err) - } - } - params := valid() - params["why"] = strings.Repeat("x", 200) - params["edits"] = make([]any, 50) - for i := range params["edits"].([]any) { - params["edits"].([]any)[i] = map[string]any{"find": strings.Repeat("é", 2000), "replace": "", "why": strings.Repeat("x", 200)} - } - raw, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - door := &workstreamFakeDoor{} - workstreamCallForTest(t, door, "suggest_page_edit", string(raw), false) - if len(door.suggestionAsks) != 1 || len(door.suggestionAsks[0].Edits) != 50 { - t.Fatal("boundary refused") - } - var schema map[string]any - if err := json.Unmarshal(workstreamToolForTest(t, door, "suggest_page_edit").Schema, &schema); err != nil { - t.Fatal(err) - } - edits := schema["properties"].(map[string]any)["edits"].(map[string]any) - if schema["additionalProperties"] != false || edits["items"].(map[string]any)["additionalProperties"] != false { - t.Fatal("schema permits invented properties") - } -} - -// WP-6d Tool: suggestions belong to the same create family as proposals. -func TestWorkstreamSuggestionActionCategory(t *testing.T) { - if category := ActionCategoryForTool("suggest_page_edit"); category != ActionCreate { - t.Fatalf("category: %q", category) - } -} diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go deleted file mode 100644 index 93c8733d54..0000000000 --- a/internal/session/workstream_tools.go +++ /dev/null @@ -1,545 +0,0 @@ -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 workstreamPageChanged = "The page changed while you were writing. Read it again before writing." - -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" + - "- Write your own pages with write_agent_page; on the person's, suggest edits with suggest_page_edit.\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" + - "- Keeping a go-ahead proposal grants it. You start nothing; the person launches jobs.\n" + - "- Sources: workstream_read (sources, source) reads; propose_source proposes. The person keeps them." - -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"), - workstreamSourceTool(door), - 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 record. It grants nothing until the person keeps it.", 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) - }), - workstreamTool("suggest_page_edit", "Suggest edits on one of the person's pages. find is plain text exactly as it reads on the page inside one paragraph, heading, list item or code block, without markdown marks or escapes; quote it uniquely. base_version is the page's version from workstream_read. Nothing changes until the person keeps an edit.", workstreamSuggestionSchema, func(ctx context.Context, ask WorkstreamSuggestionAsk) (string, error) { - return workstreamSuggestEdits(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") - } - _, source := fields["source_id"] - if source != (ask.What == "source") || source && strings.TrimSpace(ask.SourceID) == "" { - return errors.New("source_id is required for and only for source") - } - 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 WorkstreamSuggestionAsk: - ids["page_id"] = ask.PageID - case workstreamJobsAsk: - ids["milestone_id"] = ask.MilestoneID - source = ask.Source - 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 - } - // Source inventories have a row bound and must remain complete JSON. - if len(text) > workstreamReadMax && ask.What != "sources" { - 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"` - Source *workstream.Source `json:"source,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 workstreamSourceText(ask.Source) -} - -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()} - } - source, err := door.Source(ctx, ask.Source) - if err != nil { - return "", err - } - drafts, err := workstreamDrafts(ask.Jobs, source) - 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, source *workstream.Source) ([]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, Source: source}) - } - 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()} - } - err := workstreamGoAheadScope(ctx, door, ask) - if err != nil { - return "", err - } - p, err := door.Propose(ctx, WorkstreamProposal{Kind: "goahead", Text: ask.Why, GoAhead: &ask}) - if err != nil { - return "", err - } - return "Proposed go-ahead " + p.ID + ". It grants nothing; the person grants it by keeping it.", 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) error { - if ask.Scope == "job" { - return 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 nil - } - } - return errors.New("wave must name an existing plan wave") - } - for _, milestone := range plan.Milestones { - if milestone.ID == ask.MilestoneID { - return 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(workstreamPageChanged) - } - 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 -} - -func workstreamSuggestEdits(ctx context.Context, door WorkstreamDoor, ask WorkstreamSuggestionAsk) (string, error) { - if err := workstream.ValidateSuggestion(ask.PageID, ask.BaseVersion, ask.Why, ask.Edits); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - out, err := door.SuggestEdits(ctx, ask) - if errors.Is(err, ErrWorkstreamVersion) { - return "", errors.New(workstreamPageChanged) - } - if err != nil { - return "", err - } - return fmt.Sprintf("Suggested %d edit(s) on %s (%s). The person keeps or drops each one; the page is unchanged until then.", out.Count, out.Title, out.ID), nil -} diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go deleted file mode 100644 index 12f65ad8ba..0000000000 --- a/internal/session/workstream_tools_test.go +++ /dev/null @@ -1,458 +0,0 @@ -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 - sourceCalls int - sourceError error - sourceProposals []WorkstreamSourceProposal - suggestionAsks []WorkstreamSuggestionAsk - suggestionError error -} - -func (d *workstreamFakeDoor) Source(_ context.Context, source *workstream.Source) (*workstream.Source, error) { - d.sourceCalls++ - if source == nil { - source = &workstream.Source{Kind: "chat", ID: "agent-chat"} - } - return source, d.sourceError -} - -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) ProposeSource(_ context.Context, p WorkstreamSourceProposal) (WorkstreamSourceProposed, error) { - d.otherCalls++ - d.sourceProposals = append(d.sourceProposals, p) - return WorkstreamSourceProposed{ID: "source-1"}, d.sourceError -} - -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 (d *workstreamFakeDoor) SuggestEdits(_ context.Context, ask WorkstreamSuggestionAsk) (WorkstreamSuggested, error) { - d.otherCalls++ - d.suggestionAsks = append(d.suggestionAsks, ask) - return WorkstreamSuggested{ID: "sg_1", Title: "Notes", Count: len(ask.Edits)}, d.suggestionError -} - -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)+8 || len(with.workstreamTools()) != 8 { - t.Fatal("the workstream must add exactly eight 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", "propose_source", "write_agent_page", "suggest_page_edit"} - 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-02" }, - 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 go-ahead record-1. It grants nothing; the person grants it by keeping it." || p.Kind != "goahead" || p.Text != "Review the result" || p.GoAhead == nil || p.GoAhead.Scope != "wave" || p.GoAhead.Wave != 2 || p.GoAhead.CapUSD != 12.5 || p.GoAhead.EndsOn != "2026-11-04" { - 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; suggest edits on theirs with suggest_page_edit." { - 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", "propose_source", "write_agent_page", "suggest_page_edit"} - 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 deleted file mode 100644 index e5542f52b7..0000000000 --- a/internal/session/workstream_validation_test.go +++ /dev/null @@ -1,158 +0,0 @@ -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] - if proposal.GoAhead == nil || proposal.GoAhead.CapUSD != 12.5 || proposal.Text != "Review" || proposal.Kind != "goahead" || proposal.GoAhead.Scope != tc.name || (tc.name == "job" && (proposal.GoAhead.Factory != "f" || proposal.GoAhead.Job != 7)) || (tc.name == "wave" && proposal.GoAhead.Wave != 2) { - t.Fatalf("fractional dollars changed: %+v", proposal) - } - } -} diff --git a/internal/session/workstream_wake.go b/internal/session/workstream_wake.go deleted file mode 100644 index 057e9a2971..0000000000 --- a/internal/session/workstream_wake.go +++ /dev/null @@ -1,89 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "errors" - - "github.com/Agent-Field/agentfield/sdk/go/ai" - - "github.com/Agent-Field/codeaf/internal/lane" - "github.com/Agent-Field/codeaf/internal/provider" - "github.com/Agent-Field/codeaf/internal/roles" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WorkstreamWakeAsk is a bounded snapshot for one unattended turn, with no tools. -type WorkstreamWakeAsk struct { - ID string - AgentID string - Event workstream.WakeEvent - Snapshot json.RawMessage -} - -type WorkstreamWakeAnswer struct { - Text string - CostUSD *float64 - Model string -} - -const workstreamWakeRules = `You are the workstream's agent. You propose; the person keeps. You write only your own pages; the person's pages are theirs. Keep your agent page "` + WorkstreamWhereItStandsTitle + `" current, saying what it was updated from. Never invent numbers: counts, costs, states and dates come from the snapshot; absent numbers are unknown. On a page use a live block such as [live: jobs] instead of writing a number. You start nothing; the person launches jobs. -For this wake, answer one JSON object. Summarise in at most two sentences, rank the most useful items first, at most 10 items. The engine decides what applies under autonomy; decisions and questions are always proposed. You cannot start jobs or write the person's pages. There are no tools.` - -const workstreamWakeFormat = `Answer format: -{"summary":"...","items":[{"kind":"move","factory":"f1","job":1102,"to_wave":3,"why":"..."},{"kind":"move","draft_id":"d_x","to_wave":2,"why":"..."},{"kind":"draft","title":"...","repo":"api","text":"...","size":"S","budget_usd":3,"wave":3,"why":"..."},{"kind":"page","page_id":"existing agent page id; omit for new","title":"...","markdown":"...","why":"..."},{"kind":"decision","text":"...","why":"..."},{"kind":"question","text":"...","why":"..."}]}` - -func workstreamWakeQuestion(ask WorkstreamWakeAsk) string { - event, _ := json.Marshal(ask.Event) - snapshot := "Numbers absent below are unknown; say so and never estimate them.\n" + string(ask.Snapshot) - if len(snapshot) > workstreamReadMax { - const tail = "\nThe workstream read was cut." - snapshot = workstreamCut(snapshot, workstreamReadMax-len(tail)) + tail - } - return "Event:\n" + string(event) + "\nWorkstream snapshot:\n" + snapshot + "\n" + workstreamWakeFormat -} - -// RunWorkstreamWake resolves the credentialed sentinel ladder down to the person's model. -// It constructs call state only: no agent, transcript, tool belt or turn loop. -func RunWorkstreamWake(ctx context.Context, settings TeamProposerConfig, ask WorkstreamWakeAsk) (WorkstreamWakeAnswer, error) { - cfg := Config{Model: settings.Model, APIKey: settings.APIKey, BaseURL: settings.BaseURL, ProfileDir: settings.ProfileDir, Sources: settings.Sources, RolesSource: settings.RolesSource} - models, err := automationJudgeModels(cfg) - if err != nil { - return WorkstreamWakeAnswer{}, err - } - model := models[0] - client, err := newProviderClient(cfg, model) - if err != nil { - return WorkstreamWakeAnswer{}, err - } - return workstreamWakeCall(ctx, client, model, ask, func(line UsageLine) { RecordUsage(UsageLedgerPath(), line) }) -} - -func workstreamWakeCall(ctx context.Context, client Completer, model string, ask WorkstreamWakeAsk, record func(UsageLine)) (WorkstreamWakeAnswer, error) { - ctx = provider.WithRole(provider.WithRoutingIntent(provider.WithoutStream(withPurpose(ctx, purposeWorkstreamWake)), provider.IntentBackground), lane.RoleStanding) - response, err := client.CompleteWithMessages(ctx, []ai.Message{textMessage("system", workstreamWakeRules), textMessage("user", workstreamWakeQuestion(ask))}) - answer := WorkstreamWakeAnswer{Model: model} - line := UsageLine{Model: model, Role: string(roles.RoleSentinel), Calls: 1, Session: ask.AgentID, Run: ask.ID, Unbilled: true} - if response == nil { - record(line) - return answer, workstreamWakeResponseError(err) - } - answer.Text = response.Text() - if response.Usage != nil { - line.Input, line.Output = response.Usage.PromptTokens, response.Usage.CompletionTokens - answer.CostUSD = response.Usage.Cost - if answer.CostUSD != nil { - line.USD = *answer.CostUSD - line.Price = usagePrice(line.USD, usagePriceProvider) - line.Unbilled = false - } - } - record(line) - return answer, err -} -func workstreamWakeResponseError(err error) error { - if err != nil { - return err - } - return errors.New("the model answered nothing") -} diff --git a/internal/session/workstream_wake_test.go b/internal/session/workstream_wake_test.go deleted file mode 100644 index 0bc04cb14e..0000000000 --- a/internal/session/workstream_wake_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "net/http" - "strings" - "testing" - "unicode/utf8" - - "github.com/Agent-Field/agentfield/sdk/go/ai" - "github.com/Agent-Field/codeaf/internal/lane" - "github.com/Agent-Field/codeaf/internal/provider" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func TestWakeB_OneModelCallWithCostAndNoTools(t *testing.T) { - for _, known := range []bool{true, false} { - t.Run(map[bool]string{true: "known", false: "unknown"}[known], func(t *testing.T) { - usd := .04 - client := &scriptedCompleter{steps: []step{func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { - if len(messages) != 2 || provider.RoleFrom(ctx) != lane.RoleStanding || provider.RoutingIntentFrom(ctx) != provider.IntentBackground { - t.Fatalf("wake call: %+v", messages) - } - if !strings.Contains(messages[0].Content[0].Text, "person's pages") || !strings.Contains(messages[1].Content[0].Text, "Answer format:") { - t.Fatal("wake rules absent") - } - r := textResponse(`{"summary":"Next steps","items":[]}`) - if known { - r.Usage = &ai.Usage{Cost: &usd, PromptTokens: 10, CompletionTokens: 5} - } else { - r.Usage = nil - } - return r, nil - }}} - var lines []UsageLine - answer, err := workstreamWakeCall(context.Background(), client, "cheap/model", WorkstreamWakeAsk{ID: "wake", AgentID: "agent", Event: workstream.WakeEvent{Kind: "morning"}, Snapshot: json.RawMessage(`{}`)}, func(line UsageLine) { lines = append(lines, line) }) - if err != nil || client.requests() != 1 || len(lines) != 1 || answer.Model != "cheap/model" { - t.Fatal(answer, lines, err) - } - if known && (answer.CostUSD == nil || *answer.CostUSD != .04 || lines[0].USD != .04) { - t.Fatal("cost lost") - } - if !known && (answer.CostUSD != nil || !lines[0].Unbilled) { - t.Fatal("unknown cost fabricated") - } - }) - } -} - -func TestWakeB_m2_ModelCallCarriesWakePurpose(t *testing.T) { - checks := 0 - adapter, err := provider.NewClient(provider.Config{ - BaseURL: "http://provider.test", Model: "test/model", APIKey: "test-key", - HTTPClient: fakeHTTP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`)) - })), - RouteGate: func(ctx context.Context, model, tag string) error { - checks++ - if tag != "workstream-wake" { - t.Fatalf("wake purpose: %q", tag) - } - return nil - }, - }) - if err != nil { - t.Fatal(err) - } - client := &scriptedCompleter{steps: []step{func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { - return adapter.CompleteWithMessages(ctx, messages) - }}} - lines := 0 - _, err = workstreamWakeCall(context.Background(), client, "test/model", WorkstreamWakeAsk{ID: "wake", AgentID: "agent", Event: workstream.WakeEvent{Kind: "morning"}, Snapshot: json.RawMessage(`{}`)}, func(UsageLine) { lines++ }) - if err != nil || client.requests() != 1 || checks != 1 || lines != 1 { - t.Fatalf("wake call: requests %d, purpose checks %d, usage lines %d (%v)", client.requests(), checks, lines, err) - } -} - -func TestWakeB_SnapshotUsesReadCutRule(t *testing.T) { - text := workstreamWakeQuestion(WorkstreamWakeAsk{Snapshot: json.RawMessage(`{"words":"` + strings.Repeat("é", 40000) + `"}`)}) - start := strings.Index(text, "Numbers absent below") - end := strings.Index(text, "\nAnswer format:") - if start < 0 || end < start || end-start > 60000 || !utf8.ValidString(text) || !strings.HasSuffix(text[start:end], "\nThe workstream read was cut.") { - t.Fatal("snapshot cut drifted") - } -} diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go index 403d7f026d..fdb8483631 100644 --- a/internal/workspace/schema3_test.go +++ b/internal/workspace/schema3_test.go @@ -8,8 +8,6 @@ import ( "reflect" "strings" "testing" - - "github.com/Agent-Field/codeaf/internal/sqlitefile" ) // Part A (a): this literal is schema 2, independent of the schema being written. @@ -124,12 +122,6 @@ func TestPartASchemaTwoReadsWithoutWritingAndMigratesAllRows(t *testing.T) { if !reflect.DeepEqual(memberTimes, [][2]int64{{2, 4567}, {6, 5678}, {11, 6789}}) { t.Fatalf("membership times changed: %+v", memberTimes) } - for _, name := range []string{"workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans", "workstream_wakes", "workstream_wake_items", "workstream_sources"} { - var n int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n); err != nil || n != 1 { - t.Fatalf("missing %s: %v", name, err) - } - } if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "17", Factory: "factory-a"}); err != nil { t.Fatal(err) } @@ -212,242 +204,3 @@ DELETE FROM memberships WHERE ref_id='gone';`); err != nil { }) } } - -// WP-F1 (b), Storage: schema 3 is amended in place; either missing deletion -// column identifies an obsolete schema-3 file and must fail at Open. -func TestWorkstreamSchemaThreeRequiresDeletionColumns(t *testing.T) { - for _, column := range []string{"deleted_at", "deleted_root"} { - t.Run(column, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "obsolete.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Schema three") - workstreamSchemaThreeFixture(t, s) - if userVersion(t, s) != 3 { - t.Fatal("fixture is not schema 3") - } - if _, err := s.db.Exec("ALTER TABLE workstream_pages DROP COLUMN " + column); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - _ = opened.Close() - } - if err == nil || !strings.Contains(err.Error(), column) { - t.Fatalf("obsolete schema 3 opened without naming %s: %v", column, err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatalf("refusal changed obsolete schema 3: %v", err) - } - }) - } -} - -func workstreamSchemaTwoWriter(t *testing.T, path string) *sql.Conn { - t.Helper() - db, err := sql.Open("sqlite", sqlitefile.URI(path, "_pragma=foreign_keys(1)")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = db.Close() }) - conn, err := db.Conn(context.Background()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = conn.Close() }) - var version, foreignKeys int - if err := conn.QueryRowContext(context.Background(), "PRAGMA user_version").Scan(&version); err != nil || version != 2 { - t.Fatalf("older writer did not open schema 2: %d, %v", version, err) - } - if err := conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil || foreignKeys != 1 { - t.Fatalf("older writer has foreign keys disabled: %d, %v", foreignKeys, err) - } - return conn -} - -func workstreamStoredRows(t *testing.T, db *sql.DB, table string) [][]any { - t.Helper() - rows, err := db.Query("SELECT * FROM " + table + " ORDER BY 1,2") - if err != nil { - t.Fatal(err) - } - defer rows.Close() - columns, err := rows.Columns() - if err != nil { - t.Fatal(err) - } - var out [][]any - for rows.Next() { - values := make([]any, len(columns)) - pointers := make([]any, len(columns)) - for i := range values { - pointers[i] = &values[i] - } - if err := rows.Scan(pointers...); err != nil { - t.Fatal(err) - } - out = append(out, values) - } - if err := rows.Err(); err != nil { - t.Fatal(err) - } - return out -} - -// E-B1-a: a connection opened before migration must not cascade away a -// workstream, even when its writer bypasses the current Store.Delete guard. -func TestStabEB1AnOlderWriterCannotDeleteAWorkstream(t *testing.T) { - ctx := context.Background() - path := writeSchemaTwo(t) - older := workstreamSchemaTwoWriter(t, path) - s := openTestStore(t, path) - seedWorkstream(t, s, "protected", "Protected") - if userVersion(t, s) != schemaVersion { - t.Fatal("workstream creation did not migrate schema 2") - } - // The domain store imports workspace, so this fixture uses its shared SQL - // transaction to create the dependent rows without an import cycle. - if err := s.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES - ('protected','conversation','agent-protected','',''),('protected','factory_job','7','','f'); -INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection) - VALUES ('aaaa','collection','protected','','protected'); -INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) - VALUES ('page','protected','Notes','you',0,2,200,'you','{"type":"doc","content":[]}'); -INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES - ('page',1,100,'you','Notes','{"type":"doc","content":[]}',27), - ('page',2,200,'you','Notes','{"type":"doc","content":[]}',27); -INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) - VALUES ('record','protected','decision','Keep this decision','kept','you',100); -INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) - VALUES ('grant','protected','wave',1,1,200,'active',100); -INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) - VALUES ('protected',1,100,'you','[]','[]'); -`) - return err - }); err != nil { - t.Fatal(err) - } - tables := []struct { - name string - count int - }{ - {"collections", 3}, {"memberships", 6}, {"workstreams", 1}, {"workstream_jobs", 1}, - {"workstream_pages", 1}, {"workstream_page_versions", 2}, {"workstream_records", 1}, - {"workstream_goaheads", 1}, {"workstream_plans", 1}, - } - before := make(map[string][][]any, len(tables)) - for _, table := range tables { - before[table.name] = workstreamStoredRows(t, s.db, table.name) - if len(before[table.name]) != table.count { - t.Fatalf("%s fixture has %d rows, want %d", table.name, len(before[table.name]), table.count) - } - } - if _, err := older.ExecContext(ctx, "DELETE FROM collections WHERE id=?", "protected"); err == nil || !strings.Contains(err.Error(), ErrWorkstreamDelete.Error()) { - t.Fatalf("older writer was not refused with the workstream explanation: %v", err) - } - for _, table := range tables { - if after := workstreamStoredRows(t, s.db, table.name); !reflect.DeepEqual(after, before[table.name]) { - t.Errorf("refused delete changed %s: before %v, after %v", table.name, before[table.name], after) - } - } -} - -// E-B1-b: the SQL guard preserves plain collection deletion and its cascades, -// and Store.Delete still returns the friendly workstream refusal. -func TestStabEB1PlainCollectionsStillDelete(t *testing.T) { - ctx := context.Background() - path := writeSchemaTwo(t) - older := workstreamSchemaTwoWriter(t, path) - s := openTestStore(t, path) - seedWorkstream(t, s, "protected", "Protected") - for _, writer := range []string{"older writer", "store"} { - t.Run(writer, func(t *testing.T) { - plain := createTestCollection(t, s, "Plain "+writer) - if err := s.Add(ctx, plain.ID, Ref{Kind: ConversationKind, ID: "plain-chat"}); err != nil { - t.Fatal(err) - } - if err := s.Add(ctx, "aaaa", Ref{Kind: CollectionKind, ID: plain.ID}); err != nil { - t.Fatal(err) - } - var members int - if err := s.db.QueryRow("SELECT count(*) FROM memberships WHERE collection_id=? OR target_collection=?", plain.ID, plain.ID).Scan(&members); err != nil || members != 2 { - t.Fatalf("plain membership fixture: %d, %v", members, err) - } - if writer == "older writer" { - result, err := older.ExecContext(ctx, "DELETE FROM collections WHERE id=?", plain.ID) - if err != nil { - t.Fatal(err) - } - if n, err := result.RowsAffected(); err != nil || n != 1 { - t.Fatalf("older writer deleted %d collections: %v", n, err) - } - } else if parents, err := s.Delete(ctx, plain.ID); err != nil || !reflect.DeepEqual(parents, []string{"aaaa"}) { - t.Fatalf("plain Store.Delete: %v, %v", parents, err) - } - var collections int - if err := s.db.QueryRow("SELECT count(*) FROM collections WHERE id=?", plain.ID).Scan(&collections); err != nil || collections != 0 { - t.Fatalf("plain collection survived: %d, %v", collections, err) - } - if err := s.db.QueryRow("SELECT count(*) FROM memberships WHERE collection_id=? OR target_collection=?", plain.ID, plain.ID).Scan(&members); err != nil || members != 0 { - t.Fatalf("plain memberships did not cascade: %d, %v", members, err) - } - }) - } - if _, err := s.Delete(ctx, "protected"); err != ErrWorkstreamDelete { - t.Fatalf("Store.Delete lost its friendly workstream refusal: %v", err) - } -} - -// E-B1-c: earlier schema-3 files remain readable without modification and gain -// the database guard in the transaction of their next write. -func TestStabEB1AnEarlierSchemaThreeFileGainsTheGuardOnItsNextWrite(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "earlier.db") - fixture := openTestStore(t, path) - plain := createTestCollection(t, fixture, "Earlier") - workstreamSchemaThreeFixture(t, fixture) - if _, err := fixture.db.Exec("DROP TRIGGER IF EXISTS workstream_collection_keep"); err != nil { - t.Fatal(err) - } - if err := fixture.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - s := openTestStore(t, path) - if userVersion(t, s) != 3 { - t.Fatal("fixture is not schema 3") - } - list, err := s.List(ctx, nil) - if err != nil || len(list) != 1 || list[0].ID != plain.ID { - t.Fatalf("earlier schema-3 read: %+v, %v", list, err) - } - var triggers int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='workstream_collection_keep'").Scan(&triggers); err != nil || triggers != 0 { - t.Fatalf("read installed the guard: %d, %v", triggers, err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatalf("open or read changed earlier schema 3: %v", err) - } - if err := s.Rename(ctx, plain.ID, "Updated"); err != nil { - t.Fatal(err) - } - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='workstream_collection_keep'").Scan(&triggers); err != nil || triggers != 1 { - t.Fatalf("first write did not install the guard: %d, %v", triggers, err) - } - if err := s.Rename(ctx, plain.ID, "Updated again"); err != nil { - t.Fatalf("installing the guard again was not idempotent: %v", err) - } -} diff --git a/internal/workspace/schema4_test.go b/internal/workspace/schema4_test.go deleted file mode 100644 index bad69c4218..0000000000 --- a/internal/workspace/schema4_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -func workstreamSchemaThreeFixture(t *testing.T, s *Store) { - t.Helper() - if _, err := s.db.Exec("DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions; DROP TABLE workstream_sources; DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3"); err != nil { - t.Fatal(err) - } - s.version = 3 -} - -func TestWakeI_SchemaThreeMigratesAdditively(t *testing.T) { - path := filepath.Join(t.TempDir(), "schema.db") - s := openTestStore(t, path) - seedWorkstream(t, s, "w", "Watching") - if _, err := s.db.Exec(` - INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('p','w','Notes','agent',0,1,100,'agent','{}'); - INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('p',1,100,'agent','Notes','{}',2); - INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) VALUES ('r','w','decision','Keep it','kept','agent',100); - INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('w',1,100,'you','[]','[]');`); err != nil { - t.Fatal(err) - } - workstreamSchemaThreeFixture(t, s) - queries := []string{"SELECT * FROM workstreams", "SELECT * FROM workstream_pages", "SELECT * FROM workstream_page_versions", "SELECT * FROM workstream_records", "SELECT * FROM workstream_plans"} - snapshot := func() [][]string { - all := [][]string{} - for _, q := range queries { - rows, err := s.db.Query(q) - if err != nil { - t.Fatal(err) - } - columns, _ := rows.Columns() - for rows.Next() { - cells := make([]sql.NullString, len(columns)) - args := make([]any, len(cells)) - for i := range args { - args[i] = &cells[i] - } - if err := rows.Scan(args...); err != nil { - t.Fatal(err) - } - values := []string{} - for _, cell := range cells { - values = append(values, cell.String) - } - all = append(all, values) - } - rows.Close() - } - return all - } - before := snapshot() - if err := s.Write(context.Background(), func(*sql.Tx) error { return nil }); err != nil { - t.Fatal(err) - } - if userVersion(t, s) != 6 || !reflect.DeepEqual(snapshot(), before) { - t.Fatal("migration lost existing rows") - } - var count int - if err := s.db.QueryRow("SELECT count(*) FROM workstream_wakes").Scan(&count); err != nil || count != 0 { - t.Fatalf("wakes: %d %v", count, err) - } -} - -func TestWakeI_NewerSchemaIsRefusedWithoutWriting(t *testing.T) { - path := filepath.Join(t.TempDir(), "future.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Future") - if _, err := s.db.Exec("PRAGMA user_version=7"); err != nil { - t.Fatal(err) - } - s.Close() - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - opened.Close() - } - if err == nil || !strings.Contains(err.Error(), "version 7") { - t.Fatalf("newer schema: %v", err) - } - after, err := os.ReadFile(path) - if err != nil || string(before) != string(after) { - t.Fatal("newer file changed") - } -} - -func TestWakeI_FailedFirstWriteRollsBackTheMigration(t *testing.T) { - s := openTestStore(t, filepath.Join(t.TempDir(), "rollback.db")) - seedWorkstream(t, s, "w", "Watching") - workstreamSchemaThreeFixture(t, s) - err := s.Write(context.Background(), func(*sql.Tx) error { return ErrInvalid }) - if err != ErrInvalid || userVersion(t, s) != 3 { - t.Fatal("failed write migrated", err) - } - var n int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE name IN ('workstream_wakes','workstream_wake_items')").Scan(&n); err != nil || n != 0 { - t.Fatal("partial migration", n, err) - } -} diff --git a/internal/workspace/schema6_test.go b/internal/workspace/schema6_test.go deleted file mode 100644 index ed237f3212..0000000000 --- a/internal/workspace/schema6_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -// WP-6d Schema: use the actual DDL for each step, without any later tables. -func writeWorkstreamSuggestionPredecessor(t *testing.T, version int) string { - t.Helper() - path := filepath.Join(t.TempDir(), "collections.db") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - defer db.Close() - ddl := schemaTables("collections", "memberships") + schemaIndexes + workstreamSchema - if version >= WorkstreamWakeSchemaVersion { - ddl += workstreamWakeSchema - } - if version >= WorkstreamSourcesSchemaVersion { - ddl += workstreamSourcesSchema - } - if _, err := db.Exec(ddl + fmt.Sprintf(` -PRAGMA application_id=%d; PRAGMA user_version=%d; -INSERT INTO collections(id,name,created_at) VALUES ('existing','Existing',100); -INSERT INTO workstreams(id,agent_id,created_at,updated_at) VALUES ('existing','agent',100,100); -INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('existing','factory_job','7','','f'); -INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('page','existing','Notes','you',0,1,100,'you','{"type":"doc","content":[]}'); -INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('page',1,100,'you','Notes','{"type":"doc","content":[]}',27); -INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) VALUES ('record','existing','decision','Keep','kept','you',100); -INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) VALUES ('grant','existing','wave',1,1,200,'active',100); -INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('existing',1,100,'you','[]','[]'); -`, applicationID, version)); err != nil { - t.Fatal(err) - } - if version >= WorkstreamWakeSchemaVersion { - if _, err := db.Exec(` -INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,cost_usd,started_at,settled_at) VALUES ('wake','existing','morning:2026-11-03','{}','settled','Existing wake',0.25,100,200); -INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,applied_at) VALUES ('item','wake',0,'page','applied','Existing change','{}','{}',200); -`); err != nil { - t.Fatal(err) - } - } - if version >= WorkstreamSourcesSchemaVersion { - if _, err := db.Exec(` -INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,why,read_by_jobs,proposed,added_by,added_at) VALUES ('source','existing','link','https://example.com','Example','https://example.com','Existing context',1,1,'agent',100); -`); err != nil { - t.Fatal(err) - } - } - return path -} - -// WP-6d Schema: opening, reading and a refused first write preserve the file; -// the first committed write upgrades every missing step and keeps all old rows. -func TestWorkstreamSchemaSixIsAnAtomicAdditiveMigration(t *testing.T) { - for _, version := range []int{3, 4, 5} { - t.Run(fmt.Sprint(version), func(t *testing.T) { - path := writeWorkstreamSuggestionPredecessor(t, version) - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - s := openTestStore(t, path) - tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} - if version >= WorkstreamWakeSchemaVersion { - tables = append(tables, "workstream_wakes", "workstream_wake_items") - } - if version >= WorkstreamSourcesSchemaVersion { - tables = append(tables, "workstream_sources") - } - rows := map[string][][]any{} - for _, table := range tables { - rows[table] = workstreamStoredRows(t, s.db, table) - if len(rows[table]) == 0 { - t.Fatalf("fixture has no rows in %s", table) - } - } - if list, err := s.List(context.Background(), nil); err != nil || len(list) != 1 || list[0].ID != "existing" { - t.Fatalf("schema %d list: %+v %v", version, list, err) - } - if err := s.Read(context.Background(), func(_ *sql.Tx, got int) error { - if got != version { - t.Fatalf("read version %d, want %d", got, version) - } - return nil - }); err != nil { - t.Fatal(err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) || userVersion(t, s) != version { - t.Fatal("open or read changed the old file", err) - } - refusal := errors.New("refused write") - if err := s.Write(context.Background(), func(tx *sql.Tx) error { - if _, err := tx.Exec("INSERT INTO collections(id,name) VALUES ('rollback','Rollback')"); err != nil { - return err - } - return refusal - }); !errors.Is(err, refusal) { - t.Fatal(err) - } - after, err = os.ReadFile(path) - if err != nil || string(after) != string(before) || userVersion(t, s) != version { - t.Fatal("refused write changed the old file", err) - } - var n int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE name IN ('workstream_wakes','workstream_wake_items','workstream_sources','workstream_suggestions','workstream_suggestion_edits')").Scan(&n); err != nil || n != len(tables)-9 { - t.Fatal("refused write left migration tables", n, err) - } - if err := s.Write(context.Background(), func(tx *sql.Tx) error { - _, err := tx.Exec(` -INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,created_at) VALUES ('suggestion','existing','page',1,'Clarify','agent',200); -INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) VALUES ('suggestion','e1',0,'[0]',0,'Old','New','Old','Clearer','proposed');`) - return err - }); err != nil { - t.Fatal(err) - } - if userVersion(t, s) != 6 { - t.Fatal("first write did not install schema 6") - } - for _, table := range tables { - if !reflect.DeepEqual(rows[table], workstreamStoredRows(t, s.db, table)) { - t.Errorf("migration changed %s", table) - } - } - for _, table := range []string{"workstream_wakes", "workstream_wake_items", "workstream_sources", "workstream_suggestions", "workstream_suggestion_edits"} { - if _, err := s.db.Exec("SELECT * FROM " + table + " LIMIT 0"); err != nil { - t.Fatal(err) - } - } - }) - } -} - -// WP-6d Schema: blank files write 6; future and incomplete current files refuse -// without repair, including wake and source tables inherited from schemas 4/5. -func TestWorkstreamSchemaSixFreshFutureAndDamaged(t *testing.T) { - for _, damage := range []string{"future", "workstream_suggestions", "workstream_suggestion_edits", "workstream_wakes", "workstream_wake_items", "workstream_sources"} { - t.Run(damage, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "collections.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Fresh") - if userVersion(t, s) != 6 { - t.Fatal("fresh schema is not 6") - } - query := "DROP TABLE " + damage - switch damage { - case "future": - query = "PRAGMA user_version=7" - case "workstream_suggestions": - query = "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions" - case "workstream_wakes": - query = "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes" - } - if _, err := s.db.Exec(query); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - _ = opened.Close() - } - if err == nil || damage == "future" && !strings.Contains(err.Error(), "unsupported collections database") || damage != "future" && !strings.Contains(err.Error(), damage) { - t.Fatal("accepted unsupported file or returned the wrong refusal", err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatal("refusal changed file", err) - } - }) - } -} - -// WP-6d Schema: accepting schemas 4/5 must still verify their wake/source tables. -func TestWorkstreamSuggestionPredecessorsStillRequireTheirTables(t *testing.T) { - for _, version := range []int{4, 5} { - tables := []string{"workstream_wakes", "workstream_wake_items"} - if version >= WorkstreamSourcesSchemaVersion { - tables = append(tables, "workstream_sources") - } - for _, table := range tables { - t.Run(fmt.Sprintf("%d/%s", version, table), func(t *testing.T) { - path := writeWorkstreamSuggestionPredecessor(t, version) - s := openTestStore(t, path) - query := "DROP TABLE " + table - if table == "workstream_wakes" { - query = "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes" - } - if _, err := s.db.Exec(query); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - _ = opened.Close() - } - if err == nil || !strings.Contains(err.Error(), table) { - t.Fatalf("schema %d accepted missing %s: %v", version, table, err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatal("refusal changed predecessor file", err) - } - }) - } - } -} diff --git a/internal/workspace/schema7.go b/internal/workspace/schema7.go new file mode 100644 index 0000000000..404c0d7ef9 --- /dev/null +++ b/internal/workspace/schema7.go @@ -0,0 +1,32 @@ +package workspace + +// schemaSevenCleanup removes tables left by development builds (schemas 3–6), +// including their indexes and triggers. +const schemaSevenCleanup = ` +DROP TRIGGER IF EXISTS workstream_collection_keep; +DROP TRIGGER IF EXISTS workstream_job_promote; +DROP TRIGGER IF EXISTS workstream_job_update; +DROP TRIGGER IF EXISTS workstream_job_insert; +DROP INDEX IF EXISTS workstream_suggestion_page; +DROP INDEX IF EXISTS workstream_sources_order; +DROP INDEX IF EXISTS workstream_wake_item_list; +DROP INDEX IF EXISTS workstream_wake_list; +DROP INDEX IF EXISTS workstream_goahead_list; +DROP INDEX IF EXISTS workstream_record_list; +DROP INDEX IF EXISTS workstream_overview; +DROP INDEX IF EXISTS workstream_page_tree; +DROP INDEX IF EXISTS workstream_job_owner; +DROP INDEX IF EXISTS workstreams_client; +DROP TABLE IF EXISTS workstream_suggestion_edits; +DROP TABLE IF EXISTS workstream_suggestions; +DROP TABLE IF EXISTS workstream_sources; +DROP TABLE IF EXISTS workstream_wake_items; +DROP TABLE IF EXISTS workstream_wakes; +DROP TABLE IF EXISTS workstream_plans; +DROP TABLE IF EXISTS workstream_goaheads; +DROP TABLE IF EXISTS workstream_records; +DROP TABLE IF EXISTS workstream_page_versions; +DROP TABLE IF EXISTS workstream_pages; +DROP TABLE IF EXISTS workstream_jobs; +DROP TABLE IF EXISTS workstreams; +` diff --git a/internal/workspace/schema7_test.go b/internal/workspace/schema7_test.go new file mode 100644 index 0000000000..af5dfed7fa --- /dev/null +++ b/internal/workspace/schema7_test.go @@ -0,0 +1,574 @@ +package workspace + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/sqlitefile" +) + +// Contract (c): frozen SQL from the schema-6 writer and the schema-2 merge base. +// These fixtures deliberately do not call the current schema builders. +const schemaSevenTwoDDL = ` +CREATE TABLE collections ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process')), + ref_id TEXT NOT NULL, + session_id TEXT NOT NULL, + target_collection TEXT REFERENCES collections(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL DEFAULT 0, + CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) + OR (kind!='collection' AND target_collection IS NULL)), + UNIQUE(collection_id,kind,ref_id,session_id) +); + +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_target ON memberships(target_collection); +` + +const schemaSevenCoreDDL = ` +CREATE TABLE collections ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process','factory_job')), + ref_id TEXT NOT NULL, + session_id TEXT NOT NULL, + target_collection TEXT REFERENCES collections(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL DEFAULT 0, + factory TEXT NOT NULL DEFAULT '', + CHECK ((kind='factory_job' AND length(factory)>0) OR (kind!='factory_job' AND factory='')), + CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) + OR (kind!='collection' AND target_collection IS NULL)), + UNIQUE(collection_id,kind,ref_id,session_id,factory) +); + +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id,factory); +CREATE INDEX memberships_target ON memberships(target_collection); + +` + +const schemaSevenThreeDDL = `CREATE TABLE workstreams ( + id TEXT PRIMARY KEY REFERENCES collections(id) ON DELETE CASCADE, + why TEXT NOT NULL DEFAULT '', origin_chat_id TEXT NOT NULL DEFAULT '', agent_id TEXT NOT NULL, + autonomy TEXT NOT NULL DEFAULT 'propose' CHECK(autonomy IN ('propose','small','off')), + wake_morning INTEGER NOT NULL DEFAULT 0, wake_related_issues INTEGER NOT NULL DEFAULT 0, + budget_usd REAL CHECK(budget_usd IS NULL OR budget_usd>0), + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, started_at INTEGER, done_at INTEGER, + how_it_went TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX workstreams_client ON workstreams(client_id) WHERE client_id!=''; +CREATE TABLE workstream_jobs ( + member_seq INTEGER PRIMARY KEY REFERENCES memberships(seq) ON DELETE CASCADE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + factory TEXT NOT NULL, job TEXT NOT NULL +); +CREATE UNIQUE INDEX workstream_job_owner ON workstream_jobs(factory,job); +CREATE TRIGGER workstream_job_insert AFTER INSERT ON memberships + WHEN NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id) + BEGIN INSERT INTO workstream_jobs VALUES(NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id); END; +CREATE TRIGGER workstream_job_update AFTER UPDATE ON memberships + BEGIN + DELETE FROM workstream_jobs WHERE member_seq=OLD.seq; + INSERT INTO workstream_jobs SELECT NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id + WHERE NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id); + END; +CREATE TRIGGER workstream_job_promote AFTER INSERT ON workstreams + BEGIN INSERT INTO workstream_jobs SELECT seq,collection_id,factory,ref_id FROM memberships + WHERE collection_id=NEW.id AND kind='factory_job'; END; +CREATE TABLE workstream_pages ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES workstream_pages(id) ON DELETE CASCADE, + title TEXT NOT NULL, owner TEXT NOT NULL CHECK(owner IN ('you','agent')), + read_by_jobs INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL CHECK(position>=0), + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), updated_from TEXT, + doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)), + deleted_at INTEGER, deleted_root TEXT +); +CREATE INDEX workstream_page_tree ON workstream_pages(workstream_id,parent_id,position); +CREATE UNIQUE INDEX workstream_overview ON workstream_pages(workstream_id) WHERE overview=1; +CREATE TABLE workstream_page_versions ( + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + version INTEGER NOT NULL, saved_at INTEGER NOT NULL, saved_by TEXT NOT NULL CHECK(saved_by IN ('you','agent')), + source TEXT, title TEXT NOT NULL, doc TEXT NOT NULL, bytes INTEGER NOT NULL, + PRIMARY KEY(page_id,version) +); +CREATE TABLE workstream_records ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('decision','question','goahead')), text TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped','superseded')), + actor TEXT NOT NULL CHECK(actor IN ('you','agent')), source TEXT, created_at INTEGER NOT NULL, + decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id), + goahead TEXT, goahead_id TEXT +); +CREATE INDEX workstream_record_list ON workstream_records(workstream_id,created_at); +CREATE TABLE workstream_goaheads ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK(scope IN ('job','wave','milestone')), + factory TEXT, job INTEGER, wave INTEGER, milestone_id TEXT, + cap_usd REAL NOT NULL CHECK(cap_usd>0 AND cap_usd<=10000), ends_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK(state IN ('active','ended')), + end_reason TEXT CHECK(end_reason IN ('expired','revoked','workstream_done')), + granted_at INTEGER NOT NULL, ended_at INTEGER, + CHECK((scope='job' AND factory IS NOT NULL AND length(factory)>0 AND job IS NOT NULL AND job>0 AND wave IS NULL AND milestone_id IS NULL) + OR (scope='wave' AND wave IS NOT NULL AND wave>0 AND factory IS NULL AND job IS NULL AND milestone_id IS NULL) + OR (scope='milestone' AND milestone_id IS NOT NULL AND length(milestone_id)>0 AND factory IS NULL AND job IS NULL AND wave IS NULL)), + CHECK(ends_at>granted_at AND ends_at<=granted_at+7776000000) +); +CREATE INDEX workstream_goahead_list ON workstream_goaheads(workstream_id,state); +CREATE TABLE workstream_plans ( + workstream_id TEXT PRIMARY KEY REFERENCES workstreams(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), + milestones TEXT NOT NULL, waves TEXT NOT NULL +); + +CREATE TRIGGER IF NOT EXISTS workstream_collection_keep BEFORE DELETE ON collections + WHEN EXISTS(SELECT 1 FROM workstreams WHERE id=OLD.id) + BEGIN SELECT RAISE(ABORT,'mark a workstream done instead'); END; + +` + +const schemaSevenFourDDL = `CREATE TABLE workstream_wakes ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + event_key TEXT NOT NULL, event_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('running','settled','failed')), + summary TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', cost_usd REAL, + started_at INTEGER NOT NULL, settled_at INTEGER, dismissed_at INTEGER, + UNIQUE(workstream_id,event_key) +); +CREATE INDEX workstream_wake_list ON workstream_wakes(workstream_id,started_at); +CREATE TABLE workstream_wake_items ( + id TEXT PRIMARY KEY, wake_id TEXT NOT NULL REFERENCES workstream_wakes(id) ON DELETE CASCADE, + position INTEGER NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('move','draft','page','decision','question')), + state TEXT NOT NULL CHECK(state IN ('applied','undone','proposed','dismissed')), + why TEXT NOT NULL DEFAULT '', item_json TEXT NOT NULL, undo_json TEXT, + note TEXT NOT NULL DEFAULT '', applied_at INTEGER, undone_at INTEGER +); +CREATE INDEX workstream_wake_item_list ON workstream_wake_items(wake_id,position); + +` + +const schemaSevenFiveDDL = `CREATE TABLE workstream_sources ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('file','repo','issue','link','kept')), + target TEXT NOT NULL, + title TEXT NOT NULL, + path TEXT, repo TEXT, number INTEGER, url TEXT, ref TEXT, why TEXT, + read_by_jobs INTEGER NOT NULL DEFAULT 0, + proposed INTEGER NOT NULL DEFAULT 0, + added_by TEXT NOT NULL CHECK(added_by IN ('you','agent')), + added_at INTEGER NOT NULL, + UNIQUE(workstream_id,kind,target) +); +CREATE INDEX workstream_sources_order ON workstream_sources(workstream_id,seq); + +` + +const schemaSevenSixDDL = `CREATE TABLE workstream_suggestions ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + base_version INTEGER NOT NULL, why TEXT NOT NULL, actor TEXT NOT NULL, source TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX workstream_suggestion_page ON workstream_suggestions(page_id,created_at); +CREATE TABLE workstream_suggestion_edits ( + suggestion_id TEXT NOT NULL REFERENCES workstream_suggestions(id) ON DELETE CASCADE, + id TEXT NOT NULL, position INTEGER NOT NULL, block TEXT NOT NULL, at_offset INTEGER NOT NULL, + del TEXT NOT NULL, ins TEXT NOT NULL, block_text TEXT NOT NULL, why TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped')), decided_at INTEGER, + PRIMARY KEY(suggestion_id,id) +); +` + +const schemaSevenRows = ` +INSERT INTO collections(seq,id,name,created_at) VALUES + (4,'root','Root',1234),(9,'nested','Nested',2345),(12,'former','Former owner',3456); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) VALUES + (2,'root','conversation','chat','',NULL,4567), + (6,'root','task','3','chat',NULL,5678), + (11,'root','collection','nested','','nested',6789), + (15,'nested','collection','former','','former',7000), + (16,'nested','crew','crew','',NULL,7100), + (17,'nested','process','chat:p1','chat',NULL,7200), + (18,'former','conversation','agent','',NULL,7300), + (19,'nested','artifact','/tmp/report.md','',NULL,7400), + (20,'nested','standing','retired','',NULL,7500); +INSERT INTO collections(seq,id,name) VALUES (100,'gone','Deleted'); +DELETE FROM collections WHERE id='gone'; +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id) VALUES (200,'root','conversation','gone',''); +DELETE FROM memberships WHERE ref_id='gone'; +` + +const schemaSevenThreeRows = ` +INSERT INTO workstreams(id,agent_id,created_at,updated_at,client_id) VALUES ('former','agent',3456,4000,'client'); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,added_at,factory) VALUES + (21,'former','factory_job','7','',7600,'factory-a'),(22,'root','factory_job','7','',7700,'factory-a'); +INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc,overview) + VALUES ('page','former','Overview','you',0,1,4000,'you','{}',1); +INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) + VALUES ('page',1,4000,'you','Overview','{}',2); +INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) + VALUES ('record','former','decision','Keep','kept','you',4000); +INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) + VALUES ('grant','former','wave',1,1,5000,'active',4000); +INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) + VALUES ('former',1,4000,'you','[]','[]'); +` +const schemaSevenFourRows = ` +INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,started_at) + VALUES ('wake','former','morning:2026-11-03','{}','settled',4000); +INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,item_json) + VALUES ('item','wake',0,'page','applied','{}'); +` +const schemaSevenFiveRows = ` +INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) + VALUES ('source','former','link','https://example.com','Example','you',4000); +` +const schemaSevenSixRows = ` +INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,created_at) + VALUES ('suggestion','former','page',1,'Clarify','agent',4000); +INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) + VALUES ('suggestion','edit',0,'[0]',0,'Old','New','Old','Clearer','proposed'); +` + +func schemaSevenFixture(t *testing.T, version int) string { + t.Helper() + ddl, rows := schemaSevenTwoDDL, schemaSevenRows + if version >= 3 { + ddl = schemaSevenCoreDDL + schemaSevenThreeDDL + rows += schemaSevenThreeRows + } + if version >= 4 { + ddl += schemaSevenFourDDL + rows += schemaSevenFourRows + } + if version >= 5 { + ddl += schemaSevenFiveDDL + rows += schemaSevenFiveRows + } + if version >= 6 { + ddl += schemaSevenSixDDL + rows += schemaSevenSixRows + } + path := filepath.Join(t.TempDir(), "collections.db") + db, err := sql.Open("sqlite", sqlitefile.URI(path, "_pragma=foreign_keys(1)")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(ddl + rows + fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d;", applicationID, version)); err != nil { + t.Fatal(err) + } + return path +} + +func schemaSevenFile(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return b +} + +func schemaSevenQuery(t *testing.T, db *sql.DB, query string) [][]any { + t.Helper() + rows, err := db.Query(query) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + t.Fatal(err) + } + var out [][]any + for rows.Next() { + values, pointers := make([]any, len(columns)), make([]any, len(columns)) + for i := range values { + pointers[i] = &values[i] + } + if err := rows.Scan(pointers...); err != nil { + t.Fatal(err) + } + out = append(out, values) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func schemaSevenSchema(t *testing.T, s *Store) [][]any { + t.Helper() + schema := schemaSevenQuery(t, s.db, "SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name") + // ALTER TABLE quotes renamed table identifiers; the logical shape is identical. + for _, row := range schema { + if text, ok := row[3].(string); ok { + row[3] = strings.ReplaceAll(text, `"`, "") + } + } + return schema +} + +func schemaSevenSequences(t *testing.T, s *Store) [][]any { + t.Helper() + return schemaSevenQuery(t, s.db, "SELECT name,seq FROM sqlite_sequence WHERE name IN ('collections','memberships') ORDER BY name") +} + +// Contract (c), G2: every supported predecessor reads without writing, migrates +// on the requested successful write, preserves every row and cursor high-water +// mark, permits former-owner deletion, and performs no DDL on subsequent writes. +func TestSchemaSevenMigratesAndPreservesGenericData(t *testing.T) { + for _, version := range []int{2, 3, 4, 5, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + ctx := context.Background() + path := schemaSevenFixture(t, version) + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + wantCollections := schemaSevenQuery(t, s.db, "SELECT seq,id,name,created_at FROM collections ORDER BY seq") + projection := "seq,collection_id,kind,ref_id,session_id,target_collection,added_at,''" + if version >= 3 { + projection = "seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory" + } + wantMembers := schemaSevenQuery(t, s.db, "SELECT "+projection+" FROM memberships ORDER BY seq") + wantSequences := schemaSevenSequences(t, s) + if !reflect.DeepEqual(wantSequences, [][]any{{"collections", int64(100)}, {"memberships", int64(200)}}) { + t.Fatalf("fixture lacks deleted-row gaps: %v", wantSequences) + } + if version == 6 { + // Every frozen legacy table contains data; cleanup is exercised with foreign keys on. + for _, row := range schemaSevenQuery(t, s.db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('collections','memberships','sqlite_sequence')") { + count := schemaSevenQuery(t, s.db, "SELECT count(*) FROM "+row[0].(string)) + if count[0][0].(int64) == 0 { + t.Fatalf("empty fixture table: %s", row[0]) + } + } + } + collections, err := s.Collections(ctx) + if err != nil || len(collections) != 3 { + t.Fatalf("collections: %v, %v", collections, err) + } + summaries, err := s.List(ctx, nil) + if err != nil || len(summaries) != 3 || summaries[0].CreatedAt != 1234 { + t.Fatalf("summaries: %+v, %v", summaries, err) + } + wantRefs := map[string][]Ref{} + wantTrees := map[string]Tree{} + for _, c := range collections { + refs, err := s.Members(ctx, c.ID) + if err != nil { + t.Fatal(err) + } + wantRefs[c.ID] = refs + tree, err := s.Tree(ctx, c.ID, 3, 200, "") + if err != nil { + t.Fatal(err) + } + wantTrees[c.ID] = tree + expected := []Ref{} + for _, row := range wantMembers { + if row[1] == c.ID { + expected = append(expected, Ref{Kind: Kind(row[2].(string)), ID: row[3].(string), SessionID: row[4].(string), Factory: row[7].(string)}) + } + } + if !reflect.DeepEqual(refs, expected) { + t.Fatalf("read refs: %v, want %v", refs, expected) + } + } + if version >= 3 { + parents, err := s.CollectionsFor(ctx, Ref{Kind: FactoryJobKind, ID: "7", Factory: "factory-a"}) + if err != nil || !reflect.DeepEqual(parents, []Collection{{ID: "root", Name: "Root"}, {ID: "former", Name: "Former owner"}}) { + t.Fatalf("shared job: %v, %v", parents, err) + } + } + if userVersion(t, s) != version || !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("open/read modified predecessor") + } + // Renaming to the existing name is still a successful public write and + // lets us compare every original row without editing its expected values. + if err := s.Rename(ctx, "root", "Root"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 7 { + t.Fatal("first write did not install schema 7") + } + if got := schemaSevenQuery(t, s.db, "SELECT seq,id,name,created_at FROM collections ORDER BY seq"); !reflect.DeepEqual(got, wantCollections) { + t.Fatalf("collections changed: %v", got) + } + if got := schemaSevenQuery(t, s.db, "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships ORDER BY seq"); !reflect.DeepEqual(got, wantMembers) { + t.Fatalf("memberships changed: %v", got) + } + if got := schemaSevenSequences(t, s); !reflect.DeepEqual(got, wantSequences) { + t.Fatalf("high-water marks changed: %v", got) + } + if got, err := s.List(ctx, nil); err != nil || !reflect.DeepEqual(got, summaries) { + t.Fatalf("summaries changed: %v, %v", got, err) + } + for _, c := range collections { + if got, err := s.Members(ctx, c.ID); err != nil || !reflect.DeepEqual(got, wantRefs[c.ID]) { + t.Fatalf("refs changed: %v, %v", got, err) + } + if got, err := s.Tree(ctx, c.ID, 3, 200, ""); err != nil || !reflect.DeepEqual(got, wantTrees[c.ID]) { + t.Fatalf("tree changed: %v, %v", got, err) + } + } + fresh := openTestStore(t, filepath.Join(t.TempDir(), "fresh.db")) + if err := fresh.ensureSchema(ctx); err != nil { + t.Fatal(err) + } + if userVersion(t, fresh) != 7 { + t.Fatal("blank file did not initialize as schema 7") + } + if got, want := schemaSevenSchema(t, s), schemaSevenSchema(t, fresh); !reflect.DeepEqual(got, want) { + t.Fatalf("migration left legacy objects or wrong schema: %v; want %v", got, want) + } + schema := schemaSevenSchema(t, s) + var schemaCookie int + if err := s.db.QueryRow("PRAGMA schema_version").Scan(&schemaCookie); err != nil { + t.Fatal(err) + } + if err := s.Rename(ctx, "root", "Root again"); err != nil { + t.Fatal(err) + } + var afterCookie int + if err := s.db.QueryRow("PRAGMA schema_version").Scan(&afterCookie); err != nil { + t.Fatal(err) + } + if got := schemaSevenSchema(t, s); !reflect.DeepEqual(got, schema) || afterCookie != schemaCookie { + t.Fatal("second write ran DDL") + } + // IF EXISTS makes the predecessor cleanup safe when repeated or partially absent. + if _, err := s.db.Exec(schemaSevenCleanup); err != nil { + t.Fatal(err) + } + if got := schemaSevenSchema(t, s); !reflect.DeepEqual(got, schema) { + t.Fatal("repeated cleanup changed schema") + } + if _, err := s.Delete(ctx, "former"); err != nil { + t.Fatalf("former owner is not deletable: %v", err) + } + c, err := s.Create(ctx, "After") + if err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, Ref{Kind: FactoryJobKind, ID: "7", Factory: "factory-a"}); err != nil { + t.Fatal(err) + } + var collectionSeq, memberSeq int + if err := s.db.QueryRow("SELECT seq FROM collections WHERE id=?", c.ID).Scan(&collectionSeq); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT seq FROM memberships WHERE collection_id=?", c.ID).Scan(&memberSeq); err != nil { + t.Fatal(err) + } + if collectionSeq <= 100 || memberSeq <= 200 { + t.Fatalf("reused retired sequences: %d, %d", collectionSeq, memberSeq) + } + }) + } +} + +// Contract (c): a failed requested write rolls back DDL, data and version bytes. +func TestSchemaSevenFailedFirstWriteLeavesPredecessorUntouched(t *testing.T) { + for _, version := range []int{2, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := schemaSevenFixture(t, version) + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + schema := schemaSevenSchema(t, s) + if err := s.Rename(context.Background(), "root", "Nested"); err != ErrNameTaken { + t.Fatalf("expected name conflict: %v", err) + } + if userVersion(t, s) != version || !reflect.DeepEqual(schemaSevenFile(t, path), before) || !reflect.DeepEqual(schemaSevenSchema(t, s), schema) { + t.Fatal("failed write migrated predecessor") + } + }) + } +} + +// Contract (c): future versions and foreign application IDs are refused without +// modifying the file, including a file that changed after this handle opened. +func TestSchemaSevenRefusesUnknownDatabases(t *testing.T) { + for _, pragma := range []string{"PRAGMA user_version=8", "PRAGMA application_id=12345"} { + t.Run(pragma, func(t *testing.T) { + path := schemaSevenFixture(t, 6) + s := openTestStore(t, path) + if _, err := s.db.Exec(pragma); err != nil { + t.Fatal(err) + } + before := schemaSevenFile(t, path) + opened, err := Open(path) + if opened != nil { + opened.Close() + } + if err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("open accepted unknown database: %v", err) + } + if _, err := s.Collections(context.Background()); err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("read accepted unknown database: %v", err) + } + if err := s.Rename(context.Background(), "root", "Changed"); err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("write accepted unknown database: %v", err) + } + if !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("refusal changed file") + } + }) + } +} + +// Contract (c): schemas 3–6 are recognized by the generic tables alone. +func TestSchemaSevenReadsCleanPredecessorShapes(t *testing.T) { + for _, version := range []int{3, 4, 5, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "clean.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(schemaSevenCoreDDL + schemaSevenRows + fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d;", applicationID, version)); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + if got, err := s.Members(context.Background(), "root"); err != nil || len(got) != 3 { + t.Fatalf("clean predecessor: %v, %v", got, err) + } + if !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("read changed clean predecessor") + } + if err := s.Rename(context.Background(), "root", "Root"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 7 { + t.Fatal("clean predecessor not upgraded") + } + }) + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index 5d2532d03c..5c28fe4372 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -23,7 +23,7 @@ import ( // schemaVersion is the schema this build writes. Earlier schemas remain // readable without migration; the first successful write upgrades them. const ( - schemaVersion = WorkstreamSuggestionSchemaVersion + schemaVersion = 7 legacySchemaVersion = 1 ) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. @@ -37,36 +37,22 @@ const busyTimeout = 10 * time.Second type schemaState uint8 const ( - schemaBlank schemaState = iota - schemaReady - schemaLegacy - schemaTwo - schemaThree - schemaFour - schemaFive - schemaRefused + schemaBlank schemaState = 0 + schemaLegacy schemaState = 1 + schemaTwo schemaState = 2 + schemaReady schemaState = schemaVersion + schemaRefused schemaState = schemaVersion + 1 ) -// version is the schema a state reads through: 0 for a blank file. +// version is the schema a state reads through: 0 for a blank or refused file. func (s schemaState) version() int { - switch s { - case schemaReady: - return schemaVersion - case schemaTwo: - return 2 - case schemaThree: - return 3 - case schemaFour: - return WorkstreamWakeSchemaVersion - case schemaFive: - return WorkstreamSourcesSchemaVersion - case schemaLegacy: - return legacySchemaVersion + if s < schemaRefused { + return int(s) } return 0 } -// Store owns collection and workstream metadata. Transcripts, task checkpoints, standing +// Store owns collection metadata only. Transcripts, task checkpoints, standing // items and artifacts retain their existing owners and persistence formats. // // THE SCHEMA IS BUILT BY THE FIRST WRITE THAT NEEDS IT, never by Open. Opening @@ -183,65 +169,24 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := q.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return schemaBlank, err } - if app == applicationID && (version == schemaVersion || version == WorkstreamSourcesSchemaVersion || version == WorkstreamWakeSchemaVersion || version == 3 || version == 2 || version == legacySchemaVersion) { - state := schemaReady + if app == applicationID && version >= legacySchemaVersion && version <= schemaVersion { + state := schemaState(version) collections := "SELECT seq,id,name,created_at FROM collections LIMIT 0" memberships := "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at FROM memberships LIMIT 0" - if version == 2 { - state = schemaTwo - } - if version == 3 { - state = schemaThree - } - if version == WorkstreamWakeSchemaVersion { - state = schemaFour - } - if version == WorkstreamSourcesSchemaVersion { - state = schemaFive - } if version >= 3 { memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships LIMIT 0" } if version == legacySchemaVersion { - state = schemaLegacy collections = "SELECT seq,id,name FROM collections LIMIT 0" memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection FROM memberships LIMIT 0" } - // Preparing the actual reads catches missing tables before reporting a - // successful open. We never recreate a damaged initialized schema. + // Only the generic tables define a supported file's readable shape. if _, err := q.ExecContext(ctx, collections); err != nil { return state, err } if _, err := q.ExecContext(ctx, memberships); err != nil { return state, err } - if version >= 3 { - for _, query := range workstreamSchemaReads { - if _, err := q.ExecContext(ctx, query); err != nil { - return state, err - } - } - } - if version >= WorkstreamWakeSchemaVersion { - if _, err := q.ExecContext(ctx, "SELECT id,workstream_id,event_key,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at FROM workstream_wakes LIMIT 0"); err != nil { - return state, err - } - if _, err := q.ExecContext(ctx, "SELECT id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at FROM workstream_wake_items LIMIT 0"); err != nil { - return state, err - } - } - if version >= WorkstreamSourcesSchemaVersion { - if _, err := q.ExecContext(ctx, workstreamSourcesRead); err != nil { - return state, err - } - } - if version >= WorkstreamSuggestionSchemaVersion { - for _, query := range workstreamSuggestionSchemaReads { - if _, err := q.ExecContext(ctx, query); err != nil { - return state, err - } - } - } return state, nil } if app != 0 || version != 0 { @@ -292,8 +237,8 @@ CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id,factory CREATE INDEX memberships_target ON memberships(target_collection); ` -// migrateLegacy rebuilds a schema-1 or schema-2 file as schema 3 inside the caller's -// immediate transaction: either every row arrives in the new tables, in the +// migrateLegacy rebuilds a schema-1 or schema-2 file in the current generic +// shape inside the caller's immediate transaction: every row arrives in the // same order under the same sequence numbers, or the file is left exactly as // it was. A row from schema 1 says nothing of when it was made, so its // created_at and added_at are 0, which every reader takes as unknown. @@ -325,7 +270,7 @@ DROP TABLE memberships; DROP TABLE collections; ALTER TABLE collections_v3 RENAME TO collections; ALTER TABLE memberships_v3 RENAME TO memberships; -`, created, added)+schemaIndexes+workstreamSchema) +`, created, added)+schemaIndexes) return err } @@ -347,44 +292,21 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { } state, err := inspectSchema(ctx, tx) if err == nil { - switch state { - case schemaReady, schemaFive: - _, err = tx.ExecContext(ctx, workstreamCollectionKeep) - case schemaFour: - _, err = tx.ExecContext(ctx, workstreamCollectionKeep) - if err == nil { - err = migrateWorkstreamSources(ctx, tx) - } - case schemaThree: - err = migrateWorkstreamWakes(ctx, tx) - if err == nil { - err = migrateWorkstreamSources(ctx, tx) - } - case schemaLegacy, schemaTwo: + switch { + case state == schemaReady: + // A current file needs no DDL. + case state == schemaLegacy || state == schemaTwo: err = migrateLegacy(ctx, tx, state.version()) - if err == nil { - err = migrateWorkstreamWakes(ctx, tx) - } - if err == nil { - err = migrateWorkstreamSources(ctx, tx) - } - case schemaBlank: + case state >= 3 && state < schemaReady: + _, err = tx.ExecContext(ctx, schemaSevenCleanup) + case state == schemaBlank: if !create { err = ErrNotFound } else { - _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes+workstreamSchema) - if err == nil { - err = migrateWorkstreamWakes(ctx, tx) - } - if err == nil { - err = migrateWorkstreamSources(ctx, tx) - } + _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes) } } } - if err == nil && state != schemaReady { - _, err = tx.ExecContext(ctx, workstreamSuggestionSchema) - } if err == nil && state != schemaReady { _, err = tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)) } @@ -397,7 +319,39 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { // ensureSchema is used by bulk fixtures that initialize without adding rows. func (s *Store) ensureSchema(ctx context.Context) error { - return s.Write(ctx, func(*sql.Tx) error { return nil }) + tx, err := s.beginWrite(ctx, true) + if err != nil { + return err + } + defer tx.Rollback() + return storeError(tx.Commit()) +} + +// Read runs fn against a consistent read-only snapshot. Version 0 means blank. +// Reads never initialize or migrate the database. +func (s *Store) Read(ctx context.Context, fn func(tx *sql.Tx, version int) error) error { + tx, version, err := s.beginRead(ctx) + if err != nil { + return err + } + defer tx.Rollback() + return storeError(fn(tx, version)) +} + +// beginRead keeps the schema decision and projected rows in one snapshot. A +// peer may migrate between separate transactions, adding fields an old projection +// cannot supply (notably factory on a new job ref). +func (s *Store) beginRead(ctx context.Context) (*sql.Tx, int, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, 0, storeError(err) + } + state, err := inspectSchema(ctx, tx) + if err != nil { + _ = tx.Rollback() + return nil, 0, storeError(err) + } + return tx, state.version(), nil } // readyForRead answers the schema a read goes through, and 0 for a blank @@ -490,8 +444,11 @@ func (s *Store) Create(ctx context.Context, name string) (Collection, error) { return Collection{}, storeError(err) } defer tx.Rollback() + if err := nameTaken(ctx, tx, name, ""); err != nil { + return Collection{}, storeError(err) + } c := Collection{ID: id, Name: name} - if err := CreateIn(ctx, tx, id, name, s.millis()); err != nil { + if _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", c.ID, c.Name, s.millis()); err != nil { return Collection{}, storeError(err) } if err := tx.Commit(); err != nil { @@ -509,10 +466,13 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { return storeError(err) } defer tx.Rollback() - if err := RenameIn(ctx, tx, id, name); err != nil { + if err := requireCollection(ctx, tx, id); err != nil { + return storeError(err) + } + if err := nameTaken(ctx, tx, name, id); err != nil { return storeError(err) } - if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { + if _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id); err != nil { return storeError(err) } return storeError(tx.Commit()) @@ -531,13 +491,6 @@ func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { if err := requireCollection(ctx, tx, id); err != nil { return nil, storeError(err) } - var isWorkstream bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstreams WHERE id=?)", id).Scan(&isWorkstream); err != nil { - return nil, storeError(err) - } - if isWorkstream { - return nil, ErrWorkstreamDelete - } rows, err := tx.QueryContext(ctx, `SELECT c.id FROM collections c WHERE c.id!=? AND EXISTS( SELECT 1 FROM memberships m WHERE m.collection_id=c.id AND m.kind='collection' AND m.ref_id=?) ORDER BY c.seq`, id, id) if err != nil { @@ -555,11 +508,6 @@ func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { if err := rows.Close(); err != nil { return nil, storeError(err) } - for _, parent := range parents { - if err := touchWorkstream(ctx, tx, parent, s.millis()); err != nil { - return nil, storeError(err) - } - } if _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", id); err != nil { return nil, storeError(err) } @@ -610,7 +558,7 @@ func (s *Store) Collections(ctx context.Context) ([]Collection, error) { if version == 0 { return nil } - rows, err := tx.QueryContext(ctx, "SELECT c.id,c.name,"+workstreamColumn(version)+" FROM collections c ORDER BY seq") + rows, err := tx.QueryContext(ctx, "SELECT c.id,c.name FROM collections c ORDER BY seq") if err != nil { return err } @@ -628,7 +576,7 @@ func readCollections(rows *sql.Rows) ([]Collection, error) { result := make([]Collection, 0) for rows.Next() { var c Collection - if err := rows.Scan(&c.ID, &c.Name, &c.Workstream); err != nil { + if err := rows.Scan(&c.ID, &c.Name); err != nil { return nil, err } result = append(result, c) @@ -676,31 +624,11 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb return nil, nil, storeError(err) } defer tx.Rollback() - added, already, err := AddRefsIn(ctx, tx, id, refs, s.millis()) - if err != nil { - return nil, nil, storeError(err) - } - if err := tx.Commit(); err != nil { - return nil, nil, storeError(err) - } - return added, already, nil -} - -// AddRefsIn applies collection rules inside a transaction obtained from Write. -// It is shared with workstream creation so memberships and metadata commit together. -func AddRefsIn(ctx context.Context, tx *sql.Tx, id string, refs []Addition, now int64) ([]Member, []Ref, error) { - for _, ref := range refs { - if err := ref.Validate(); err != nil { - return nil, nil, err - } - if ref.AddedAt < 0 { - return nil, nil, ErrInvalid - } - } if err := requireCollection(ctx, tx, id); err != nil { return nil, nil, storeError(err) } + now := s.millis() added, already := make([]Member, 0, len(refs)), make([]Ref, 0) for _, ref := range refs { var target any @@ -710,9 +638,6 @@ func AddRefsIn(ctx context.Context, tx *sql.Tx, id string, refs []Addition, now } target = ref.ID } - if err := checkWorkstreamJob(ctx, tx, id, ref.Ref); err != nil { - return nil, nil, err - } at := ref.AddedAt if at == 0 { at = now @@ -732,10 +657,8 @@ func AddRefsIn(ctx context.Context, tx *sql.Tx, id string, refs []Addition, now added = append(added, Member{Ref: ref.Ref, AddedAt: at}) } } - if len(added) > 0 { - if err := touchWorkstream(ctx, tx, id, now); err != nil { - return nil, nil, err - } + if err := tx.Commit(); err != nil { + return nil, nil, storeError(err) } return added, already, nil } @@ -808,9 +731,6 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo if _, err := tx.ExecContext(ctx, "DELETE FROM memberships WHERE seq=?", seq); err != nil { return Member{}, false, storeError(err) } - if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { - return Member{}, false, err - } if err := tx.Commit(); err != nil { return Member{}, false, storeError(err) } @@ -818,9 +738,6 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo } else if !errors.Is(err, sql.ErrNoRows) { return Member{}, false, storeError(err) } - if err := checkWorkstreamJob(ctx, tx, id, to); err != nil { - return Member{}, false, err - } var target any if to.Kind == CollectionKind { if err := nestable(ctx, tx, id, to.ID); err != nil { @@ -832,9 +749,6 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo to.Kind, to.ID, to.SessionID, target, to.Factory, seq); err != nil { return Member{}, false, storeError(err) } - if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { - return Member{}, false, err - } if err := tx.Commit(); err != nil { return Member{}, false, storeError(err) } @@ -877,11 +791,6 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member } removed = append(removed, Member{Ref: ref, AddedAt: at}) } - if len(removed) > 0 { - if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { - return nil, err - } - } if err := tx.Commit(); err != nil { return nil, storeError(err) } @@ -926,7 +835,7 @@ func (s *Store) CollectionsFor(ctx context.Context, ref Ref) ([]Collection, erro if version == 0 { return nil } - rows, err := tx.QueryContext(ctx, `SELECT c.id,c.name,`+workstreamColumn(version)+` FROM collections c JOIN memberships m ON m.collection_id=c.id + rows, err := tx.QueryContext(ctx, `SELECT c.id,c.name FROM collections c JOIN memberships m ON m.collection_id=c.id WHERE m.kind=? AND m.ref_id=? AND m.session_id=? AND `+factoryColumn(version)+`=? ORDER BY c.seq`, ref.Kind, ref.ID, ref.SessionID, ref.Factory) if err != nil { return err @@ -1023,3 +932,10 @@ func pathReason(err error) error { } return err } + +func factoryColumn(version int) string { + if version >= 3 { + return "factory" + } + return "''" +} diff --git a/internal/workspace/views.go b/internal/workspace/views.go index 3c8e78b4a3..3ca1c469c8 100644 --- a/internal/workspace/views.go +++ b/internal/workspace/views.go @@ -224,8 +224,8 @@ func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only st rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT c.seq,c.id,c.name,%s, (SELECT count(*) FROM memberships m WHERE m.collection_id=c.id), (SELECT coalesce(max(%s),0) FROM memberships m WHERE m.collection_id=c.id), - %s, %s -FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, workstreamColumn(version), clause), args...) + %s +FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, clause), args...) if err != nil { return nil, err } @@ -234,7 +234,7 @@ FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, workstreamC for rows.Next() { var c Summary var h bool - if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h, &c.Workstream); err != nil { + if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h); err != nil { return nil, err } if holds != nil { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 4e2dde954d..0414537657 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -62,9 +62,8 @@ type Ref struct { } type Collection struct { - ID string `json:"id"` - Name string `json:"name"` - Workstream bool `json:"workstream,omitempty"` + ID string `json:"id"` + Name string `json:"name"` } // Member is a reference as one collection holds it, with when it was added in diff --git a/internal/workspace/workstream_sources.go b/internal/workspace/workstream_sources.go deleted file mode 100644 index eef802eb1e..0000000000 --- a/internal/workspace/workstream_sources.go +++ /dev/null @@ -1,36 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" -) - -// WorkstreamSourcesSchemaVersion is the first schema with source pointers. -const WorkstreamSourcesSchemaVersion = 5 - -// Sources are pointers with their own lifetime, separate from memberships. -const workstreamSourcesSchema = ` -CREATE TABLE workstream_sources ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE, - workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK(kind IN ('file','repo','issue','link','kept')), - target TEXT NOT NULL, - title TEXT NOT NULL, - path TEXT, repo TEXT, number INTEGER, url TEXT, ref TEXT, why TEXT, - read_by_jobs INTEGER NOT NULL DEFAULT 0, - proposed INTEGER NOT NULL DEFAULT 0, - added_by TEXT NOT NULL CHECK(added_by IN ('you','agent')), - added_at INTEGER NOT NULL, - UNIQUE(workstream_id,kind,target) -); -CREATE INDEX workstream_sources_order ON workstream_sources(workstream_id,seq); -` - -const workstreamSourcesRead = "SELECT seq,id,workstream_id,kind,target,title,path,repo,number,url,ref,why,read_by_jobs,proposed,added_by,added_at FROM workstream_sources LIMIT 0" - -// migrateWorkstreamSources is additive so existing rows and schema text stay intact. -func migrateWorkstreamSources(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, workstreamSourcesSchema) - return err -} diff --git a/internal/workspace/workstream_sources_test.go b/internal/workspace/workstream_sources_test.go deleted file mode 100644 index 9ec77d623f..0000000000 --- a/internal/workspace/workstream_sources_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -func workstreamSchemaFourFixture(t *testing.T, s *Store) { - t.Helper() - if _, err := s.db.Exec("DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions; DROP TABLE workstream_sources; PRAGMA user_version=4"); err != nil { - t.Fatal(err) - } - s.version = 4 -} - -// WP-12 (i): every existing table and value survives the additive migration; -// failed first writes and all reads leave schemas three and four unchanged. -func TestWorkstreamSourcesSchemaFiveMigration(t *testing.T) { - t.Run("schema three", func(t *testing.T) { workstreamSourcesMigration(t, 3) }) - t.Run("schema four", func(t *testing.T) { workstreamSourcesMigration(t, 4) }) -} - -func workstreamSourcesMigration(t *testing.T, version int) { - t.Helper() - ctx := context.Background() - path := filepath.Join(t.TempDir(), "sources.db") - s := openTestStore(t, path) - seedWorkstream(t, s, "stream", "Sources") - if err := s.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.Exec(` -INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('stream','factory_job','7','','f'); -INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('page','stream','Overview','you',0,1,100,'you','{"type":"doc","content":[]}'); -INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('page',1,100,'you','Overview','{"type":"doc","content":[]}',27); -INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) VALUES ('grant','stream','wave',1,1,200,'active',100); -INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at,goahead,goahead_id) VALUES ('record','stream','goahead','Keep this','kept','agent',100,'{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"1970-01-01T00:00:00.2Z"}','grant'); -INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('stream',1,100,'you','[]','[]');`) - return err - }); err != nil { - t.Fatal(err) - } - tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} - if version == 4 { - if _, err := s.db.Exec(` -INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at) - VALUES ('wake','stream','morning:2026-11-03','{"kind":"morning","slot":"2026-11-03"}','settled','Review this.','',0.04,100,200,300); -INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at) - VALUES ('wake-item','wake',0,'decision','undone','Keep the context.','{"kind":"decision","text":"Review this."}','{"record_id":"record"}','Changed since.',150,250);`); err != nil { - t.Fatal(err) - } - workstreamSchemaFourFixture(t, s) - tables = append(tables, "workstream_wakes", "workstream_wake_items") - } else { - workstreamSchemaThreeFixture(t, s) - } - before := map[string][][]any{} - for _, table := range tables { - before[table] = workstreamStoredRows(t, s.db, table) - if len(before[table]) == 0 { - t.Fatalf("empty migration fixture: %s", table) - } - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - bytesBefore, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - s = openTestStore(t, path) - if _, err := s.Members(ctx, "stream"); err != nil { - t.Fatal(err) - } - if err := s.Read(ctx, func(_ *sql.Tx, gotVersion int) error { - if gotVersion != version { - t.Fatalf("read schema %d, want %d", gotVersion, version) - } - return nil - }); err != nil { - t.Fatal(err) - } - refused := errors.New("first write refused") - if err := s.Write(ctx, func(tx *sql.Tx) error { - if _, err := tx.Exec("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) VALUES ('rollback','stream','link','https://example.com','Example','you',100)"); err != nil { - return err - } - return refused - }); !errors.Is(err, refused) { - t.Fatal(err) - } - bytesAfter, err := os.ReadFile(path) - if err != nil || string(bytesAfter) != string(bytesBefore) || userVersion(t, s) != version { - t.Fatalf("read/refusal changed schema %d: %v", version, err) - } - if err := s.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.Exec("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) VALUES ('source','stream','link','https://example.com','Example','you',100)") - return err - }); err != nil { - t.Fatal(err) - } - if userVersion(t, s) != 6 { - t.Fatal("first write did not migrate") - } - for _, table := range tables { - if got := workstreamStoredRows(t, s.db, table); !reflect.DeepEqual(got, before[table]) { - t.Fatalf("migration changed %s: %v", table, got) - } - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - opened := openTestStore(t, path) - if userVersion(t, opened) != 6 { - t.Fatal("current schema did not reopen") - } -} - -// WP-12 (i): missing sources in schema five and future versions are refused. -func TestWorkstreamSourcesSchemaFiveRequiresItsTable(t *testing.T) { - for _, future := range []bool{false, true} { - path := filepath.Join(t.TempDir(), "invalid.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Sources") - query := "DROP TABLE workstream_sources" - if future { - query = "PRAGMA user_version=7" - } - if _, err := s.db.Exec(query); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - opened.Close() - } - if err == nil || future && !strings.Contains(err.Error(), "unsupported collections database") || !future && !strings.Contains(err.Error(), "workstream_sources") { - t.Fatalf("invalid schema opened: %v", err) - } - after, err := os.ReadFile(path) - if err != nil || string(before) != string(after) { - t.Fatalf("refusal changed invalid schema: %v", err) - } - } -} diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go deleted file mode 100644 index 63b411a9b2..0000000000 --- a/internal/workspace/workstreams.go +++ /dev/null @@ -1,298 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strconv" - "time" -) - -// These bounds are shared by SQLite constraints and the domain validator. -const ( - WorkstreamGoAheadCapMax = 10000 - WorkstreamGoAheadDurationMax = 90 * 24 * time.Hour -) - -var ErrWorkstreamDelete = errors.New("mark a workstream done instead") - -// WorkstreamTaken identifies the existing owner so adapters can explain an -// atomic refusal without another read that could race a rename. -type WorkstreamTaken struct { - Factory string - Job int - WorkstreamID string - WorkstreamName string -} - -func (e *WorkstreamTaken) Error() string { return "that job belongs to another workstream" } - -// Write runs fn in the store's immediate transaction, including any schema -// upgrade. Returning an error rolls back everything. The callback must use tx -// rather than reenter the Store, which owns a single database connection. -func (s *Store) Write(ctx context.Context, fn func(tx *sql.Tx) error) error { - tx, err := s.beginWrite(ctx, true) - if err != nil { - return storeError(err) - } - defer tx.Rollback() - if err := fn(tx); err != nil { - return storeError(err) - } - return storeError(tx.Commit()) -} - -// Read runs fn against a consistent read-only snapshot. Version 0 means blank; -// workstream readers must answer empty/not found for every version below 3. -// Reads never initialize or migrate the database, and share Write's busy mapping. -func (s *Store) Read(ctx context.Context, fn func(tx *sql.Tx, version int) error) error { - tx, version, err := s.beginRead(ctx) - if err != nil { - return err - } - defer tx.Rollback() - return storeError(fn(tx, version)) -} - -// beginRead keeps the schema decision and projected rows in one snapshot. A -// peer may migrate between separate transactions, adding fields an old projection -// cannot supply (notably factory on a new job ref). -func (s *Store) beginRead(ctx context.Context) (*sql.Tx, int, error) { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return nil, 0, storeError(err) - } - state, err := inspectSchema(ctx, tx) - if err != nil { - _ = tx.Rollback() - return nil, 0, storeError(err) - } - return tx, state.version(), nil -} - -// CreateIn preserves collection naming rules within a larger atomic write. -func CreateIn(ctx context.Context, tx *sql.Tx, id, name string, at int64) error { - if err := ValidateName(name); err != nil { - return err - } - if err := nameTaken(ctx, tx, name, ""); err != nil { - return err - } - _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", id, name, at) - return err -} - -// RenameIn preserves Unicode name uniqueness within a larger atomic write. -func RenameIn(ctx context.Context, tx *sql.Tx, id, name string) error { - if err := ValidateName(name); err != nil { - return err - } - if err := requireCollection(ctx, tx, id); err != nil { - return err - } - if err := nameTaken(ctx, tx, name, id); err != nil { - return err - } - _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id) - return err -} - -func touchWorkstream(ctx context.Context, tx *sql.Tx, id string, at int64) error { - _, err := tx.ExecContext(ctx, "UPDATE workstreams SET updated_at=max(updated_at+1,?) WHERE id=?", at, id) - return err -} - -func checkWorkstreamJob(ctx context.Context, tx *sql.Tx, id string, ref Ref) error { - if ref.Kind != FactoryJobKind { - return nil - } - var taken WorkstreamTaken - var isWorkstream bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstreams WHERE id=?)", id).Scan(&isWorkstream); err != nil { - return err - } - if !isWorkstream { - return nil - } - err := tx.QueryRowContext(ctx, `SELECT j.workstream_id,c.name FROM workstream_jobs j JOIN collections c ON c.id=j.workstream_id - WHERE j.factory=? AND j.job=? AND j.workstream_id!=?`, ref.Factory, ref.ID, id).Scan(&taken.WorkstreamID, &taken.WorkstreamName) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return err - } - n, _ := strconv.ParseInt(ref.ID, 10, 64) - taken.Factory, taken.Job = ref.Factory, int(n) - return &taken -} - -func factoryColumn(version int) string { - if version >= 3 { - return "factory" - } - return "''" -} - -func workstreamColumn(version int) string { - if version >= 3 { - return "EXISTS(SELECT 1 FROM workstreams w WHERE w.id=c.id)" - } - return "0" -} - -// Claims are maintained by triggers so even SQL writes through Write cannot -// accidentally bypass the unique job owner. Plain collections have no claims. -var workstreamSchema = fmt.Sprintf(` -CREATE TABLE workstreams ( - id TEXT PRIMARY KEY REFERENCES collections(id) ON DELETE CASCADE, - why TEXT NOT NULL DEFAULT '', origin_chat_id TEXT NOT NULL DEFAULT '', agent_id TEXT NOT NULL, - autonomy TEXT NOT NULL DEFAULT 'propose' CHECK(autonomy IN ('propose','small','off')), - wake_morning INTEGER NOT NULL DEFAULT 0, wake_related_issues INTEGER NOT NULL DEFAULT 0, - budget_usd REAL CHECK(budget_usd IS NULL OR budget_usd>0), - created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, started_at INTEGER, done_at INTEGER, - how_it_went TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '' -); -CREATE UNIQUE INDEX workstreams_client ON workstreams(client_id) WHERE client_id!=''; -CREATE TABLE workstream_jobs ( - member_seq INTEGER PRIMARY KEY REFERENCES memberships(seq) ON DELETE CASCADE, - workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - factory TEXT NOT NULL, job TEXT NOT NULL -); -CREATE UNIQUE INDEX workstream_job_owner ON workstream_jobs(factory,job); -CREATE TRIGGER workstream_job_insert AFTER INSERT ON memberships - WHEN NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id) - BEGIN INSERT INTO workstream_jobs VALUES(NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id); END; -CREATE TRIGGER workstream_job_update AFTER UPDATE ON memberships - BEGIN - DELETE FROM workstream_jobs WHERE member_seq=OLD.seq; - INSERT INTO workstream_jobs SELECT NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id - WHERE NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id); - END; -CREATE TRIGGER workstream_job_promote AFTER INSERT ON workstreams - BEGIN INSERT INTO workstream_jobs SELECT seq,collection_id,factory,ref_id FROM memberships - WHERE collection_id=NEW.id AND kind='factory_job'; END; -CREATE TABLE workstream_pages ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - parent_id TEXT REFERENCES workstream_pages(id) ON DELETE CASCADE, - title TEXT NOT NULL, owner TEXT NOT NULL CHECK(owner IN ('you','agent')), - read_by_jobs INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL CHECK(position>=0), - version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, - updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), updated_from TEXT, - doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)), - deleted_at INTEGER, deleted_root TEXT -); -CREATE INDEX workstream_page_tree ON workstream_pages(workstream_id,parent_id,position); -CREATE UNIQUE INDEX workstream_overview ON workstream_pages(workstream_id) WHERE overview=1; -CREATE TABLE workstream_page_versions ( - page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, - version INTEGER NOT NULL, saved_at INTEGER NOT NULL, saved_by TEXT NOT NULL CHECK(saved_by IN ('you','agent')), - source TEXT, title TEXT NOT NULL, doc TEXT NOT NULL, bytes INTEGER NOT NULL, - PRIMARY KEY(page_id,version) -); -CREATE TABLE workstream_records ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK(kind IN ('decision','question','goahead')), text TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped','superseded')), - actor TEXT NOT NULL CHECK(actor IN ('you','agent')), source TEXT, created_at INTEGER NOT NULL, - decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id), - goahead TEXT, goahead_id TEXT -); -CREATE INDEX workstream_record_list ON workstream_records(workstream_id,created_at); -CREATE TABLE workstream_goaheads ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - scope TEXT NOT NULL CHECK(scope IN ('job','wave','milestone')), - factory TEXT, job INTEGER, wave INTEGER, milestone_id TEXT, - cap_usd REAL NOT NULL CHECK(cap_usd>0 AND cap_usd<=%d), ends_at INTEGER NOT NULL, - state TEXT NOT NULL CHECK(state IN ('active','ended')), - end_reason TEXT CHECK(end_reason IN ('expired','revoked','workstream_done')), - granted_at INTEGER NOT NULL, ended_at INTEGER, - CHECK((scope='job' AND factory IS NOT NULL AND length(factory)>0 AND job IS NOT NULL AND job>0 AND wave IS NULL AND milestone_id IS NULL) - OR (scope='wave' AND wave IS NOT NULL AND wave>0 AND factory IS NULL AND job IS NULL AND milestone_id IS NULL) - OR (scope='milestone' AND milestone_id IS NOT NULL AND length(milestone_id)>0 AND factory IS NULL AND job IS NULL AND wave IS NULL)), - CHECK(ends_at>granted_at AND ends_at<=granted_at+%d) -); -CREATE INDEX workstream_goahead_list ON workstream_goaheads(workstream_id,state); -CREATE TABLE workstream_plans ( - workstream_id TEXT PRIMARY KEY REFERENCES workstreams(id) ON DELETE CASCADE, - version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, - updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), - milestones TEXT NOT NULL, waves TEXT NOT NULL -); -`, WorkstreamGoAheadCapMax, WorkstreamGoAheadDurationMax.Milliseconds()) + workstreamCollectionKeep - -// Older writers may already hold a connection opened before workstreams existed. -// The database must enforce the same deletion refusal as Store.Delete. -const workstreamCollectionKeep = ` -CREATE TRIGGER IF NOT EXISTS workstream_collection_keep BEFORE DELETE ON collections - WHEN EXISTS(SELECT 1 FROM workstreams WHERE id=OLD.id) - BEGIN SELECT RAISE(ABORT,'mark a workstream done instead'); END; -` - -// Opening a schema-3 file verifies the tables needed by both domain reads and -// collection ownership checks. A partial schema is refused rather than repaired. -var workstreamSchemaReads = []string{ - "SELECT id,why,origin_chat_id,agent_id,autonomy,wake_morning,wake_related_issues,budget_usd,created_at,updated_at,started_at,done_at,how_it_went,client_id FROM workstreams LIMIT 0", - "SELECT member_seq,workstream_id,factory,job FROM workstream_jobs LIMIT 0", - "SELECT id,workstream_id,parent_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview,deleted_at,deleted_root FROM workstream_pages LIMIT 0", - "SELECT page_id,version,saved_at,saved_by,source,title,doc,bytes FROM workstream_page_versions LIMIT 0", - "SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes,superseded_by,goahead,goahead_id FROM workstream_records LIMIT 0", - "SELECT id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,end_reason,granted_at,ended_at FROM workstream_goaheads LIMIT 0", - "SELECT workstream_id,version,updated_at,updated_by,milestones,waves FROM workstream_plans LIMIT 0", -} - -// WorkstreamWakeSchemaVersion keeps admission reads tied to this additive step. -const WorkstreamWakeSchemaVersion = 4 - -// Schema 4 is additive; its tables can be renumbered without changing schema 3. -const workstreamWakeSchema = ` -CREATE TABLE workstream_wakes ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - event_key TEXT NOT NULL, event_json TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('running','settled','failed')), - summary TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', cost_usd REAL, - started_at INTEGER NOT NULL, settled_at INTEGER, dismissed_at INTEGER, - UNIQUE(workstream_id,event_key) -); -CREATE INDEX workstream_wake_list ON workstream_wakes(workstream_id,started_at); -CREATE TABLE workstream_wake_items ( - id TEXT PRIMARY KEY, wake_id TEXT NOT NULL REFERENCES workstream_wakes(id) ON DELETE CASCADE, - position INTEGER NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('move','draft','page','decision','question')), - state TEXT NOT NULL CHECK(state IN ('applied','undone','proposed','dismissed')), - why TEXT NOT NULL DEFAULT '', item_json TEXT NOT NULL, undo_json TEXT, - note TEXT NOT NULL DEFAULT '', applied_at INTEGER, undone_at INTEGER -); -CREATE INDEX workstream_wake_item_list ON workstream_wake_items(wake_id,position); -` - -func migrateWorkstreamWakes(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, workstreamWakeSchema+workstreamCollectionKeep) - return err -} - -// WorkstreamSuggestionSchemaVersion keeps suggestion reads tied to this step. -const WorkstreamSuggestionSchemaVersion = 6 - -// Suggestions are additive to schema 5 and are installed by the first write. -const workstreamSuggestionSchema = ` -CREATE TABLE workstream_suggestions ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, - base_version INTEGER NOT NULL, why TEXT NOT NULL, actor TEXT NOT NULL, source TEXT, - created_at INTEGER NOT NULL -); -CREATE INDEX workstream_suggestion_page ON workstream_suggestions(page_id,created_at); -CREATE TABLE workstream_suggestion_edits ( - suggestion_id TEXT NOT NULL REFERENCES workstream_suggestions(id) ON DELETE CASCADE, - id TEXT NOT NULL, position INTEGER NOT NULL, block TEXT NOT NULL, at_offset INTEGER NOT NULL, - del TEXT NOT NULL, ins TEXT NOT NULL, block_text TEXT NOT NULL, why TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped')), decided_at INTEGER, - PRIMARY KEY(suggestion_id,id) -); -` - -var workstreamSuggestionSchemaReads = []string{ - "SELECT id,workstream_id,page_id,base_version,why,actor,source,created_at FROM workstream_suggestions LIMIT 0", - "SELECT suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state,decided_at FROM workstream_suggestion_edits LIMIT 0", -} diff --git a/internal/workspace/workstreams_fields_v02_test.go b/internal/workspace/workstreams_fields_v02_test.go deleted file mode 100644 index 6bf939c62a..0000000000 --- a/internal/workspace/workstreams_fields_v02_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package workspace - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// WP-F2 (d), Storage: missing proposal columns refuse at Open without repair. -func TestWorkstreamSchemaThreeRequiresGoAheadRecordColumns(t *testing.T) { - for _, column := range []string{"goahead", "goahead_id"} { - t.Run(column, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "obsolete.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Schema three") - workstreamSchemaThreeFixture(t, s) - if userVersion(t, s) != 3 { - t.Fatal("fixture is not schema 3") - } - if _, err := s.db.Exec("ALTER TABLE workstream_records DROP COLUMN " + column); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - _ = opened.Close() - } - if err == nil || !strings.Contains(err.Error(), column) { - t.Fatalf("obsolete schema opened: %v", err) - } - after, err := os.ReadFile(path) - if err != nil || string(before) != string(after) { - t.Fatalf("refusal changed file: %v", err) - } - }) - } -} diff --git a/internal/workspace/workstreams_test.go b/internal/workspace/workstreams_test.go deleted file mode 100644 index 128d73a254..0000000000 --- a/internal/workspace/workstreams_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "errors" - "path/filepath" - "testing" -) - -func seedWorkstream(t *testing.T, s *Store, id, name string) { - t.Helper() - if err := s.Write(context.Background(), func(tx *sql.Tx) error { - if err := CreateIn(context.Background(), tx, id, name, 100); err != nil { - return err - } - _, err := tx.Exec("INSERT INTO workstreams(id,agent_id,created_at,updated_at) VALUES (?,?,100,100)", id, "agent-"+id) - return err - }); err != nil { - t.Fatal(err) - } -} - -// Part A (b): the collection store, including relink and raw SQL's unique -// constraint, enforces job ownership; plain collections make no job claim. -func TestPartAWorkstreamMembershipOwnershipIsEnforcedInSQLite(t *testing.T) { - s := openTestStore(t, filepath.Join(t.TempDir(), "claims.db")) - ctx := context.Background() - seedWorkstream(t, s, "a", "First") - seedWorkstream(t, s, "b", "Second") - job := Ref{Kind: FactoryJobKind, ID: "7", Factory: "f"} - fixedClock(s, 200) - if err := s.Add(ctx, "a", job); err != nil { - t.Fatal(err) - } - fixedClock(s, 300) - _, _, err := s.AddRefs(ctx, "b", []Addition{{Ref: Ref{Kind: ConversationKind, ID: "extra"}}, {Ref: job}}) - var taken *WorkstreamTaken - if !errors.As(err, &taken) || taken.WorkstreamID != "a" || taken.WorkstreamName != "First" || taken.Job != 7 || taken.Factory != "f" { - t.Fatalf("taken: %+v %v", taken, err) - } - for id, want := range map[string]int64{"a": 200, "b": 100} { - var at int64 - if err := s.db.QueryRow("SELECT updated_at FROM workstreams WHERE id=?", id).Scan(&at); err != nil || at != want { - t.Fatalf("timestamp %s: %d %v", id, at, err) - } - } - if members, err := s.Members(ctx, "b"); err != nil || len(members) != 0 { - t.Fatalf("partial add: %+v %v", members, err) - } - if err := s.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('b','factory_job','7','','f')") - return err - }); err == nil { - t.Fatal("SQL bypassed unique job owner") - } - plain := createTestCollection(t, s, "Plain") - if err := s.Add(ctx, plain.ID, job); err != nil { - t.Fatal(err) - } - differentFactory := Ref{Kind: FactoryJobKind, ID: "7", Factory: "other"} - if err := s.Add(ctx, "b", differentFactory); err != nil { - t.Fatal(err) - } - if _, _, err := s.Relink(ctx, "b", differentFactory, job); !errors.As(err, &taken) { - t.Fatalf("relink took another owner's job: %v", err) - } - if err := s.Remove(ctx, "a", job); err != nil { - t.Fatal(err) - } - if _, _, err := s.Relink(ctx, "b", differentFactory, job); err != nil { - t.Fatal(err) - } - a, err := s.Describe(ctx, "a") - if err != nil || !a.Workstream { - t.Fatalf("workstream flag: %+v %v", a, err) - } - copy, err := s.Duplicate(ctx, "b", "Copy") - if err != nil { - t.Fatal(err) - } - copySummary, err := s.Describe(ctx, copy.ID) - if err != nil || copySummary.Workstream { - t.Fatalf("plain duplicate: %+v %v", copySummary, err) - } - if _, err := s.Delete(ctx, "a"); !errors.Is(err, ErrWorkstreamDelete) { - t.Fatalf("delete: %v", err) - } -} - -// Part A (a), WP-12 (i): schema 1 upgrades directly to the current schema, -// preserving unknown timestamps and checking a job reference's factory. -func TestPartASchemaOneMigratesStraightToCurrent(t *testing.T) { - s := openTestStore(t, writeLegacyStore(t)) - ctx := context.Background() - if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "11", Factory: "f"}); err != nil { - t.Fatal(err) - } - if userVersion(t, s) != schemaVersion { - t.Fatal("did not migrate directly to the current schema") - } - var count int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 12 { - t.Fatalf("workstream tables: %d %v", count, err) - } - var created, added int64 - if err := s.db.QueryRow("SELECT created_at FROM collections WHERE id='aaaa'").Scan(&created); err != nil { - t.Fatal(err) - } - if err := s.db.QueryRow("SELECT added_at FROM memberships WHERE kind='task'").Scan(&added); err != nil { - t.Fatal(err) - } - if created != 0 || added != 0 { - t.Fatal("invented legacy timestamps") - } - for _, q := range []string{ - "INSERT INTO memberships(collection_id,kind,ref_id,session_id) VALUES ('aaaa','factory_job','12','')", - "INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('aaaa','conversation','other','','f')", - } { - if _, err := s.db.Exec(q); err == nil { - t.Fatalf("accepted invalid factory: %s", q) - } - } -} - -func TestPartAFactoryIsPartOfEveryCollectionReadIdentity(t *testing.T) { - s := openTestStore(t, filepath.Join(t.TempDir(), "identity.db")) - ctx := context.Background() - c := createTestCollection(t, s, "Jobs") - a, b := Ref{Kind: FactoryJobKind, ID: "1", Factory: "a"}, Ref{Kind: FactoryJobKind, ID: "1", Factory: "b"} - if err := s.Add(ctx, c.ID, a); err != nil { - t.Fatal(err) - } - if err := s.Add(ctx, c.ID, b); err != nil { - t.Fatal(err) - } - for _, r := range []Ref{a, b} { - parents, err := s.CollectionsFor(ctx, r) - if err != nil || len(parents) != 1 { - t.Fatalf("find %+v: %+v %v", r, parents, err) - } - } - expansion, err := s.Expand(ctx, c.ID, 1) - if err != nil || len(expansion.Refs) != 2 { - t.Fatalf("expansion: %+v %v", expansion, err) - } - if err := s.Remove(ctx, c.ID, a); err != nil { - t.Fatal(err) - } - members, err := s.Members(ctx, c.ID) - if err != nil || len(members) != 1 || members[0] != b { - t.Fatalf("remove: %+v %v", members, err) - } - list, err := s.List(ctx, &a) - if err != nil || len(list) != 1 || *list[0].Holds { - t.Fatalf("holds: %+v %v", list, err) - } - parents, err := s.CollectionsFor(ctx, a) - if err != nil || len(parents) != 0 { - t.Fatalf("removed find: %+v %v", parents, err) - } -} diff --git a/internal/workstream/goaheads.go b/internal/workstream/goaheads.go deleted file mode 100644 index 8dd562bfdf..0000000000 --- a/internal/workstream/goaheads.go +++ /dev/null @@ -1,232 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "math" - "strconv" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const goaheadSelect = `SELECT id,workstream_id,scope,coalesce(factory,''),coalesce(job,0),coalesce(wave,0),coalesce(milestone_id,''),cap_usd,ends_at,state,coalesce(end_reason,''),granted_at,ended_at FROM workstream_goaheads WHERE id=?` - -func goaheadIn(ctx context.Context, tx *sql.Tx, id string) (GoAhead, error) { - var g GoAhead - var ends, granted int64 - var ended sql.NullInt64 - err := tx.QueryRowContext(ctx, goaheadSelect, id).Scan(&g.ID, &g.WorkstreamID, &g.Scope, &g.Factory, &g.Job, &g.Wave, &g.MilestoneID, &g.CapUSD, &ends, &g.State, &g.EndReason, &granted, &ended) - if err != nil { - return g, found(err) - } - g.EndsAt, g.GrantedAt, g.EndedAt = instant(ends), instant(granted), optionalInstant(ended) - return g, nil -} - -// Expiry is derived on read, so listing never writes or emits a change. -func expire(g GoAhead, now time.Time) GoAhead { - if g.State == "active" && !g.EndsAt.After(now) { - g.State = "ended" - g.EndReason = "expired" - g.EndedAt = g.EndsAt - } - return g -} -func (s *Store) GoAheads(ctx context.Context, id string, ended bool) ([]GoAhead, error) { - out := []GoAhead{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, "SELECT id FROM workstream_goaheads WHERE workstream_id=? ORDER BY granted_at DESC,rowid DESC", id) - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err - } - now := s.now() - for _, id := range ids { - g, err := goaheadIn(ctx, tx, id) - if err != nil { - return err - } - g = expire(g, now) - if ended || g.State == "active" { - out = append(out, g) - } - } - return nil - }) - return out, err -} -func workstreamGoAheadFields(p GoAheadGrant, now time.Time) error { - // Store millisecond precision in the validation too: an end within the same - // stored millisecond as the grant is not a future authorization. - ends := p.EndsAt - if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || ends.IsZero() || !ends.After(now) || ends.After(now.Add(workspace.WorkstreamGoAheadDurationMax)) || ends.UnixMilli() <= now.UnixMilli() { - return invalid("go-ahead needs a cap greater than 0 and at most 10000, and a future end within 90 days") - } - switch p.Scope { - case "job": - if p.Job <= 0 || p.Wave != 0 || p.MilestoneID != "" { - return invalid("job scope needs only factory and job") - } - if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: strconv.Itoa(p.Job), Factory: p.Factory}).Validate(); err != nil { - return err - } - case "wave": - if p.Wave <= 0 || p.Factory != "" || p.Job != 0 || p.MilestoneID != "" { - return invalid("wave scope needs only wave") - } - case "milestone": - if p.MilestoneID == "" || p.Factory != "" || p.Job != 0 || p.Wave != 0 { - return invalid("milestone scope needs only milestone_id") - } - default: - return invalid("go-ahead needs a scope") - } - return nil -} - -func workstreamGoAheadAllowedIn(ctx context.Context, tx *sql.Tx, p GoAheadGrant, at time.Time) error { - w, err := getIn(ctx, tx, p.WorkstreamID) - if err != nil { - return err - } - if !w.DoneAt.IsZero() { - return ErrConflict - } - if err := workstreamGoAheadFields(p, at); err != nil { - return err - } - if p.Scope == "job" { - for _, j := range w.Jobs() { - if j.Factory == p.Factory && j.Number == p.Job { - return nil - } - } - } else { - plan, err := planIn(ctx, tx, p.WorkstreamID) - if err != nil { - return err - } - if workstreamGoAheadInPlan(p, plan) { - return nil - } - } - return invalid("scope target is not a member or does not exist in the plan") -} - -func workstreamGoAheadInPlan(p GoAheadGrant, plan Plan) bool { - for _, wave := range plan.Waves { - if p.Scope == "wave" && wave.N == p.Wave { - return true - } - } - for _, milestone := range plan.Milestones { - if p.Scope == "milestone" && milestone.ID == p.MilestoneID { - return true - } - } - return false -} - -// Both direct grants and kept proposals validate and insert under the writer. -func (s *Store) grantGoAheadIn(ctx context.Context, tx *sql.Tx, p GoAheadGrant, at time.Time) (GoAhead, error) { - if err := workstreamGoAheadAllowedIn(ctx, tx, p, at); err != nil { - return GoAhead{}, err - } - id, err := s.newID() - if err != nil { - return GoAhead{}, err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_goaheads(id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,granted_at) VALUES (?,?,?,?,?,?,?,?,?,'active',?)`, id, p.WorkstreamID, p.Scope, nullString(p.Factory), nullInt(p.Job), nullInt(p.Wave), nullString(p.MilestoneID), p.CapUSD, p.EndsAt.UnixMilli(), at.UnixMilli()); err != nil { - return GoAhead{}, err - } - return goaheadIn(ctx, tx, id) -} - -func (s *Store) GrantGoAhead(ctx context.Context, p GoAheadGrant) (GoAhead, Change, error) { - if err := workstreamGoAheadFields(p, s.now()); err != nil { - return GoAhead{}, Change{}, err - } - var out GoAhead - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - at := s.now() - var err error - out, err = s.grantGoAheadIn(ctx, tx, p, at) - if err != nil { - return err - } - if err := touch(ctx, tx, p.WorkstreamID, at.UnixMilli()); err != nil { - return err - } - c = changed(p.WorkstreamID, PartGoAheads) - return nil - }) - if err != nil { - return GoAhead{}, Change{}, err - } - return out, c, nil -} - -func revokeGoAheadIn(ctx context.Context, tx *sql.Tx, id string, now time.Time) (GoAhead, bool, error) { - g, err := goaheadIn(ctx, tx, id) - if err != nil { - return GoAhead{}, false, err - } - g = expire(g, now) - if g.State == "ended" { - return g, false, nil - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_goaheads SET state='ended',end_reason='revoked',ended_at=? WHERE id=?", now.UnixMilli(), id); err != nil { - return GoAhead{}, false, err - } - g, err = goaheadIn(ctx, tx, id) - return g, true, err -} - -func (s *Store) RevokeGoAhead(ctx context.Context, id string) (GoAhead, Change, error) { - var out GoAhead - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - now := s.now() - g, ended, err := revokeGoAheadIn(ctx, tx, id, now) - if err != nil { - return err - } - out = g - if !ended { - return nil - } - at := now.UnixMilli() - if err := touch(ctx, tx, g.WorkstreamID, at); err != nil { - return err - } - c = changed(g.WorkstreamID, PartGoAheads) - return nil - }) - if err != nil { - return GoAhead{}, Change{}, err - } - return out, c, nil -} diff --git a/internal/workstream/legacy_test.go b/internal/workstream/legacy_test.go deleted file mode 100644 index ade79780cd..0000000000 --- a/internal/workstream/legacy_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -// Part A (a): domain reads through older schemas are empty, never migrations. -func TestPartALegacyWorkstreamReadsDoNotWrite(t *testing.T) { - for _, version := range []int{1, 2} { - t.Run(fmt.Sprint(version), func(t *testing.T) { - path := filepath.Join(t.TempDir(), "legacy.db") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - created, added := "", "" - if version == 2 { - created = ", created_at INTEGER NOT NULL DEFAULT 0" - added = ", added_at INTEGER NOT NULL DEFAULT 0" - } - _, err = db.Exec(fmt.Sprintf(`CREATE TABLE collections(seq INTEGER PRIMARY KEY,id TEXT UNIQUE,name TEXT%s); -CREATE TABLE memberships(seq INTEGER PRIMARY KEY,collection_id TEXT,kind TEXT,ref_id TEXT,session_id TEXT,target_collection TEXT%s); -PRAGMA application_id=1095123788; PRAGMA user_version=%d; -INSERT INTO collections(seq,id,name) VALUES (1,'collection','Existing');`, created, added, version)) - if err != nil { - t.Fatal(err) - } - if err := db.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - w, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer w.Close() - s := New(w, Options{}) - ctx := context.Background() - all, err := s.List(ctx) - if err != nil || all == nil || len(all) != 0 { - t.Fatalf("legacy list: %+v %v", all, err) - } - for name, read := range map[string]func() error{ - "get": func() error { _, err := s.Get(ctx, "collection"); return err }, - "plan": func() error { _, err := s.Plan(ctx, "collection"); return err }, - "pages": func() error { _, err := s.Pages(ctx, "collection"); return err }, - "page": func() error { _, err := s.Page(ctx, "missing", true); return err }, - "history": func() error { _, err := s.PageHistory(ctx, "missing", 50); return err }, - "records": func() error { _, err := s.Records(ctx, "collection", "", ""); return err }, - "goaheads": func() error { _, err := s.GoAheads(ctx, "collection", true); return err }, - } { - if err := read(); !errors.Is(err, ErrNotFound) { - t.Fatalf("legacy %s: %v", name, err) - } - } - after, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(before) != string(after) { - t.Fatal("domain read changed legacy file") - } - }) - } -} diff --git a/internal/workstream/limits_test.go b/internal/workstream/limits_test.go deleted file mode 100644 index 05bb676d98..0000000000 --- a/internal/workstream/limits_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" - "testing" - "time" -) - -func TestStabEM4PlanLimitsAreAtomic(t *testing.T) { - for _, name := range []string{"draft text", "milestones", "waves", "items", "encoded JSON"} { - t.Run(name, func(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Limits") - milestones := []Milestone{} - waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d", Title: "Draft"}}}} - switch name { - case "draft text": - waves[0].Items[0].Text = strings.Repeat("x", PlanDraftTextMax) - case "milestones": - for i := range PlanMilestonesMax { - milestones = append(milestones, Milestone{ID: fmt.Sprintf("m%d", i), Title: "Milestone"}) - } - case "waves": - waves = []Wave{} - for i := range PlanWavesMax { - waves = append(waves, Wave{N: i + 1, Items: []PlanItem{}}) - } - case "items": - waves[0].Items = []PlanItem{} - for i := range PlanItemsMax { - waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: fmt.Sprintf("d%d", i), Title: "Draft"}) - } - case "encoded JSON": - waves[0].Items = []PlanItem{} - for i := range 53 { - waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: fmt.Sprintf("d%d", i), Title: "Draft", Text: strings.Repeat("x", PlanDraftTextMax)}) - } - last := &waves[0].Items[52] - last.Text = "x" - m, err := json.Marshal(milestones) - if err != nil { - t.Fatal(err) - } - v, err := json.Marshal(waves) - if err != nil { - t.Fatal(err) - } - last.Text = strings.Repeat("x", 1+PlanEncodedMax-len(m)-len(v)) - if len(last.Text) > PlanDraftTextMax { - t.Fatal("encoded fixture exceeds the draft text bound") - } - } - before, c, err := s.SetPlan(ctx, w.ID, 0, milestones, waves, Attribution{}) - if err != nil || before.Version != 1 || len(c.Parts) == 0 || !reflect.DeepEqual(before.Milestones, milestones) || !reflect.DeepEqual(before.Waves, waves) { - t.Fatalf("at bound: version=%d change=%+v: %v", before.Version, c, err) - } - row, err := s.Get(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - switch name { - case "draft text": - waves[0].Items[0].Text += "x" - case "milestones": - milestones = append(milestones, Milestone{ID: "extra", Title: "Extra"}) - case "waves": - waves = append(waves, Wave{N: PlanWavesMax + 1, Items: []PlanItem{}}) - case "items": - waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: "extra", Title: "Extra"}) - case "encoded JSON": - waves[0].Items[52].Text += "x" - } - *now = now.Add(time.Minute) - _, c, err = s.SetPlan(ctx, w.ID, before.Version, milestones, waves, Attribution{}) - if !errors.Is(err, ErrLimit) || len(c.Parts) != 0 || !strings.Contains(err.Error(), "at most") { - t.Fatalf("over bound: change=%+v: %v", c, err) - } - after, err := s.Plan(ctx, w.ID) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("refused plan changed: version=%d: %v", after.Version, err) - } - afterRow, err := s.Get(ctx, w.ID) - if err != nil || !reflect.DeepEqual(row, afterRow) { - t.Fatalf("refused plan touched the workstream: %v", err) - } - }) - } -} - -func TestStabEM4DraftControlCharactersRemainInvalid(t *testing.T) { - s, _, _ := testStore(t) - w := createStream(t, s, "Control characters") - for _, text := range []string{"bad\x00text", strings.Repeat("x", PlanDraftTextMax) + "\x00"} { - _, c, err := s.SetPlan(context.Background(), w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d", Title: "Draft", Text: text}}}}, Attribution{}) - if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("control character in %d bytes: change=%+v: %v", len(text), c, err) - } - } -} - -func TestStabEM4RestoreChecksSavedDocumentSize(t *testing.T) { - s, db, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Restore") - pages, err := s.Pages(ctx, w.ID) - if err != nil || len(pages) != 1 { - t.Fatalf("Overview: %v", err) - } - before, err := s.Page(ctx, pages[0].ID, false) - if err != nil { - t.Fatal(err) - } - if err := db.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `UPDATE workstream_page_versions SET doc=? WHERE page_id=? AND version=1`, `{"text":"`+strings.Repeat("x", DocMax)+`"}`, before.ID) - return err - }); err != nil { - t.Fatal(err) - } - _, c, err := s.RestorePage(ctx, before.ID, 1, before.Version, Attribution{}) - if !errors.Is(err, ErrTooLarge) || len(c.Parts) != 0 { - t.Fatalf("restore oversized saved document: change=%+v: %v", c, err) - } - after, err := s.Page(ctx, before.ID, false) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("refused restore changed the page: %v", err) - } -} diff --git a/internal/workstream/pagedoc/frommarkdown.go b/internal/workstream/pagedoc/frommarkdown.go deleted file mode 100644 index acb836c9d5..0000000000 --- a/internal/workstream/pagedoc/frommarkdown.go +++ /dev/null @@ -1,359 +0,0 @@ -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 deleted file mode 100644 index 49a46a2466..0000000000 --- a/internal/workstream/pagedoc/frommarkdown_test.go +++ /dev/null @@ -1,136 +0,0 @@ -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/pagedoc/pagedoc.go b/internal/workstream/pagedoc/pagedoc.go deleted file mode 100644 index 21c0c5591d..0000000000 --- a/internal/workstream/pagedoc/pagedoc.go +++ /dev/null @@ -1,395 +0,0 @@ -// Package pagedoc renders workstream page editor JSON documents to markdown. -package pagedoc - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "math" - "strconv" - "strings" - "unicode/utf8" -) - -// Markdown renders a workstream page's editor JSON document into deterministic -// markdown matching the desktop app's reference renderer. An absent document -// (nil, empty or whitespace only) returns "", nil, the same as JSON null. -// Invalid JSON returns an error prefixed with "pagedoc: ". -// -// Two untestable boundary cases differ from the JavaScript reference because -// of runtime semantics: Go's JSON decoder replaces lone UTF-16 surrogate -// escapes with U+FFFD, and documents nested deeply enough to overflow the -// JavaScript call stack (where the reference catches the range error and -// returns "") render normally in Go up to encoding/json's 10000-level nesting -// limit, beyond which json.Valid reports an invalid-JSON error. -func Markdown(doc json.RawMessage) (out string, err error) { - // The export boundary must not take the engine down on a document nobody - // foresaw; the reference renderer likewise returns rather than throws. - defer func() { - if r := recover(); r != nil { - out = "" - err = fmt.Errorf("pagedoc: recovered panic: %v", r) - } - }() - - if len(bytes.TrimSpace(doc)) == 0 { - return "", nil - } - if !json.Valid(doc) { - return "", errors.New("pagedoc: invalid JSON") - } - dec := json.NewDecoder(bytes.NewReader(doc)) - dec.UseNumber() - var root any - if err := dec.Decode(&root); err != nil { - return "", fmt.Errorf("pagedoc: invalid JSON: %w", err) - } - rendered := block(object(root)) - if rendered == "" { - return "", nil - } - return strings.TrimRight(rendered, "\n") + "\n", nil -} - -func object(v any) map[string]any { - if m, ok := v.(map[string]any); ok { - return m - } - return nil -} - -func children(node map[string]any) []map[string]any { - raw, ok := node["content"].([]any) - if !ok { - return nil - } - out := make([]map[string]any, len(raw)) - for i, elem := range raw { - out[i] = object(elem) - } - return out -} - -func marks(node map[string]any) []map[string]any { - raw, ok := node["marks"].([]any) - if !ok { - return nil - } - out := make([]map[string]any, len(raw)) - for i, elem := range raw { - out[i] = object(elem) - } - return out -} - -func str(v any) string { - if s, ok := v.(string); ok { - return s - } - return "" -} - -func number(v any) (float64, bool) { - n, ok := v.(json.Number) - if !ok { - return 0, false - } - f, err := strconv.ParseFloat(string(n), 64) - if err != nil && !errors.Is(err, strconv.ErrRange) { - return 0, false - } - return f, true -} - -func intNumber(v any) (float64, bool) { - f, ok := number(v) - if !ok || math.IsInf(f, 0) || math.IsNaN(f) || math.Trunc(f) != f { - return 0, false - } - return f, true -} - -func jsNumber(x float64) string { - if x == 0 { - return "0" - } - if math.Abs(x) < 1e21 { - return strconv.FormatFloat(x, 'f', -1, 64) - } - return strconv.FormatFloat(x, 'e', -1, 64) -} - -func escape(s string) string { - var b strings.Builder - for i := 0; i < len(s); i++ { - switch s[i] { - case '\\', '`', '*', '_', '[', ']', '<', '>': - b.WriteByte('\\') - } - b.WriteByte(s[i]) - } - return b.String() -} - -func longestBackticks(s string) int { - longest, run := 0, 0 - for i := 0; i < len(s); i++ { - if s[i] == '`' { - run++ - if run > longest { - longest = run - } - } else { - run = 0 - } - } - return longest -} - -func inlineLine(s string) string { - return strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(s) -} - -func trimInline(s string) string { - return strings.Trim(s, " \t") -} - -func descendantText(node map[string]any) string { - var b strings.Builder - b.WriteString(str(node["text"])) - for _, child := range children(node) { - b.WriteString(descendantText(child)) - } - return b.String() -} - -func inlineText(node map[string]any) string { - t := inlineLine(str(node["text"])) - if t == "" { - return "" - } - hasCode := false - for _, m := range marks(node) { - if str(m["type"]) == "code" { - hasCode = true - break - } - } - if !hasCode { - return escape(t) - } - delim := strings.Repeat("`", longestBackticks(t)+1) - pad := "" - if strings.HasPrefix(t, "`") || strings.HasSuffix(t, "`") { - pad = " " - } - return delim + pad + t + pad + delim -} - -func linkHref(node map[string]any) string { - if str(node["type"]) != "text" { - return "" - } - for _, m := range marks(node) { - if str(m["type"]) == "link" { - return str(object(m["attrs"])["href"]) - } - } - return "" -} - -func encodeHref(href string) string { - return strings.NewReplacer( - " ", "%20", - "(", "%28", - ")", "%29", - "<", "%3C", - ">", "%3E", - ).Replace(href) -} - -func inline(nodes []map[string]any) string { - var b strings.Builder - for i := 0; i < len(nodes); { - n := nodes[i] - href := linkHref(n) - text := inlineText(n) - i++ - if href != "" { - for i < len(nodes) && linkHref(nodes[i]) == href { - text += inlineText(nodes[i]) - i++ - } - if text == "" { - continue - } - b.WriteByte('[') - b.WriteString(text) - b.WriteString("](") - b.WriteString(encodeHref(href)) - b.WriteByte(')') - } else { - b.WriteString(text) - } - } - return b.String() -} - -func paragraph(s string) string { - s = trimInline(s) - var b strings.Builder - lineStart := true - for i := 0; i < len(s); { - if lineStart { - switch s[i] { - case '#', '-', '+', '>': - b.WriteByte('\\') - b.WriteByte(s[i]) - i++ - lineStart = false - continue - } - if s[i] >= '0' && s[i] <= '9' { - j := i + 1 - for j < len(s) && s[j] >= '0' && s[j] <= '9' { - j++ - } - b.WriteString(s[i:j]) - if j < len(s) && (s[j] == '.' || s[j] == ')') { - b.WriteByte('\\') - b.WriteByte(s[j]) - j++ - } - i = j - lineStart = false - continue - } - } - r, w := utf8.DecodeRuneInString(s[i:]) - b.WriteString(s[i : i+w]) - i += w - lineStart = r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' - } - return b.String() -} - -func liveBlock(attrs map[string]any) string { - kind := str(attrs["kind"]) - if kind == "" { - kind = "unknown" - } - switch kind { - case "jobs", "waves": - if m := str(attrs["milestone_id"]); m != "" { - return "[live: " + kind + " milestone=" + m + "]" - } - return "[live: " + kind + "]" - case "job": - if job, ok := intNumber(attrs["job"]); ok && job >= 0 { - return "[live: job " + str(attrs["factory"]) + "#" + jsNumber(job) + "]" - } - return "[live: job]" - case "decision": - if r := str(attrs["record_id"]); r != "" { - return "[live: decision " + r + "]" - } - return "[live: decision]" - default: - return "[live: " + kind + "]" - } -} - -func listItem(node map[string]any, prefix string) string { - var body strings.Builder - for _, child := range children(node) { - r := block(child) - if r == "" { - continue - } - if body.Len() > 0 { - t := str(child["type"]) - if t == "bulletList" || t == "orderedList" { - body.WriteByte('\n') - } else { - body.WriteString("\n\n") - } - } - body.WriteString(r) - } - if body.Len() == 0 { - return strings.TrimRight(prefix, " ") - } - lines := strings.Split(body.String(), "\n") - indent := strings.Repeat(" ", len(prefix)) - for i, line := range lines { - if i == 0 { - lines[i] = prefix + line - } else if line != "" { - lines[i] = indent + line - } - } - return strings.Join(lines, "\n") -} - -func block(node map[string]any) string { - attrs := object(node["attrs"]) - switch str(node["type"]) { - case "doc": - var parts []string - for _, child := range children(node) { - if r := block(child); r != "" { - parts = append(parts, r) - } - } - return strings.Join(parts, "\n\n") - case "paragraph": - return paragraph(inline(children(node))) - case "heading": - t := trimInline(inline(children(node))) - if t == "" { - return "" - } - level := 1 - if f, ok := number(attrs["level"]); ok && !math.IsInf(f, 0) && !math.IsNaN(f) { - tr := math.Trunc(f) - if tr < 1 { - level = 1 - } else if tr > 3 { - level = 3 - } else { - level = int(tr) - } - } - return strings.Repeat("#", level) + " " + t - case "bulletList", "orderedList": - start := 1.0 - if s, ok := intNumber(attrs["start"]); ok { - start = s - } - kids := children(node) - items := make([]string, len(kids)) - isBullet := str(node["type"]) == "bulletList" - for i, item := range kids { - prefix := "- " - if !isBullet { - prefix = jsNumber(start+float64(i)) + ". " - } - items[i] = listItem(item, prefix) - } - return strings.Join(items, "\n") - case "listItem": - return listItem(node, "") - case "codeBlock": - t := descendantText(node) - fence := strings.Repeat("`", max(3, longestBackticks(t)+1)) - if t != "" { - return fence + str(attrs["language"]) + "\n" + t + "\n" + fence - } - return fence + str(attrs["language"]) + "\n" + fence - case "liveBlock": - return liveBlock(attrs) - default: - return paragraph(escape(inlineLine(descendantText(node)))) - } -} diff --git a/internal/workstream/pagedoc/pagedoc_test.go b/internal/workstream/pagedoc/pagedoc_test.go deleted file mode 100644 index e2ce9fa7ee..0000000000 --- a/internal/workstream/pagedoc/pagedoc_test.go +++ /dev/null @@ -1,414 +0,0 @@ -package pagedoc - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -type testCase struct { - name, input, want string -} - -func doc(blocks ...string) string { - return `{"type":"doc","content":[` + strings.Join(blocks, ",") + `]}` -} - -func runCases(t *testing.T, cases []testCase) { - t.Helper() - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := Markdown(json.RawMessage(tc.input)) - if err != nil { - t.Fatalf("%s: unexpected error: %v", tc.name, err) - } - if got != tc.want { - t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) - } - }) - } -} - -// Ported from the desktop app's markdown tests: "paragraphs, heading levels 1–3 and clamping, empty blocks and exactly one terminal newline". -// Covers validation contract (a). -func TestMarkdownParagraphsHeadingsEmptyBlocksAndTerminalNewline(t *testing.T) { - runCases(t, []testCase{ - {"paragraphs and empty paragraph", doc(`{"type":"paragraph","content":[{"type":"text","text":"First"}]}`, `{"type":"paragraph","content":[]}`, `{"type":"paragraph","content":[{"type":"text","text":"Second "}]}`), "First\n\nSecond\n"}, - {"heading level 1", doc(`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, - {"heading level 2", doc(`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]}`), "## Title\n"}, - {"heading level 3", doc(`{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Title"}]}`), "### Title\n"}, - {"heading level 0 clamped to 1", doc(`{"type":"heading","attrs":{"level":0},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, - {"heading level -2 clamped to 1", doc(`{"type":"heading","attrs":{"level":-2},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, - {"heading level 8 clamped to 3", doc(`{"type":"heading","attrs":{"level":8},"content":[{"type":"text","text":"Title"}]}`), "### Title\n"}, - {"heading default level", doc(`{"type":"heading","content":[{"type":"text","text":"Default"}]}`), "# Default\n"}, - {"empty heading and paragraph", doc(`{"type":"heading","content":[]}`, `{"type":"paragraph","content":[]}`), ""}, - {"null document", `null`, ""}, - {"doc without content", `{"type":"doc"}`, ""}, - {"doc with empty content", doc(), ""}, - }) -} - -// Ported from the desktop app's markdown tests: "paragraph and heading newlines become spaces, including inline code, and outer spaces and tabs are trimmed". -// Covers validation contract (a). -func TestMarkdownInlineNewlinesAndOuterWhitespace(t *testing.T) { - runCases(t, []testCase{ - {"paragraph crlf in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\r\nright"}]}`), "Left right\n"}, - {"paragraph crlf in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\r\nright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, - {"paragraph crlf alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\r\n"}]}`), ""}, - {"paragraph crlf and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\r\n "}]}`), ""}, - {"paragraph cr in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\rright"}]}`), "Left right\n"}, - {"paragraph cr in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\rright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, - {"paragraph cr alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\r"}]}`), ""}, - {"paragraph cr and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\r "}]}`), ""}, - {"paragraph lf in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\nright"}]}`), "Left right\n"}, - {"paragraph lf in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\nright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, - {"paragraph lf alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\n"}]}`), ""}, - {"paragraph lf and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\n "}]}`), ""}, - {"paragraph outer spaces and tabs trimmed", doc(`{"type":"paragraph","content":[{"type":"text","text":" Indented \t"}]}`), "Indented\n"}, - {"paragraph spaces and tabs only", doc(`{"type":"paragraph","content":[{"type":"text","text":"\t "}]}`), ""}, - {"heading crlf in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\r\nright"}]}`), "# Left right\n"}, - {"heading crlf in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\r\nright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, - {"heading crlf alone", doc(`{"type":"heading","content":[{"type":"text","text":"\r\n"}]}`), ""}, - {"heading crlf and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\r\n "}]}`), ""}, - {"heading cr in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\rright"}]}`), "# Left right\n"}, - {"heading cr in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\rright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, - {"heading cr alone", doc(`{"type":"heading","content":[{"type":"text","text":"\r"}]}`), ""}, - {"heading cr and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\r "}]}`), ""}, - {"heading lf in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\nright"}]}`), "# Left right\n"}, - {"heading lf in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\nright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, - {"heading lf alone", doc(`{"type":"heading","content":[{"type":"text","text":"\n"}]}`), ""}, - {"heading lf and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\n "}]}`), ""}, - {"heading outer spaces and tabs trimmed", doc(`{"type":"heading","content":[{"type":"text","text":" Indented \t"}]}`), "# Indented\n"}, - {"heading spaces and tabs only", doc(`{"type":"heading","content":[{"type":"text","text":"\t "}]}`), ""}, - {"whitespace-only blocks omitted between paragraphs", doc(`{"type":"paragraph","content":[{"type":"text","text":"A"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"\n"}]}`, `{"type":"heading","content":[{"type":"text","text":"\n"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"B"}]}`), "A\n\nB\n"}, - {"empty paragraph inside list item omitted", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"\n"}]},{"type":"paragraph","content":[{"type":"text","text":" Kept "}]}]}]}`), "- Kept\n"}, - {"code block newlines and spaces verbatim", doc(`{"type":"codeBlock","content":[{"type":"text","text":" Left\r\nright\rnext\nlast "}]}`), "```\n Left\r\nright\rnext\nlast \n```\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "bullet and ordered lists, custom/default starts, nested and multi-block items, bare empty items". -// Covers validation contract (a). -func TestMarkdownBulletAndOrderedLists(t *testing.T) { - runCases(t, []testCase{ - {"bullet list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"One"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Two"}]}]}]}`), "- One\n- Two\n"}, - {"ordered list with custom start and multi-paragraph items", doc(`{"type":"orderedList","attrs":{"start":9,"ignored":true},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nine"}]},{"type":"paragraph","content":[{"type":"text","text":"Again"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Ten"}]},{"type":"paragraph","content":[{"type":"text","text":"Last"}]}]}]}`), "9. Nine\n\n Again\n10. Ten\n\n Last\n"}, - {"ordered list with default start and empty item", doc(`{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Default"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[]}]}]}`), "1. Default\n2.\n"}, - {"nested lists with empty paragraphs and bare empty item", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[]},{"type":"paragraph","content":[{"type":"text","text":"Parent"}]},{"type":"orderedList","attrs":{"start":3},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Child"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Leaf"}]}]}]}]}]},{"type":"paragraph","content":[]},{"type":"paragraph","content":[{"type":"text","text":"After"}]}]},{"type":"listItem","content":[]}]}`), "- Parent\n 3. Child\n - Leaf\n\n After\n-\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "code blocks preserve text, language, empty content and safe backtick fences". -// Covers validation contract (a). -func TestMarkdownCodeBlocks(t *testing.T) { - runCases(t, []testCase{ - {"code block with language", doc(`{"type":"codeBlock","attrs":{"language":"ts"},"content":[{"type":"text","text":"const x = 1"}]}`), "```ts\nconst x = 1\n```\n"}, - {"code block with triple backticks in text and null language", doc(`{"type":"codeBlock","attrs":{"language":null},"content":[{"type":"text","text":"before ` + "```" + ` after\n"}]}`), "````\nbefore ``` after\n\n````\n"}, - {"empty code block", doc(`{"type":"codeBlock"}`), "```\n```\n"}, - {"code block preserves outer spaces", doc(`{"type":"codeBlock","content":[{"type":"text","text":" x "}]}`), "```\n x \n```\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "inline code delimiters and padding handle embedded and edge backticks without escaping". -// Covers validation contract (a). -func TestMarkdownInlineCode(t *testing.T) { - runCases(t, []testCase{ - {"inline code without escaping", doc(`{"type":"paragraph","content":[{"type":"text","text":"x * y","marks":[{"type":"code"}]}]}`), "`x * y`\n"}, - {"inline code with embedded backtick", doc(`{"type":"paragraph","content":[{"type":"text","text":"a` + "`" + `b","marks":[{"type":"code"}]}]}`), "``a`b``\n"}, - {"inline code with edge backticks", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "`x`" + `","marks":[{"type":"code"}]}]}`), "`` `x` ``\n"}, - {"inline code with leading double backticks", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "``x" + `","marks":[{"type":"code"}]}]}`), "``` ``x ```\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "adjacent same-href text forms one link, code stays inside, destinations encode delimiters, absent href is plain text". -// Covers validation contract (a). -func TestMarkdownLinksAndDestinations(t *testing.T) { - runCases(t, []testCase{ - {"adjacent same-href nodes with inline code and encoded destination", doc(`{"type":"paragraph","content":[{"type":"text","text":"Read ","marks":[{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]},{"type":"text","text":"x","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]},{"type":"text","text":" now","marks":[{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]}]}`), "[Read `x` now](https://example.com/a%20b%28c%29%3Cd%3E)\n"}, - {"adjacent different-href links stay separate", doc(`{"type":"paragraph","content":[{"type":"text","text":"one","marks":[{"type":"link","attrs":{"href":"https://a.test"}}]},{"type":"text","text":"two","marks":[{"type":"link","attrs":{"href":"https://b.test"}}]}]}`), "[one](https://a.test)[two](https://b.test)\n"}, - {"empty and missing href render plain escaped text", doc(`{"type":"paragraph","content":[{"type":"text","text":"empty*","marks":[{"type":"link","attrs":{"href":""}}]},{"type":"text","text":"missing","marks":[{"type":"link","attrs":{}}]}]}`), "empty\\*missing\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "empty text and empty link runs render nothing while non-empty same-href runs remain linked". -// Covers validation contract (a). -func TestMarkdownEmptyTextAndLinkRuns(t *testing.T) { - runCases(t, []testCase{ - {"paragraph empty text no marks", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[]}]}`), ""}, - {"paragraph empty text code mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"code"}]}]}`), ""}, - {"paragraph empty text link mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"paragraph empty text code and link marks", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"paragraph multiple empty link nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"heading empty text no marks", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[]}]}`), ""}, - {"heading empty text code mark", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"code"}]}]}`), ""}, - {"heading empty text link mark", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"heading empty text code and link marks", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"heading multiple empty link nodes", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"empty link between plain text nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"Before"},{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":" after"}]}`), "Before after\n"}, - {"non-empty link surrounded by empty same-href nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"Guide","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), "[Guide](https://example.com)\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "every live block kind with and without attributes, job zero and an empty factory, unknown kinds". -// Covers validation contract (a). -func TestMarkdownLiveBlocks(t *testing.T) { - runCases(t, []testCase{ - {"all four kinds without attributes", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"decision","milestone_id":null,"factory":null,"job":null,"record_id":null}}`), "[live: jobs]\n\n[live: waves]\n\n[live: job]\n\n[live: decision]\n"}, - {"all four kinds with attributes", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":"m1","factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":"m2","factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":"api","job":1107,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"decision","milestone_id":null,"factory":null,"job":null,"record_id":"r1"}}`), "[live: jobs milestone=m1]\n\n[live: waves milestone=m2]\n\n[live: job api#1107]\n\n[live: decision r1]\n"}, - {"job zero without factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":null,"job":0,"record_id":null}}`), "[live: job #0]\n"}, - {"factory without job number", doc(`{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":"api","job":null,"record_id":null}}`), "[live: job]\n"}, - {"unknown kind and missing attrs", doc(`{"type":"liveBlock","attrs":{"kind":"canvas"}}`, `{"type":"liveBlock"}`), "[live: canvas]\n\n[live: unknown]\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "live references require non-empty strings and job numbers require non-negative integers". -// Covers validation contract (a). -func TestMarkdownLiveBlockAttributeValidation(t *testing.T) { - // JSON cannot carry NaN, so the NaN row from the TypeScript loop is omitted; - // 1e400 and -1e400 cover positive and negative infinity after JSON parsing. - runCases(t, []testCase{ - {"jobs milestone empty string", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":""}}`), "[live: jobs]\n"}, - {"waves milestone empty string", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":""}}`), "[live: waves]\n"}, - {"decision record empty string", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":""}}`), "[live: decision]\n"}, - {"job factory empty string", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"","job":7}}`), "[live: job #7]\n"}, - {"jobs milestone number", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":42}}`), "[live: jobs]\n"}, - {"waves milestone number", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":42}}`), "[live: waves]\n"}, - {"decision record number", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":42}}`), "[live: decision]\n"}, - {"job factory number", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":42,"job":7}}`), "[live: job #7]\n"}, - {"jobs milestone false", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":false}}`), "[live: jobs]\n"}, - {"waves milestone false", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":false}}`), "[live: waves]\n"}, - {"decision record false", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":false}}`), "[live: decision]\n"}, - {"job factory false", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":false,"job":7}}`), "[live: job #7]\n"}, - {"jobs milestone object", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":{}}}`), "[live: jobs]\n"}, - {"waves milestone object", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":{}}}`), "[live: waves]\n"}, - {"decision record object", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":{}}}`), "[live: decision]\n"}, - {"job factory object", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":{},"job":7}}`), "[live: job #7]\n"}, - {"jobs milestone null", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":null}}`), "[live: jobs]\n"}, - {"waves milestone null", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":null}}`), "[live: waves]\n"}, - {"decision record null", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":null}}`), "[live: decision]\n"}, - {"job factory null", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":null,"job":7}}`), "[live: job #7]\n"}, - {"job string", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":"7"}}`), "[live: job]\n"}, - {"job fractional", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1.5}}`), "[live: job]\n"}, - {"job negative", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-1}}`), "[live: job]\n"}, - {"job positive infinity", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1e400}}`), "[live: job]\n"}, - {"job negative infinity", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-1e400}}`), "[live: job]\n"}, - {"job boolean", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":false}}`), "[live: job]\n"}, - {"job null", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":null}}`), "[live: job]\n"}, - {"job without factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","job":7}}`), "[live: job #7]\n"}, - {"job zero with factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":0}}`), "[live: job api#0]\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "every escaped character and paragraph line-start case, also inside lists". -// Covers validation contract (a). -func TestMarkdownEscapingAndParagraphLineStarts(t *testing.T) { - runCases(t, []testCase{ - {"escape backslash", doc(`{"type":"paragraph","content":[{"type":"text","text":"\\"}]}`), "\\\\\n"}, - {"escape backtick", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "`" + `"}]}`), "\\`\n"}, - {"escape asterisk", doc(`{"type":"paragraph","content":[{"type":"text","text":"*"}]}`), "\\*\n"}, - {"escape underscore", doc(`{"type":"paragraph","content":[{"type":"text","text":"_"}]}`), "\\_\n"}, - {"escape open bracket", doc(`{"type":"paragraph","content":[{"type":"text","text":"["}]}`), "\\[\n"}, - {"escape close bracket", doc(`{"type":"paragraph","content":[{"type":"text","text":"]"}]}`), "\\]\n"}, - {"escape less than", doc(`{"type":"paragraph","content":[{"type":"text","text":"<"}]}`), "\\<\n"}, - {"escape greater than", doc(`{"type":"paragraph","content":[{"type":"text","text":">"}]}`), "\\>\n"}, - {"line start hash", doc(`{"type":"paragraph","content":[{"type":"text","text":"# title"}]}`), "\\# title\n"}, - {"line start hash after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" # title\t "}]}`), "\\# title\n"}, - {"line start hash in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"# title"}]}]}]}`), "- \\# title\n"}, - {"line start minus", doc(`{"type":"paragraph","content":[{"type":"text","text":"- bullet"}]}`), "\\- bullet\n"}, - {"line start minus after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" - bullet\t "}]}`), "\\- bullet\n"}, - {"line start minus in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"- bullet"}]}]}]}`), "- \\- bullet\n"}, - {"line start plus", doc(`{"type":"paragraph","content":[{"type":"text","text":"+ bullet"}]}`), "\\+ bullet\n"}, - {"line start plus after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" + bullet\t "}]}`), "\\+ bullet\n"}, - {"line start plus in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"+ bullet"}]}]}]}`), "- \\+ bullet\n"}, - {"line start quote", doc(`{"type":"paragraph","content":[{"type":"text","text":"> quote"}]}`), "\\> quote\n"}, - {"line start quote after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" > quote\t "}]}`), "\\> quote\n"}, - {"line start quote in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"> quote"}]}]}]}`), "- \\> quote\n"}, - {"line start dot ordered", doc(`{"type":"paragraph","content":[{"type":"text","text":"12. list"}]}`), "12\\. list\n"}, - {"line start dot ordered after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" 12. list\t "}]}`), "12\\. list\n"}, - {"line start dot ordered in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"12. list"}]}]}]}`), "- 12\\. list\n"}, - {"line start paren ordered", doc(`{"type":"paragraph","content":[{"type":"text","text":"42) list"}]}`), "42\\) list\n"}, - {"line start paren ordered after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" 42) list\t "}]}`), "42\\) list\n"}, - {"line start paren ordered in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"42) list"}]}]}]}`), "- 42\\) list\n"}, - {"mid-paragraph markers after normalized newlines", doc(`{"type":"paragraph","content":[{"type":"text","text":"one \n# two\n3. three "}]}`), "one # two 3. three\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "unknown blocks flatten descendant text, unknown inline nodes render their own text, unknown marks and malformed JSON never throw". -// Covers validation contract (a) and (d). -func TestMarkdownUnknownNodesMarksAndMalformedShapes(t *testing.T) { - runCases(t, []testCase{ - {"unknown block flattens descendant text", doc(`{"type":"future","content":[{"type":"paragraph","content":[{"type":"text","text":"a*"}]},{"type":"other","content":[{"type":"text","text":"b_"}]}]}`), "a\\*b\\_\n"}, - {"unknown inline node renders own text and unknown mark is ignored", doc(`{"type":"paragraph","content":[{"type":"futureInline","text":"","content":[{"type":"text","text":"ignored"}]},{"type":"text","text":" ok","marks":[{"type":"futureMark"}]}]}`), "\\ ok\n"}, - {"empty unknown block omitted", doc(`{"type":"future"}`), ""}, - {"top-level null", `null`, ""}, - {"top-level false", `false`, ""}, - {"top-level number", `7`, ""}, - {"top-level string", `"wrong"`, ""}, - {"top-level array", `[]`, ""}, - {"top-level empty object", `{}`, ""}, - {"malformed content elements and non-string text", `{"type":"doc","content":[null,3,{},{"type":"paragraph","content":[null,{"type":"text","text":7,"marks":[null]}]}]}`, ""}, - {"codeBlock with null attrs and non-array content", `{"type":"codeBlock","attrs":null,"content":"bad"}`, "```\n```\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "whole-document fixture is the wire contract's exact JSON and markdown example". -// Covers validation contract (a) and (b). -func TestMarkdownRendersTheContractExample(t *testing.T) { - rawJSON, err := os.ReadFile(filepath.Join("testdata", "example.json")) - if err != nil { - t.Fatalf("read example.json: %v", err) - } - rawMD, err := os.ReadFile(filepath.Join("testdata", "example.md")) - if err != nil { - t.Fatalf("read example.md: %v", err) - } - example := map[string]any{"type": "doc", "content": []any{ - map[string]any{"type": "heading", "attrs": map[string]any{"level": float64(1)}, "content": []any{map[string]any{"type": "text", "text": "Spec"}}}, - map[string]any{"type": "paragraph", "content": []any{ - map[string]any{"type": "text", "text": "Keep "}, - map[string]any{"type": "text", "text": "event_id", "marks": []any{map[string]any{"type": "code"}}}, - map[string]any{"type": "text", "text": " and "}, - map[string]any{"type": "text", "text": "guide", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://example.com/spec"}}}}, - map[string]any{"type": "text", "text": "."}, - }}, - map[string]any{"type": "bulletList", "content": []any{ - map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Retry safely"}}}}}, - map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Record outcomes"}}}}}, - }}, - map[string]any{"type": "orderedList", "attrs": map[string]any{"start": float64(3)}, "content": []any{ - map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Ship"}}}}}, - }}, - map[string]any{"type": "codeBlock", "attrs": map[string]any{"language": "ts"}, "content": []any{map[string]any{"type": "text", "text": "send(event)"}}}, - map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "jobs", "milestone_id": "m1", "factory": nil, "job": nil, "record_id": nil}}, - map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "waves", "milestone_id": nil, "factory": nil, "job": nil, "record_id": nil}}, - map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "job", "milestone_id": nil, "factory": "api", "job": float64(1107), "record_id": nil}}, - map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "decision", "milestone_id": nil, "factory": nil, "job": nil, "record_id": "r1"}}, - }} - var decoded any - if err := json.Unmarshal(rawJSON, &decoded); err != nil { - t.Fatalf("unmarshal example.json: %v", err) - } - if !reflect.DeepEqual(decoded, example) { - t.Errorf("example.json differs from the reference example") - } - const expected = "# Spec\n\nKeep `event_id` and [guide](https://example.com/spec).\n\n- Retry safely\n- Record outcomes\n\n3. Ship\n\n```ts\nsend(event)\n```\n\n[live: jobs milestone=m1]\n\n[live: waves]\n\n[live: job api#1107]\n\n[live: decision r1]\n" - if string(rawMD) != expected { - t.Errorf("example.md: got %q, want %q", string(rawMD), expected) - } - got, err := Markdown(json.RawMessage(rawJSON)) - if err != nil { - t.Fatalf("Markdown(example.json): unexpected error: %v", err) - } - if got != string(rawMD) { - t.Errorf("Markdown(example.json): got %q, want %q", got, string(rawMD)) - } -} - -// Covers validation contract (c). -func TestMarkdownRefusesInvalidJSON(t *testing.T) { - cases := []struct { - name, input string - }{ - {"unclosed brace", `{`}, - {"trailing comma", `{"type":"doc",}`}, - {"bare word", `nope`}, - {"trailing token", `{"type":"doc"} x`}, - {"unclosed array", `[1,2`}, - {"unclosed content array", `{"type":"doc","content":[`}, - {"nesting past encoding/json limit", strings.Repeat("[", 10001) + strings.Repeat("]", 10001)}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := Markdown(json.RawMessage(tc.input)) - if err == nil { - t.Fatalf("%s: expected error, got nil (output %q)", tc.name, got) - } - if !strings.HasPrefix(err.Error(), "pagedoc: ") { - t.Errorf("%s: error %q does not start with %q", tc.name, err.Error(), "pagedoc: ") - } - if got != "" { - t.Errorf("%s: got %q, want %q", tc.name, got, "") - } - }) - } -} - -// Covers validation contract (c). -func TestMarkdownTreatsAnAbsentDocumentAsEmpty(t *testing.T) { - cases := []struct { - name string - input json.RawMessage - }{ - {"nil", nil}, - {"empty", json.RawMessage("")}, - {"whitespace only", json.RawMessage(" \n")}, - {"json null", json.RawMessage("null")}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := Markdown(tc.input) - if err != nil { - t.Fatalf("%s: unexpected error: %v", tc.name, err) - } - if got != "" { - t.Errorf("%s: got %q, want %q", tc.name, got, "") - } - }) - } -} - -// Covers validation contract (c). -func TestMarkdownRendersADeepDocumentWithoutPanicking(t *testing.T) { - deep := strings.Repeat(`{"type":"future","content":[`, 4000) + `{"type":"text","text":"x"}` + strings.Repeat(`]}`, 4000) - got, err := Markdown(json.RawMessage(deep)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "x\n" { - t.Errorf("deep document: got %q, want %q", got, "x\n") - } -} - -// Covers validation contract (d) and (e). -func TestMarkdownMatchesTheReferenceOnEdgeCases(t *testing.T) { - runCases(t, []testCase{ - {"1 line separator u2028 triggers line-start escape", doc(`{"type":"paragraph","content":[{"type":"text","text":"a\u2028# b"}]}`), "a\u2028\\# b\n"}, - {"2 newline inside link destination triggers line-start escape", doc(`{"type":"paragraph","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"a\n- b"}}]}]}`), "[x](a\n\\-%20b)\n"}, - {"3 bulletList with direct paragraph child", doc(`{"type":"bulletList","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}`), "- a\n"}, - {"4 bulletList with null child", doc(`{"type":"bulletList","content":[null]}`), "-\n"}, - {"5 orderedList with null children", doc(`{"type":"orderedList","content":[null,null]}`), "1.\n2.\n"}, - {"6 unknown inline node with code mark and embedded backtick", doc(`{"type":"paragraph","content":[{"type":"mention","text":"a` + "`" + `b","marks":[{"type":"code"}]}]}`), "``a`b``\n"}, - {"7 unknown inline node with link mark breaks link run", doc(`{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]},{"type":"x","text":"b","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]},{"type":"text","text":"c","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]}]}`), "[a](https://e.x)b[c](https://e.x)\n"}, - {"8 first link mark with empty href hides later link mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"link","attrs":{"href":""}},{"type":"link","attrs":{"href":"https://x"}}]}]}`), "a\n"}, - {"9 text node at block level", doc(`{"type":"text","text":"*a*"}`), "\\*a\\*\n"}, - {"10 nested doc inside doc", doc(doc(`{"type":"paragraph","content":[{"type":"text","text":"a"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"b"}]}`)), "a\n\nb\n"}, - {"11 bare paragraph root without doc", `{"type":"paragraph","content":[{"type":"text","text":"x"}]}`, "x\n"}, - {"12 lf followed by cr becomes two spaces", doc(`{"type":"paragraph","content":[{"type":"text","text":"a\n\rb"}]}`), "a b\n"}, - {"13 non-breaking spaces preserved at paragraph edges", doc(`{"type":"paragraph","content":[{"type":"text","text":"\u00a0x\u00a0"}]}`), "\u00a0x\u00a0\n"}, - {"14 heading text starting with hash is not line-start escaped", doc(`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"# x"}]}`), "# # x\n"}, - {"15 codeBlock collects nested descendant text", doc(`{"type":"codeBlock","content":[{"type":"text","text":"a"},{"type":"x","text":"b","content":[{"type":"text","text":"c"}]}]}`), "```\nabc\n```\n"}, - {"16 codeBlock ignores non-string language", doc(`{"type":"codeBlock","attrs":{"language":5},"content":[{"type":"text","text":"x"}]}`), "```\nx\n```\n"}, - {"17a liveBlock empty kind", doc(`{"type":"liveBlock","attrs":{"kind":""}}`), "[live: unknown]\n"}, - {"17b liveBlock null attrs", doc(`{"type":"liveBlock","attrs":null}`), "[live: unknown]\n"}, - {"17c liveBlock array attrs", doc(`{"type":"liveBlock","attrs":[]}`), "[live: unknown]\n"}, - {"18 bulletList codeBlock with crlf indents cr line", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"codeBlock","content":[{"type":"text","text":"a\r\nb"}]}]}]}`), "- ```\n a\r\n b\n ```\n"}, - {"19 bulletList codeBlock with blank line keeps blank line empty", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"codeBlock","content":[{"type":"text","text":"a\n\nb"}]}]}]}`), "- ```\n a\n\n b\n ```\n"}, - {"20 orderedList start 1e21 uses exponential format", doc(`{"type":"orderedList","attrs":{"start":1e21},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1e+21. x\n"}, - {"21 orderedList start 1e20 uses fixed format", doc(`{"type":"orderedList","attrs":{"start":1e20},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "100000000000000000000. x\n"}, - {"22 orderedList start -0 renders as 0", doc(`{"type":"orderedList","attrs":{"start":-0},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}`), "0. a\n1. b\n"}, - {"23 orderedList start -2 counts up through negative integers", doc(`{"type":"orderedList","attrs":{"start":-2},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}`), "-2. a\n-1. b\n"}, - {"24a orderedList fractional start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":2.5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, - {"24b orderedList string start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":"3"},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, - {"24c orderedList infinite start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":1e400},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, - {"25 orderedList start 3.0 is accepted as integer", doc(`{"type":"orderedList","attrs":{"start":3.0},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "3. x\n"}, - {"26 liveBlock job -0 renders as 0", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-0}}`), "[live: job api#0]\n"}, - {"27 liveBlock job 1e21 uses exponential format", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1e21}}`), "[live: job api#1e+21]\n"}, - {"28 liveBlock job 3.0 renders as 3", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":3.0}}`), "[live: job api#3]\n"}, - {"29 heading level 2.9 truncates to 2", doc(`{"type":"heading","attrs":{"level":2.9},"content":[{"type":"text","text":"T"}]}`), "## T\n"}, - {"30a heading string level falls back to 1", doc(`{"type":"heading","attrs":{"level":"2"},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, - {"30b heading infinite level falls back to 1", doc(`{"type":"heading","attrs":{"level":1e400},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, - {"30c heading level -0.5 clamps to 1", doc(`{"type":"heading","attrs":{"level":-0.5},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, - }) -} diff --git a/internal/workstream/pagedoc/testdata/example.json b/internal/workstream/pagedoc/testdata/example.json deleted file mode 100644 index a8cfa438e9..0000000000 --- a/internal/workstream/pagedoc/testdata/example.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "type": "doc", - "content": [ - { "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Spec" }] }, - { "type": "paragraph", "content": [ - { "type": "text", "text": "Keep " }, - { "type": "text", "text": "event_id", "marks": [{ "type": "code" }] }, - { "type": "text", "text": " and " }, - { "type": "text", "text": "guide", "marks": [{ "type": "link", "attrs": { "href": "https://example.com/spec" } }] }, - { "type": "text", "text": "." } - ] }, - { "type": "bulletList", "content": [ - { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Retry safely" }] }] }, - { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Record outcomes" }] }] } - ] }, - { "type": "orderedList", "attrs": { "start": 3 }, "content": [ - { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Ship" }] }] } - ] }, - { "type": "codeBlock", "attrs": { "language": "ts" }, "content": [{ "type": "text", "text": "send(event)" }] }, - { "type": "liveBlock", "attrs": { "kind": "jobs", "milestone_id": "m1", "factory": null, "job": null, "record_id": null } }, - { "type": "liveBlock", "attrs": { "kind": "waves", "milestone_id": null, "factory": null, "job": null, "record_id": null } }, - { "type": "liveBlock", "attrs": { "kind": "job", "milestone_id": null, "factory": "api", "job": 1107, "record_id": null } }, - { "type": "liveBlock", "attrs": { "kind": "decision", "milestone_id": null, "factory": null, "job": null, "record_id": "r1" } } - ] -} diff --git a/internal/workstream/pagedoc/testdata/example.md b/internal/workstream/pagedoc/testdata/example.md deleted file mode 100644 index 36608d30f2..0000000000 --- a/internal/workstream/pagedoc/testdata/example.md +++ /dev/null @@ -1,20 +0,0 @@ -# Spec - -Keep `event_id` and [guide](https://example.com/spec). - -- Retry safely -- Record outcomes - -3. Ship - -```ts -send(event) -``` - -[live: jobs milestone=m1] - -[live: waves] - -[live: job api#1107] - -[live: decision r1] diff --git a/internal/workstream/pagedoc/testdata/suggestion-keep.json b/internal/workstream/pagedoc/testdata/suggestion-keep.json deleted file mode 100644 index 08a391447f..0000000000 --- a/internal/workstream/pagedoc/testdata/suggestion-keep.json +++ /dev/null @@ -1,306 +0,0 @@ -{ - "_about": "WP-6d shared fixture: identical bytes in the engine and the desk. doc_before + every edit kept in document order = doc_after; keeping only e3 = doc_after_e3. Offsets count Unicode code points. markdown_* are the exact renderer outputs.", - "doc_before": { - "type": "doc", - "content": [ - { - "type": "heading", - "attrs": { - "level": 2 - }, - "content": [ - { - "type": "text", - "text": "Signing" - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Every request carries " - }, - { - "type": "text", - "text": "X-Signature", - "marks": [ - { - "type": "code" - } - ] - }, - { - "type": "text", - "text": ", an HMAC 🔏 of the café body. Retries reuse the first signature." - } - ] - }, - { - "type": "bulletList", - "content": [ - { - "type": "listItem", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Partners verify within 5 minutes of the " - }, - { - "type": "text", - "text": "timestamp", - "marks": [ - { - "type": "link", - "attrs": { - "href": "https://example.com/ts" - } - } - ] - }, - { - "type": "text", - "text": "." - } - ] - } - ] - } - ] - }, - { - "type": "codeBlock", - "attrs": { - "language": "go" - }, - "content": [ - { - "type": "text", - "text": "sign(body)\nsend()" - } - ] - } - ] - }, - "edits": [ - { - "id": "e1", - "find": "Signing", - "replace": "Signing and retries", - "why": "Covers retries too", - "block": [ - 0 - ], - "offset": 7, - "delete": "", - "insert": " and retries", - "block_text": "Signing" - }, - { - "id": "e2", - "find": "Retries reuse the first signature.", - "replace": "Retries are signed again at send time.", - "why": "Retries are signed at send time since api#1107", - "block": [ - 1 - ], - "offset": 71, - "delete": "reuse the first signature.", - "insert": "are signed again at send time.", - "block_text": "Every request carries X-Signature, an HMAC 🔏 of the café body. Retries reuse the first signature." - }, - { - "id": "e3", - "find": "within 5 minutes", - "replace": "within 10 minutes", - "why": "The backoff cap is now 10 minutes", - "block": [ - 2, - 0, - 0 - ], - "offset": 23, - "delete": "5", - "insert": "10", - "block_text": "Partners verify within 5 minutes of the timestamp." - } - ], - "doc_after": { - "type": "doc", - "content": [ - { - "type": "heading", - "attrs": { - "level": 2 - }, - "content": [ - { - "type": "text", - "text": "Signing and retries" - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Every request carries " - }, - { - "type": "text", - "text": "X-Signature", - "marks": [ - { - "type": "code" - } - ] - }, - { - "type": "text", - "text": ", an HMAC 🔏 of the café body. Retries are signed again at send time." - } - ] - }, - { - "type": "bulletList", - "content": [ - { - "type": "listItem", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Partners verify within 10 minutes of the " - }, - { - "type": "text", - "text": "timestamp", - "marks": [ - { - "type": "link", - "attrs": { - "href": "https://example.com/ts" - } - } - ] - }, - { - "type": "text", - "text": "." - } - ] - } - ] - } - ] - }, - { - "type": "codeBlock", - "attrs": { - "language": "go" - }, - "content": [ - { - "type": "text", - "text": "sign(body)\nsend()" - } - ] - } - ] - }, - "doc_after_e3": { - "type": "doc", - "content": [ - { - "type": "heading", - "attrs": { - "level": 2 - }, - "content": [ - { - "type": "text", - "text": "Signing" - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Every request carries " - }, - { - "type": "text", - "text": "X-Signature", - "marks": [ - { - "type": "code" - } - ] - }, - { - "type": "text", - "text": ", an HMAC 🔏 of the café body. Retries reuse the first signature." - } - ] - }, - { - "type": "bulletList", - "content": [ - { - "type": "listItem", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Partners verify within 10 minutes of the " - }, - { - "type": "text", - "text": "timestamp", - "marks": [ - { - "type": "link", - "attrs": { - "href": "https://example.com/ts" - } - } - ] - }, - { - "type": "text", - "text": "." - } - ] - } - ] - } - ] - }, - { - "type": "codeBlock", - "attrs": { - "language": "go" - }, - "content": [ - { - "type": "text", - "text": "sign(body)\nsend()" - } - ] - } - ] - }, - "markdown_before": "## Signing\n\nEvery request carries `X-Signature`, an HMAC 🔏 of the café body. Retries reuse the first signature.\n\n- Partners verify within 5 minutes of the [timestamp](https://example.com/ts).\n\n```go\nsign(body)\nsend()\n```\n", - "markdown_after": "## Signing and retries\n\nEvery request carries `X-Signature`, an HMAC 🔏 of the café body. Retries are signed again at send time.\n\n- Partners verify within 10 minutes of the [timestamp](https://example.com/ts).\n\n```go\nsign(body)\nsend()\n```\n" -} diff --git a/internal/workstream/pagedoc/textblocks.go b/internal/workstream/pagedoc/textblocks.go deleted file mode 100644 index 20034a62cb..0000000000 --- a/internal/workstream/pagedoc/textblocks.go +++ /dev/null @@ -1,275 +0,0 @@ -package pagedoc - -import ( - "bytes" - "encoding/json" - "errors" - "maps" - "reflect" - "slices" - "strings" - "unicode" - "unicode/utf8" -) - -// TextBlock retains the editor node so a keep changes only its text content. -type TextBlock struct { - Path []int - Kind string - Text string - Node map[string]any -} - -// Decode preserves exact JSON numbers, including unknown editor attributes. -func Decode(raw json.RawMessage) (map[string]any, error) { - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - var root map[string]any - err := decoder.Decode(&root) - if err == nil && root == nil { - err = errors.New("pagedoc: expected a document object") - } - return root, err -} - -func TextBlocks(doc map[string]any) []TextBlock { - blocks := make([]TextBlock, 0) - var visit func(map[string]any, []int) - visit = func(node map[string]any, path []int) { - kind, _ := node["type"].(string) - if kind == "paragraph" || kind == "heading" || kind == "codeBlock" { - blocks = append(blocks, TextBlock{Path: slices.Clone(path), Kind: kind, Text: nodeText(node), Node: node}) - } - content, _ := node["content"].([]any) - for i, child := range content { - visit(object(child), append(slices.Clone(path), i)) - } - } - visit(doc, []int{}) - return blocks -} - -func nodeText(node map[string]any) string { - if node["type"] == "text" { - text, _ := node["text"].(string) - return text - } - var text strings.Builder - for _, child := range children(node) { - text.WriteString(nodeText(child)) - } - return text.String() -} - -// Locate tries the stored path before the unique whole-block text fallback. -func Locate(doc map[string]any, path []int, text string) (TextBlock, bool) { - blocks := TextBlocks(doc) - for _, block := range blocks { - if slices.Equal(block.Path, path) && block.Text == text { - return block, true - } - } - var match TextBlock - count := 0 - for _, block := range blocks { - if block.Text == text { - match = block - count++ - } - } - return match, count == 1 -} - -// TrimEdit trims only at whitespace word boundaries in both strings. -func TrimEdit(find, replace string) (offset int, del, ins string) { - a, b := []rune(find), []rune(replace) - p := 0 - for p < len(a) && p < len(b) && a[p] == b[p] { - p++ - } - for !wordBoundary(a, p) || !wordBoundary(b, p) { - p-- - } - s := 0 - for s < len(a)-p && s < len(b)-p && a[len(a)-1-s] == b[len(b)-1-s] { - s++ - } - for !wordBoundary(a, len(a)-s) || !wordBoundary(b, len(b)-s) { - s-- - } - return p, string(a[p : len(a)-s]), string(b[p : len(b)-s]) -} - -func wordBoundary(s []rune, p int) bool { - return p == 0 || p == len(s) || unicode.IsSpace(s[p-1]) || unicode.IsSpace(s[p]) -} - -// Apply rewrites a located block. Offsets and deletion lengths are code points. -func Apply(block TextBlock, offset int, del, ins string) error { - text, removed := []rune(block.Text), []rune(del) - if offset < 0 || offset+len(removed) > len(text) || string(text[offset:offset+len(removed)]) != del { - return errors.New("pagedoc: edit text does not match") - } - if block.Kind != "codeBlock" && strings.ContainsAny(ins, "\r\n") { - return errors.New("pagedoc: line break outside code block") - } - var inherited []any - if len(removed) > 0 { - at := offset - inherited, _ = characterMarks(block.Node, &at) - } else if offset > 0 && offset < len(text) { - leftAt, rightAt := offset-1, offset - left, _ := characterMarks(block.Node, &leftAt) - right, _ := characterMarks(block.Node, &rightAt) - inherited = commonMarks(left, right) - } - edit := textRewrite{start: offset, end: offset + len(removed), insert: ins, marks: inherited} - edit.content(block.Node) - if !edit.inserted && ins != "" { - content, _ := block.Node["content"].([]any) - block.Node["content"] = mergeText(append(content, edit.insertion())) - } - return nil -} - -// characterMarks stops at the requested character without allocating per rune. -func characterMarks(node map[string]any, at *int) ([]any, bool) { - if node["type"] == "text" { - text, _ := node["text"].(string) - length := utf8.RuneCountInString(text) - if *at < length { - marks, _ := node["marks"].([]any) - return marks, true - } - *at -= length - return nil, false - } - for _, child := range children(node) { - if marks, found := characterMarks(child, at); found { - return marks, true - } - } - return nil, false -} - -func commonMarks(a, b []any) []any { - var out []any - for _, mark := range a { - if slices.ContainsFunc(b, func(other any) bool { return reflect.DeepEqual(mark, other) }) { - out = append(out, mark) - } - } - return out -} - -type textRewrite struct { - start, end, position int - insert string - marks []any - inserted bool -} - -func (e *textRewrite) insertion() map[string]any { - node := map[string]any{"type": "text", "text": e.insert} - if len(e.marks) > 0 { - node["marks"] = e.marks - } - return node -} - -func (e *textRewrite) content(node map[string]any) { - content, exists := node["content"].([]any) - if !exists { - return - } - out := make([]any, 0, len(content)) - for _, raw := range content { - child := object(raw) - if child["type"] == "text" { - out = append(out, e.leaf(child)...) - } else { - e.content(child) - out = append(out, raw) - } - } - node["content"] = mergeText(out) -} - -func (e *textRewrite) leaf(node map[string]any) []any { - text, _ := node["text"].(string) - runes := []rune(text) - start, end := e.position, e.position+len(runes) - e.position = end - if end < e.start || start > e.end { - return []any{node} - } - out := []any{} - appendText := func(text string) { - if text != "" { - part := maps.Clone(node) - part["text"] = text - out = append(out, part) - } - } - left := min(len(runes), max(0, e.start-start)) - appendText(string(runes[:left])) - if !e.inserted && e.start >= start && e.start <= end { - e.inserted = true - if e.insert != "" { - out = append(out, e.insertion()) - } - } - right := min(len(runes), max(0, e.end-start)) - appendText(string(runes[right:])) - return out -} - -func mergeText(content []any) []any { - out := make([]any, 0, len(content)) - for _, raw := range content { - node := object(raw) - if node["type"] != "text" { - out = append(out, raw) - continue - } - text, _ := node["text"].(string) - if text == "" { - continue - } - if len(out) > 0 { - previous := object(out[len(out)-1]) - if previous["type"] == "text" && sameTextFields(previous, node) { - previous["text"] = previous["text"].(string) + text - continue - } - } - out = append(out, raw) - } - return out -} - -func sameTextFields(a, b map[string]any) bool { - aMarks, _ := a["marks"].([]any) - bMarks, _ := b["marks"].([]any) - if !slices.EqualFunc(aMarks, bMarks, reflect.DeepEqual) { - return false - } - for key, value := range a { - if key == "text" || key == "marks" { - continue - } - other, exists := b[key] - if !exists || !reflect.DeepEqual(value, other) { - return false - } - } - for key := range b { - if key == "text" || key == "marks" { - continue - } - if _, exists := a[key]; !exists { - return false - } - } - return true -} diff --git a/internal/workstream/pagedoc/textblocks_test.go b/internal/workstream/pagedoc/textblocks_test.go deleted file mode 100644 index c7350cb435..0000000000 --- a/internal/workstream/pagedoc/textblocks_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package pagedoc - -import ( - "encoding/json" - "os" - "reflect" - "slices" - "testing" -) - -// WP-6d (g): the shared fixture fixes trimming, code-point offsets, JSON and Markdown. -func TestSuggestionKeepSharedFixture(t *testing.T) { - raw, err := os.ReadFile("testdata/suggestion-keep.json") - if err != nil { - t.Fatal(err) - } - var fixture struct { - Before json.RawMessage `json:"doc_before"` - After json.RawMessage `json:"doc_after"` - OnlyE3 json.RawMessage `json:"doc_after_e3"` - MarkdownBefore string `json:"markdown_before"` - MarkdownAfter string `json:"markdown_after"` - Edits []struct { - ID, Find, Replace, Delete, Insert string - Block []int - Offset int - BlockText string `json:"block_text"` - } `json:"edits"` - } - if err := json.Unmarshal(raw, &fixture); err != nil { - t.Fatal(err) - } - root, err := Decode(fixture.Before) - if err != nil { - t.Fatal(err) - } - for _, edit := range fixture.Edits { - block, ok := Locate(root, edit.Block, edit.BlockText) - if !ok || !slices.Equal(block.Path, edit.Block) { - t.Fatalf("%s block: %+v", edit.ID, block) - } - prefix, del, ins := TrimEdit(edit.Find, edit.Replace) - // Locate the quoted text in code points, independently of byte indexes. - text, find := []rune(block.Text), []rune(edit.Find) - index := -1 - for i := 0; i+len(find) <= len(text); i++ { - if slices.Equal(text[i:i+len(find)], find) { - index = i - break - } - } - if index < 0 || index+prefix != edit.Offset || del != edit.Delete || ins != edit.Insert { - t.Fatalf("%s trim: offset=%d delete=%q insert=%q", edit.ID, index+prefix, del, ins) - } - if err := Apply(block, edit.Offset, del, ins); err != nil { - t.Fatal(err) - } - } - want, err := Decode(fixture.After) - if err != nil || !reflect.DeepEqual(root, want) { - t.Fatalf("all edits: %+v; %v", root, err) - } - root, err = Decode(fixture.Before) - if err != nil { - t.Fatal(err) - } - e3 := fixture.Edits[2] - block, ok := Locate(root, e3.Block, e3.BlockText) - if !ok { - t.Fatal("e3 missing") - } - if err := Apply(block, e3.Offset, e3.Delete, e3.Insert); err != nil { - t.Fatal(err) - } - want, err = Decode(fixture.OnlyE3) - if err != nil || !reflect.DeepEqual(root, want) { - t.Fatal("e3 changed other text", err) - } - for _, test := range []struct { - doc json.RawMessage - markdown string - }{{fixture.Before, fixture.MarkdownBefore}, {fixture.After, fixture.MarkdownAfter}} { - got, err := Markdown(test.doc) - if err != nil || got != test.markdown { - t.Fatalf("Markdown: %q; %v", got, err) - } - } -} - -// WP-6d (g), Tool: trim stops at word boundaries, including pure insertion. -func TestSuggestionWordBoundaryTrim(t *testing.T) { - for _, test := range []struct { - find, replace string - at int - del, ins string - }{ - {"within 5 minutes", "within 10 minutes", 7, "5", "10"}, - {"Retries reuse the first signature.", "Retries are signed again at send time.", 8, "reuse the first signature.", "are signed again at send time."}, - {"Signing", "Signing and retries", 7, "", " and retries"}, - {"foobar", "foobaz", 0, "foobar", "foobaz"}, - {"α β γ", "α δ γ", 2, "β", "δ"}, - } { - at, del, ins := TrimEdit(test.find, test.replace) - if at != test.at || del != test.del || ins != test.ins { - t.Errorf("%q: %d %q %q", test.find, at, del, ins) - } - } -} - -// WP-6d (g): replacement inherits first-deleted marks, insertion intersects -// both sides, ends inherit none, empty nodes vanish and exact numbers survive. -func TestSuggestionKeepMarksAndUnknownAttributes(t *testing.T) { - for _, test := range []struct { - name string - at int - del, ins, content string - }{ - {"replacement", 1, "bc", "X", `[{"type":"text","text":"aX","marks":[{"type":"code"}]},{"type":"text","text":"d","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, - {"intersection", 2, "", "X", `[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"X"},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, - {"end", 4, "", "X", `[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"X"}]`}, - {"start", 0, "", "X", `[{"type":"text","text":"X"},{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, - {"deletion", 0, "abcd", "", `[]`}, - {"same marks", 1, "", "X", `[{"type":"text","text":"aXb","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, - } { - t.Run(test.name, func(t *testing.T) { - doc, err := Decode(json.RawMessage(`{"type":"doc","unknown":9007199254740993,"content":[{"type":"paragraph","attrs":{"keep":9007199254740993},"content":[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]},{"type":"liveBlock","attrs":{"kind":"jobs"}}]}`)) - if err != nil { - t.Fatal(err) - } - if err := Apply(TextBlocks(doc)[0], test.at, test.del, test.ins); err != nil { - t.Fatal(err) - } - want, err := Decode(json.RawMessage(`{"type":"doc","unknown":9007199254740993,"content":[{"type":"paragraph","attrs":{"keep":9007199254740993},"content":` + test.content + `},{"type":"liveBlock","attrs":{"kind":"jobs"}}]}`)) - if err != nil || !reflect.DeepEqual(doc, want) { - t.Fatalf("rewrote attributes or marks: %+v; %v", doc, err) - } - }) - } -} - -// WP-6d (g): keeping an edit preserves every field on untouched text nodes. -func TestSuggestionKeepPreservesTextNodeFields(t *testing.T) { - for _, test := range []struct { - name, before, after string - }{ - { - "right-only attrs", - `{"type":"text","text":" suffix"},{"type":"text","text":" tail","attrs":{"revision":9007199254740993}}`, - `{"type":"text","text":" suffix"},{"type":"text","text":" tail","attrs":{"revision":9007199254740993}}`, - }, - { - "left-only field", - `{"type":"text","text":" suffix","revision":9007199254740993},{"type":"text","text":" tail"}`, - `{"type":"text","text":" suffix","revision":9007199254740993},{"type":"text","text":" tail"}`, - }, - { - "different nested attrs", - `{"type":"text","text":" suffix","attrs":{"meta":{"revision":9007199254740992}}},{"type":"text","text":" tail","attrs":{"meta":{"revision":9007199254740993}}}`, - `{"type":"text","text":" suffix","attrs":{"meta":{"revision":9007199254740992}}},{"type":"text","text":" tail","attrs":{"meta":{"revision":9007199254740993}}}`, - }, - { - "equal nested fields", - `{"type":"text","text":" suffix","attrs":{"revision":9007199254740993},"extra":[{"value":1}]},{"type":"text","text":" tail","extra":[{"value":1}],"attrs":{"revision":9007199254740993}}`, - `{"type":"text","text":" suffix tail","attrs":{"revision":9007199254740993},"extra":[{"value":1}]}`, - }, - } { - t.Run(test.name, func(t *testing.T) { - doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Old","marks":[{"type":"code"}]},` + test.before + `]}]}`)) - if err != nil { - t.Fatal(err) - } - if err := Apply(TextBlocks(doc)[0], 0, "Old", "New"); err != nil { - t.Fatal(err) - } - want, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"New","marks":[{"type":"code"}]},` + test.after + `]}]}`)) - if err != nil || !reflect.DeepEqual(doc, want) { - t.Fatalf("rewrote untouched text fields: %+v; %v", doc, err) - } - if test.name == "right-only attrs" { - tail := children(TextBlocks(doc)[0].Node)[2] - if revision, ok := object(tail["attrs"])["revision"].(json.Number); !ok || revision.String() != "9007199254740993" { - t.Fatalf("revision lost precision: %#v", tail["attrs"]) - } - } - }) - } -} - -// WP-6d (g): absent and empty marks both mean no marks for keeps and insertions. -func TestSuggestionKeepTreatsEmptyAndAbsentMarksEqually(t *testing.T) { - for _, test := range []struct { - name, before, after string - at int - del, ins string - }{ - {"replacement", `[{"type":"text","text":"one","marks":[]},{"type":"text","text":" two"}]`, `[{"type":"text","text":"one three","marks":[]}]`, 4, "two", "three"}, - {"insertion", `[{"type":"text","text":"one","marks":[]},{"type":"text","text":" two"}]`, `[{"type":"text","text":"one more two","marks":[]}]`, 3, "", " more"}, - {"reverse insertion", `[{"type":"text","text":"one"},{"type":"text","text":" two","marks":[]}]`, `[{"type":"text","text":"one more two"}]`, 3, "", " more"}, - } { - t.Run(test.name, func(t *testing.T) { - doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":` + test.before + `}]}`)) - if err != nil { - t.Fatal(err) - } - if err := Apply(TextBlocks(doc)[0], test.at, test.del, test.ins); err != nil { - t.Fatal(err) - } - want, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":` + test.after + `}]}`)) - if err != nil || !reflect.DeepEqual(doc, want) { - t.Fatalf("no-mark text did not merge: %+v; %v", doc, err) - } - }) - } -} - -// WP-6d (d): path wins over duplicate text; fallback needs exactly one block, -// and nested list paragraphs retain their full content-array path. -func TestSuggestionLocatePathAndUniqueFallback(t *testing.T) { - doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Same"}]},{"type":"paragraph","content":[{"type":"text","text":"Same"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Unique"}]}]}]}]}`)) - if err != nil { - t.Fatal(err) - } - if block, ok := Locate(doc, []int{1}, "Same"); !ok || !slices.Equal(block.Path, []int{1}) { - t.Fatalf("stored path: %+v %v", block, ok) - } - if _, ok := Locate(doc, []int{9}, "Same"); ok { - t.Fatal("ambiguous fallback applied") - } - if block, ok := Locate(doc, []int{9}, "Unique"); !ok || !slices.Equal(block.Path, []int{2, 0, 0}) { - t.Fatalf("nested fallback: %+v %v", block, ok) - } - if _, ok := Locate(doc, []int{0}, "Changed"); ok { - t.Fatal("stale text applied") - } -} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go deleted file mode 100644 index 920787dba1..0000000000 --- a/internal/workstream/pages.go +++ /dev/null @@ -1,711 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -const pageSelect = `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview,deleted_at FROM workstream_pages WHERE id=?` - -// ReadByJobsDefault chooses the creation default; later title changes do not -// alter an existing page's explicit Read by jobs setting. -func ReadByJobsDefault(title string) bool { - title = strings.TrimSpace(title) - return strings.EqualFold(title, "Spec") || strings.EqualFold(title, "Decisions") -} - -func pageIn(ctx context.Context, tx *sql.Tx, id string) (Page, bool, error) { - var p Page - var at int64 - var source sql.NullString - var deletedAt sql.NullInt64 - var overview bool - var doc string - err := tx.QueryRowContext(ctx, pageSelect, id).Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &doc, &overview, &deletedAt) - if err != nil { - return p, false, found(err) - } - p.UpdatedAt = instant(at) - if deletedAt.Valid { - p.DeletedAt = instant(deletedAt.Int64) - } - p.Doc = json.RawMessage(doc) - p.UpdatedFrom, err = readSource(source) - return p, overview, err -} -func livePageIn(ctx context.Context, tx *sql.Tx, id string) (Page, bool, error) { - p, overview, err := pageIn(ctx, tx, id) - if err == nil && !p.DeletedAt.IsZero() { - err = ErrNotFound - } - return p, overview, err -} - -const pageNodeColumns = `id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,deleted_at` - -func readPageNodes(rows *sql.Rows) ([]PageNode, error) { - defer rows.Close() - out := make([]PageNode, 0) - for rows.Next() { - var p PageNode - var at int64 - var source sql.NullString - var deletedAt sql.NullInt64 - if err := rows.Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &deletedAt); err != nil { - return nil, err - } - p.UpdatedAt = instant(at) - if deletedAt.Valid { - p.DeletedAt = instant(deletedAt.Int64) - } - var err error - p.UpdatedFrom, err = readSource(source) - if err != nil { - return nil, err - } - out = append(out, p) - } - return out, rows.Err() -} - -func pageNodesIn(ctx context.Context, tx *sql.Tx, id string) ([]PageNode, error) { - rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_at IS NULL ORDER BY position,id`, id) - if err != nil { - return nil, err - } - nodes, err := readPageNodes(rows) - if err != nil { - return nil, err - } - siblings := map[string][]PageNode{} - for _, p := range nodes { - siblings[p.ParentID] = append(siblings[p.ParentID], p) - } - out := make([]PageNode, 0) - seen := map[string]bool{} - // An explicit stack keeps exports safe for a very deep tree. - var stack []PageNode - push := func(nodes []PageNode) { - for i := len(nodes) - 1; i >= 0; i-- { - stack = append(stack, nodes[i]) - } - } - push(siblings[""]) - for len(stack) > 0 { - last := len(stack) - 1 - p := stack[last] - stack = stack[:last] - if seen[p.ID] { - continue - } - seen[p.ID] = true - out = append(out, p) - push(siblings[p.ID]) - } - return out, nil -} -func (s *Store) Pages(ctx context.Context, id string) ([]PageNode, error) { - out := make([]PageNode, 0) - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - var err error - out, err = pageNodesIn(ctx, tx, id) - return err - }) - return out, err -} - -// DeletedPages lists delete roots; descendants remain readable but are not -// separate entries unless they were deleted by their own delete operation. -func (s *Store) DeletedPages(ctx context.Context, id string) ([]PageNode, error) { - out := make([]PageNode, 0) - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_at IS NOT NULL AND deleted_root=id ORDER BY deleted_at DESC,id`, id) - if err != nil { - return err - } - out, err = readPageNodes(rows) - return err - }) - return out, err -} -func (s *Store) Page(ctx context.Context, id string, markdown bool) (Page, error) { - return s.page(ctx, id, 0, markdown) -} - -// PageAtVersion reads saved content while preserving the page's current tree metadata. -func (s *Store) PageAtVersion(ctx context.Context, id string, version int, markdown bool) (Page, error) { - if version < 1 { - return Page{}, invalid("version must be positive") - } - return s.page(ctx, id, version, markdown) -} - -func (s *Store) page(ctx context.Context, id string, version int, markdown bool) (Page, error) { - var p Page - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - var err error - p, _, err = pageIn(ctx, tx, id) - if err != nil || version == 0 || version == p.Version { - return err - } - var at int64 - var source sql.NullString - var doc string - err = tx.QueryRowContext(ctx, `SELECT title,doc,version,saved_at,saved_by,source FROM workstream_page_versions WHERE page_id=? AND version=?`, id, version). - Scan(&p.Title, &doc, &p.Version, &at, &p.UpdatedBy, &source) - if err != nil { - return found(err) - } - p.Doc, p.UpdatedAt = json.RawMessage(doc), instant(at) - p.UpdatedFrom, err = readSource(source) - return err - }) - if err == nil && markdown { - var md string - md, err = pagedoc.Markdown(p.Doc) - p.Markdown = &md - } - return p, err -} -func checkDoc(doc json.RawMessage) error { - if len(doc) > DocMax { - return ErrTooLarge - } - raw := strings.TrimSpace(string(doc)) - if len(raw) == 0 || raw[0] != '{' || !json.Valid(doc) || !utf8.Valid(doc) { - return invalid("doc must be a JSON object") - } - return nil -} -func saveVersion(ctx context.Context, tx *sql.Tx, id string) error { - _, err := tx.ExecContext(ctx, `INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,source,title,doc,bytes) - SELECT id,version,updated_at,updated_by,updated_from,title,doc,length(CAST(doc AS BLOB)) FROM workstream_pages WHERE id=? AND deleted_at IS NULL`, id) - return err -} -func (s *Store) SavePage(ctx context.Context, id string, base int, doc json.RawMessage, who Attribution) (Page, Change, error) { - if err := checkDoc(doc); err != nil { - return Page{}, Change{}, err - } - return s.savePage(ctx, id, base, doc, 0, who) -} -func (s *Store) RestorePage(ctx context.Context, id string, version, base int, who Attribution) (Page, Change, error) { - if version < 1 { - return Page{}, Change{}, invalid("version must be positive") - } - return s.savePage(ctx, id, base, nil, version, who) -} -func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawMessage, restore int, who Attribution) (Page, Change, error) { - if base < 0 { - return Page{}, Change{}, invalid("base_version must be non-negative") - } - by, err := actor(who.By) - if err != nil { - return Page{}, Change{}, err - } - source, err := sourceJSON(who.From) - if err != nil { - return Page{}, Change{}, err - } - var out Page - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, _, err := pageIn(ctx, tx, id) - if err != nil { - return err - } - if !p.DeletedAt.IsZero() { - if restore > 0 { - return invalid("deleted page restore requires only id") - } - 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} - } - title := p.Title - if restore > 0 { - var raw string - err := tx.QueryRowContext(ctx, "SELECT title,doc FROM workstream_page_versions WHERE page_id=? AND version=?", id, restore).Scan(&title, &raw) - if err != nil { - return found(err) - } - doc = json.RawMessage(raw) - } - if err := checkDoc(doc); err != nil { - return err - } - if err := workstreamSavePageIn(ctx, tx, p, title, doc, by, source, s.now().UnixMilli()); err != nil { - return err - } - out, _, err = pageIn(ctx, tx, id) - c = changed(p.WorkstreamID, PartPages) - return err - }) - if err != nil { - return Page{}, Change{}, err - } - return out, c, nil -} - -// workstreamSavePageIn shares history and attribution with atomic wake writes. -func workstreamSavePageIn(ctx context.Context, tx *sql.Tx, p Page, title string, doc json.RawMessage, by string, source any, at int64) error { - if err := workstreamPageWriteAllowed(ctx, tx, p, by); err != nil { - return err - } - if err := checkDoc(doc); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,doc=?,version=version+1,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, string(doc), at, by, source, p.ID); err != nil { - return err - } - if err := saveVersion(ctx, tx, p.ID); err != nil { - return err - } - return touch(ctx, tx, p.WorkstreamID, at) -} - -// placePage validates every target before changing sibling positions. It packs -// positions after a move so a later insert never relies on gaps or old offsets. -func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, last bool, at int64, by string, source any) (bool, error) { - if parent != "" { - seen := map[string]bool{} - for id := parent; id != ""; { - if id == p.ID || seen[id] { - return false, ErrCycle - } - seen[id] = true - var workstreamID, next string - err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=? AND deleted_at IS NULL", id).Scan(&workstreamID, &next) - if err != nil { - return false, found(err) - } - if workstreamID != p.WorkstreamID { - return false, invalid("parent is in another workstream") - } - id = next - } - } - if after != "" { - var workstreamID, siblingParent string - err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=? AND deleted_at IS NULL", after).Scan(&workstreamID, &siblingParent) - if err != nil { - return false, found(err) - } - if workstreamID != p.WorkstreamID || siblingParent != parent || after == p.ID { - return false, invalid("after_id must be another sibling in this workstream") - } - } - nodes, err := pageNodesIn(ctx, tx, p.WorkstreamID) - if err != nil { - return false, err - } - siblings := make([]PageNode, 0) - old := make([]PageNode, 0) - for _, n := range nodes { - if n.ID == p.ID { - continue - } - if n.ParentID == parent { - siblings = append(siblings, n) - } - if p.ParentID != parent && n.ParentID == p.ParentID { - old = append(old, n) - } - } - index := 0 - if last { - index = len(siblings) - } - if after != "" { - for i, n := range siblings { - if n.ID == after { - index = i + 1 - break - } - } - } - // Root position zero belongs to the immutable Overview identity, even after a rename. - if parent == "" && index == 0 && len(siblings) > 0 { - index = 1 - } - if p.ParentID == parent && p.Position == index { - return false, nil - } - siblings = append(siblings, PageNode{}) - copy(siblings[index+1:], siblings[index:]) - siblings[index] = p.PageNode - for i, n := range siblings { - if n.ID == p.ID { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET parent_id=?,position=?,updated_at=?,updated_by=?,updated_from=? WHERE id=?", nullString(parent), i, at, by, source, p.ID); err != nil { - return false, err - } - } else if n.Position != i { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { - return false, err - } - } - } - for i, n := range old { - if n.Position != i { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { - return false, err - } - } - } - return true, nil -} -func workstreamPreparePageCreate(p PageCreate) (PageCreate, any, error) { - if err := validTitle(p.Title); err != nil { - return p, nil, err - } - owner, err := actor(p.Owner) - if err != nil { - return p, nil, err - } - by, err := actor(p.By) - if err != nil { - 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 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 { - out, err = s.workstreamCreatePageIn(ctx, tx, p, source) - c = changed(p.WorkstreamID, PartPages) - return err - }) - if err != nil { - return Page{}, Change{}, err - } - return out, c, nil -} - -func (s *Store) workstreamCreatePageIn(ctx context.Context, tx *sql.Tx, p PageCreate, source any) (Page, error) { - if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, p.By); err != nil { - return Page{}, err - } - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_pages WHERE workstream_id=?", p.WorkstreamID).Scan(&count); err != nil { - return Page{}, err - } - if count >= PagesMax { - return Page{}, fmt.Errorf("%w: a workstream holds at most %d pages, including deleted pages", ErrLimit, PagesMax) - } - id, err := s.newID() - if err != nil { - return Page{}, err - } - at := s.now().UnixMilli() - // A temporary root position lets the common placement path pack every sibling. - 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 Page{}, err - } - page, _, err := livePageIn(ctx, tx, id) - if err != nil { - return Page{}, err - } - if _, err := placePage(ctx, tx, page, p.ParentID, p.AfterID, p.AfterID == "", at, p.By, source); err != nil { - return Page{}, err - } - if err := saveVersion(ctx, tx, id); err != nil { - return Page{}, err - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return Page{}, err - } - out, _, err := pageIn(ctx, tx, id) - return out, err -} -func (s *Store) UpdatePage(ctx context.Context, id string, p PagePatch) (PageNode, Change, error) { - if p.Title != nil { - if err := validTitle(*p.Title); err != nil { - return PageNode{}, Change{}, err - } - } - if p.Owner != nil { - if *p.Owner != "you" && *p.Owner != "agent" { - return PageNode{}, Change{}, invalid("owner must be you or agent") - } - } - by, err := actor(p.By) - if err != nil { - return PageNode{}, Change{}, err - } - source, err := sourceJSON(p.From) - if err != nil { - return PageNode{}, Change{}, err - } - var out PageNode - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - page, _, err := livePageIn(ctx, tx, id) - if err != nil { - return err - } - title, owner, read := page.Title, page.Owner, page.ReadByJobs - if p.Title != nil { - title = *p.Title - } - if p.Owner != nil { - owner = *p.Owner - } - if p.ReadByJobs != nil { - read = *p.ReadByJobs - } - if title == page.Title && owner == page.Owner && read == page.ReadByJobs { - out = page.PageNode - return nil - } - at := s.now().UnixMilli() - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,owner=?,read_by_jobs=?,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, owner, read, at, by, source, id); err != nil { - return err - } - if err := touch(ctx, tx, page.WorkstreamID, at); err != nil { - return err - } - page, _, err = pageIn(ctx, tx, id) - out = page.PageNode - c = changed(page.WorkstreamID, PartPages) - return err - }) - if err != nil { - return PageNode{}, Change{}, err - } - return out, c, nil -} -func (s *Store) MovePage(ctx context.Context, id, parent, after string) (PageNode, Change, error) { - var out PageNode - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, overview, err := livePageIn(ctx, tx, id) - if err != nil { - return err - } - if overview { - return invalid("Overview cannot be moved") - } - at := s.now().UnixMilli() - moved, err := placePage(ctx, tx, p, parent, after, false, at, "you", nil) - if err != nil { - return err - } - if moved { - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - c = changed(p.WorkstreamID, PartPages) - } - p, _, err = pageIn(ctx, tx, id) - out = p.PageNode - return err - }) - if err != nil { - return PageNode{}, Change{}, err - } - return out, c, nil -} -func (s *Store) DeletePage(ctx context.Context, id string) ([]string, Change, error) { - deleted := []string{} - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, overview, err := livePageIn(ctx, tx, id) - if err != nil { - return err - } - if overview { - return invalid("Overview cannot be deleted") - } - nodes, err := pageNodesIn(ctx, tx, p.WorkstreamID) - if err != nil { - return err - } - gone := map[string]bool{id: true} - for _, n := range nodes { - if gone[n.ID] || gone[n.ParentID] { - gone[n.ID] = true - deleted = append(deleted, n.ID) - } - } - at := s.now().UnixMilli() - for _, deletedID := range deleted { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET deleted_at=?,deleted_root=? WHERE id=?", at, id, deletedID); err != nil { - return err - } - } - i := 0 - for _, n := range nodes { - if !gone[n.ID] && n.ParentID == p.ParentID { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { - return err - } - i++ - } - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - c = changed(p.WorkstreamID, PartPages) - return nil - }) - if err != nil { - return nil, Change{}, err - } - return deleted, c, nil -} - -// UndeletePage restores only descendants removed by the same delete. Like a -// move, it records the person as the actor and does not create a saved version. -func (s *Store) UndeletePage(ctx context.Context, id string) (Page, Change, error) { - var out Page - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, _, err := pageIn(ctx, tx, id) - if err != nil { - return err - } - if p.DeletedAt.IsZero() { - return invalid("live page restore requires version and base_version") - } - var root string - if err := tx.QueryRowContext(ctx, "SELECT deleted_root FROM workstream_pages WHERE id=?", id).Scan(&root); err != nil { - return found(err) - } - rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_root=? AND deleted_at=? ORDER BY position,id`, p.WorkstreamID, root, p.DeletedAt.UnixMilli()) - if err != nil { - return err - } - nodes, err := readPageNodes(rows) - if err != nil { - return err - } - children := map[string][]string{} - for _, n := range nodes { - children[n.ParentID] = append(children[n.ParentID], n.ID) - } - parent := p.ParentID - if parent != "" { - if _, _, err := livePageIn(ctx, tx, parent); errors.Is(err, ErrNotFound) { - parent = "" - } else if err != nil { - return err - } - } - at := s.now().UnixMilli() - stack := []string{id} - seen := map[string]bool{} - for len(stack) > 0 { - last := len(stack) - 1 - restoredID := stack[last] - stack = stack[:last] - if seen[restoredID] { - continue - } - seen[restoredID] = true - if _, err := tx.ExecContext(ctx, `UPDATE workstream_pages SET deleted_at=NULL,deleted_root=NULL,updated_at=?,updated_by='you',updated_from=NULL WHERE id=?`, at, restoredID); err != nil { - return err - } - stack = append(stack, children[restoredID]...) - } - // Force placement even when the old ordinal happens to equal the append - // index: the restored page still needs its parent and attribution written. - p.Position = -1 - if _, err := placePage(ctx, tx, p, parent, "", true, at, "you", nil); err != nil { - return err - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - out, _, err = pageIn(ctx, tx, id) - c = changed(p.WorkstreamID, PartPages) - return err - }) - if err != nil { - return Page{}, Change{}, err - } - return out, c, nil -} - -func (s *Store) PageHistory(ctx context.Context, id string, limit int) ([]PageVersion, error) { - if limit == 0 { - limit = 50 - } - if limit < 1 || limit > 200 { - return nil, invalid("history limit must be 1–200") - } - out := []PageVersion{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, _, err := livePageIn(ctx, tx, id); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, "SELECT version,saved_at,saved_by,source,title,bytes FROM workstream_page_versions WHERE page_id=? ORDER BY version DESC LIMIT ?", id, limit) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var p PageVersion - var at int64 - var source sql.NullString - if err := rows.Scan(&p.Version, &at, &p.SavedBy, &source, &p.Title, &p.Bytes); err != nil { - return err - } - p.SavedAt = instant(at) - p.From, err = readSource(source) - if err != nil { - return err - } - out = append(out, p) - } - return rows.Err() - }) - return out, err -} diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go deleted file mode 100644 index 22b15f8a4a..0000000000 --- a/internal/workstream/pages_test.go +++ /dev/null @@ -1,517 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -// Part A (f): optimistic saves refuse stale versions without changing history; -// restoration copies an old title and document into a new immutable version. -func TestPartAPageSaveHistoryAndRestore(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Pages") - nodes, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - first, err := s.Page(ctx, nodes[0].ID, false) - if err != nil { - t.Fatal(err) - } - 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"}}) - if err != nil || saved.Version != 2 { - t.Fatalf("save: %+v %v", saved, err) - } - _, c, err := s.SavePage(ctx, first.ID, 1, json.RawMessage(emptyDoc), Attribution{}) - var stale *VersionConflict - if !errors.As(err, &stale) || stale.ID != first.ID || stale.Version != 2 || stale.UpdatedBy != "agent" || !stale.UpdatedAt.Equal(*now) || len(c.Parts) != 0 { - t.Fatalf("stale: %+v %v", stale, err) - } - current, _ := s.Page(ctx, first.ID, false) - history, _ := s.PageHistory(ctx, first.ID, 50) - if !reflect.DeepEqual(current, saved) || len(history) != 2 || history[0].Version != 2 || history[1].Version != 1 { - t.Fatalf("stale changed page or history: %+v %+v", current, history) - } - title := "Renamed" - _, _, err = s.UpdatePage(ctx, first.ID, PagePatch{Title: &title}) - if err != nil { - t.Fatal(err) - } - restored, _, err := s.RestorePage(ctx, first.ID, 1, 2, Attribution{}) - if err != nil || restored.Version != 3 || restored.Title != "Overview" || string(restored.Doc) != emptyDoc { - t.Fatalf("restore: %+v %v", restored, err) - } - history, _ = s.PageHistory(ctx, first.ID, 50) - if len(history) != 3 || history[0].Version != 3 || history[1].SavedBy != "agent" { - t.Fatalf("history: %+v", history) - } - _, _, err = s.SavePage(ctx, first.ID, 3, json.RawMessage(`{"x":"`+strings.Repeat("x", DocMax)+`"}`), Attribution{}) - if !errors.Is(err, ErrTooLarge) { - t.Fatalf("size: %v", err) - } -} - -// Part A (f): placement defaults differ for create and move; Overview is fixed, -// parent cycles and cross-workstream moves refuse, and delete removes descendants. -func TestPartAPageTreePlacementCyclesAndDelete(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Tree") - nodes, _ := s.Pages(ctx, w.ID) - overview := nodes[0] - a, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "A"}) - if err != nil { - t.Fatal(err) - } - b, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "B"}) - if err != nil { - t.Fatal(err) - } - child, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, ParentID: a.ID, Title: "Child"}) - if err != nil { - t.Fatal(err) - } - if a.Position != 1 || b.Position != 2 || child.Position != 0 { - t.Fatalf("positions: %+v %+v %+v", a, b, child) - } - if _, _, err := s.MovePage(ctx, a.ID, child.ID, ""); !errors.Is(err, ErrCycle) { - t.Fatalf("descendant: %v", err) - } - if _, _, err := s.MovePage(ctx, a.ID, a.ID, ""); !errors.Is(err, ErrCycle) { - t.Fatalf("self: %v", err) - } - if _, _, err := s.MovePage(ctx, overview.ID, a.ID, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("Overview move: %v", err) - } - moved, _, err := s.MovePage(ctx, b.ID, "", "") - if err != nil || moved.Position != 1 { - t.Fatalf("root move: %+v %v", moved, err) - } - nodes, _ = s.Pages(ctx, w.ID) - ids := []string{} - for _, n := range nodes { - ids = append(ids, n.ID) - } - if !reflect.DeepEqual(ids, []string{overview.ID, b.ID, a.ID, child.ID}) { - t.Fatalf("depth-first: %v", ids) - } - other := createStream(t, s, "OtherTree") - otherPages, _ := s.Pages(ctx, other.ID) - if _, _, err := s.MovePage(ctx, a.ID, otherPages[0].ID, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("cross-workstream: %v", err) - } - if _, _, err := s.DeletePage(ctx, overview.ID); !errors.Is(err, ErrInvalid) { - t.Fatalf("Overview delete: %v", err) - } - deleted, _, err := s.DeletePage(ctx, a.ID) - if err != nil || !reflect.DeepEqual(deleted, []string{a.ID, child.ID}) { - t.Fatalf("delete: %v %v", deleted, err) - } - if page, err := s.Page(ctx, child.ID, false); err != nil || page.DeletedAt.IsZero() { - t.Fatalf("deleted child must remain readable: %+v %v", page, err) - } -} - -func TestPartAPlanMembershipVersionsAndDone(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Plan", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "8", Factory: "f"}) - p, err := s.Plan(ctx, w.ID) - if err != nil || p.Version != 0 || p.Milestones == nil || p.Waves == nil { - t.Fatalf("initial: %+v %v", p, err) - } - waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 8}, {Kind: "draft", DraftID: "draft", Title: "Next"}}}} - p, _, err = s.SetPlan(ctx, w.ID, 0, []Milestone{{Title: "Ship"}}, waves, Attribution{By: "agent"}) - if err != nil || p.Version != 1 || p.Milestones[0].ID == "" { - t.Fatalf("set: %+v %v", p, err) - } - _, _, err = s.SetPlan(ctx, w.ID, 0, nil, nil, Attribution{}) - var stale *VersionConflict - if !errors.As(err, &stale) || stale.Version != 1 || stale.UpdatedBy != "agent" { - t.Fatalf("stale: %v", err) - } - for _, bad := range [][]Wave{ - {{N: 0}}, {{N: 2}, {N: 1}}, {{N: 1}, {N: 1}}, {{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 99}}}}, - } { - if _, _, err := s.SetPlan(ctx, w.ID, 1, nil, bad, Attribution{}); !errors.Is(err, ErrInvalid) { - t.Fatalf("invalid waves %+v: %v", bad, err) - } - } - _, _, err = s.MarkDone(ctx, w.ID, "") - if err != nil { - t.Fatal(err) - } - if _, _, err := s.SetPlan(ctx, w.ID, 1, nil, nil, Attribution{}); !errors.Is(err, ErrConflict) { - t.Fatalf("done: %v", err) - } -} - -// page.get's markdown is the shared page renderer's output (pagedoc), so the -// engine has one renderer and it matches the desktop app's. -func TestPartAMarkdownExportIsThePageRenderer(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Markdown") - pages, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - doc := json.RawMessage(`{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},{"type":"paragraph","content":[{"type":"text","text":"Body"}]}]}`) - if _, _, err := s.SavePage(ctx, pages[0].ID, 1, doc, Attribution{}); err != nil { - t.Fatal(err) - } - p, err := s.Page(ctx, pages[0].ID, true) - if err != nil { - t.Fatal(err) - } - want, err := pagedoc.Markdown(doc) - if err != nil { - t.Fatal(err) - } - if p.Markdown == nil || *p.Markdown != want || !strings.Contains(want, "Title") { - t.Fatalf("markdown %v, want %q", p.Markdown, want) - } -} - -func TestPartAOverviewIdentitySurvivesRenaming(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Identity") - pages, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - title := "Introduction" - overview, _, err := s.UpdatePage(ctx, pages[0].ID, PagePatch{Title: &title}) - if err != nil { - t.Fatal(err) - } - other, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Overview"}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MovePage(ctx, overview.ID, other.ID, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("renamed Overview move: %v", err) - } - if _, _, err := s.DeletePage(ctx, overview.ID); !errors.Is(err, ErrInvalid) { - t.Fatalf("renamed Overview delete: %v", err) - } - if _, _, err := s.MovePage(ctx, other.ID, "", ""); err != nil { - t.Fatalf("ordinary page named Overview: %v", err) - } - pages, err = s.Pages(ctx, w.ID) - if err != nil || pages[0].ID != overview.ID || pages[0].Position != 0 { - t.Fatalf("Overview order: %+v %v", pages, err) - } -} - -// WP-F1 (a): saved content and attribution combine with current page metadata; -// selecting the current version is identical to reading without a version. -func TestWorkstreamPageGetSavedVersion(t *testing.T) { - s, _, now := testStore(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", Owner: "agent", Doc: firstDoc, - Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: "first-source"}}}) - if err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - if _, _, err := s.SavePage(ctx, first.ID, 1, json.RawMessage(emptyDoc), Attribution{}); err != nil { - t.Fatal(err) - } - parent, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Parent"}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MovePage(ctx, first.ID, parent.ID, ""); err != nil { - t.Fatal(err) - } - title, owner, read := "Current title", "agent", true - if _, _, err := s.UpdatePage(ctx, first.ID, PagePatch{Title: &title, Owner: &owner, ReadByJobs: &read}); err != nil { - t.Fatal(err) - } - current, err := s.Page(ctx, first.ID, false) - if err != nil { - t.Fatal(err) - } - selected, err := s.PageAtVersion(ctx, first.ID, current.Version, false) - if err != nil || !reflect.DeepEqual(selected, current) { - t.Fatalf("current version: %+v, want %+v: %v", selected, current, err) - } - check := func(deleted bool) { - t.Helper() - old, err := s.PageAtVersion(ctx, first.ID, 1, true) - if err != nil { - t.Fatal(err) - } - want := current - want.Title, want.Doc, want.Version = first.Title, first.Doc, first.Version - want.UpdatedAt, want.UpdatedBy, want.UpdatedFrom = first.UpdatedAt, first.UpdatedBy, first.UpdatedFrom - md, err := pagedoc.Markdown(firstDoc) - if err != nil { - t.Fatal(err) - } - want.Markdown = &md - if deleted { - want.DeletedAt = *now - } - if !reflect.DeepEqual(old, want) || old.Markdown == nil || !strings.Contains(*old.Markdown, "First") { - t.Fatalf("saved version: %+v, want %+v", old, want) - } - } - check(false) - if _, err := s.PageAtVersion(ctx, first.ID, 99, false); !errors.Is(err, ErrNotFound) { - t.Fatalf("unknown version: %v", err) - } - for _, version := range []int{0, -1} { - if _, err := s.PageAtVersion(ctx, first.ID, version, false); !errors.Is(err, ErrInvalid) { - t.Fatalf("invalid version %d: %v", version, err) - } - } - if _, _, err := s.DeletePage(ctx, first.ID); err != nil { - t.Fatal(err) - } - check(true) -} - -func createWorkstreamPage(t *testing.T, s *Store, workstreamID, parent, title string) Page { - t.Helper() - p, _, err := s.CreatePage(context.Background(), PageCreate{WorkstreamID: workstreamID, ParentID: parent, Title: title}) - if err != nil { - t.Fatal(err) - } - return p -} - -// WP-F1 (b): delete groups preserve separate deletions, history, nesting and order; -// undelete appends under a live parent and changes attribution without a save. -func TestWorkstreamDeletedPageTreeRestore(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Restorable tree") - deleted, err := s.DeletedPages(ctx, w.ID) - if err != nil || deleted == nil || len(deleted) != 0 { - t.Fatalf("initial deleted list: %+v %v", deleted, err) - } - parent := createWorkstreamPage(t, s, w.ID, "", "Parent") - root := createWorkstreamPage(t, s, w.ID, parent.ID, "Root") - earlier := createWorkstreamPage(t, s, w.ID, root.ID, "Earlier deletion") - first := createWorkstreamPage(t, s, w.ID, root.ID, "First") - grandchild := createWorkstreamPage(t, s, w.ID, first.ID, "Grandchild") - 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) - } - lastTitle := "Last title" - if _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{Title: &lastTitle, Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}}); err != nil { - t.Fatal(err) - } - if _, _, err := s.DeletePage(ctx, earlier.ID); err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - ids, change, err := s.DeletePage(ctx, root.ID) - if err != nil || !reflect.DeepEqual(ids, []string{root.ID, first.ID, grandchild.ID, second.ID}) || !reflect.DeepEqual(change.Parts, []ChangedPart{PartWorkstream, PartPages}) { - t.Fatalf("delete receipt: %v %+v %v", ids, change, err) - } - deleted, err = s.DeletedPages(ctx, w.ID) - if err != nil || len(deleted) != 2 || deleted[0].ID != root.ID || deleted[1].ID != earlier.ID || !deleted[0].DeletedAt.Equal(*now) || !deleted[0].DeletedAt.After(deleted[1].DeletedAt) { - t.Fatalf("delete roots: %+v %v", deleted, err) - } - nodes, err := s.Pages(ctx, w.ID) - if err != nil || len(nodes) != 3 || nodes[1].ID != parent.ID || nodes[2].ID != sibling.ID || nodes[2].Position != 0 { - t.Fatalf("live tree: %+v %v", nodes, err) - } - for _, id := range ids { - p, err := s.Page(ctx, id, true) - if err != nil || !p.DeletedAt.Equal(*now) { - t.Fatalf("deleted page %s: %+v %v", id, p, err) - } - } - for name, write := range map[string]func() error{ - "save": func() error { - _, _, err := s.SavePage(ctx, root.ID, 2, json.RawMessage(emptyDoc), Attribution{}) - return err - }, - "update": func() error { _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{}); return err }, - "move": func() error { _, _, err := s.MovePage(ctx, root.ID, "", ""); return err }, - "delete": func() error { _, _, err := s.DeletePage(ctx, root.ID); return err }, - "history": func() error { _, err := s.PageHistory(ctx, root.ID, 50); return err }, - "create parent": func() error { - _, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, ParentID: root.ID, Title: "No"}) - return err - }, - "create after": func() error { - _, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, AfterID: root.ID, Title: "No"}) - return err - }, - "move parent": func() error { _, _, err := s.MovePage(ctx, sibling.ID, root.ID, ""); return err }, - "move after": func() error { _, _, err := s.MovePage(ctx, sibling.ID, "", root.ID); return err }, - } { - t.Run(name, func(t *testing.T) { - if err := write(); !errors.Is(err, ErrNotFound) { - t.Fatalf("deleted target: %v", err) - } - }) - } - *now = now.Add(time.Hour) - restored, change, err := s.UndeletePage(ctx, root.ID) - if err != nil || restored.ParentID != parent.ID || restored.Position != 1 || restored.Title != lastTitle || restored.Version != 2 || string(restored.Doc) != string(doc) || !restored.DeletedAt.IsZero() || !restored.UpdatedAt.Equal(*now) || restored.UpdatedBy != "you" || restored.UpdatedFrom != nil || change.ID != w.ID || !reflect.DeepEqual(change.Parts, []ChangedPart{PartWorkstream, PartPages}) { - t.Fatalf("restore: %+v %+v %v", restored, change, err) - } - nodes, err = s.Pages(ctx, w.ID) - var liveIDs []string - for _, p := range nodes { - liveIDs = append(liveIDs, p.ID) - } - if err != nil || len(liveIDs) != 7 || !reflect.DeepEqual(liveIDs[1:], []string{parent.ID, sibling.ID, root.ID, first.ID, grandchild.ID, second.ID}) { - t.Fatalf("restored order: %+v %v", nodes, err) - } - for _, want := range []Page{first, grandchild, second} { - p, err := s.Page(ctx, want.ID, false) - if err != nil || p.ParentID != want.ParentID || p.Version != want.Version || !p.DeletedAt.IsZero() { - t.Fatalf("restored nesting: %+v %v", p, err) - } - } - deleted, err = s.DeletedPages(ctx, w.ID) - if err != nil || len(deleted) != 1 || deleted[0].ID != earlier.ID { - t.Fatalf("earlier deletion revived: %+v %v", deleted, err) - } - history, err := s.PageHistory(ctx, root.ID, 50) - if err != nil || len(history) != 2 || history[0].Title != root.Title || history[0].SavedBy != "agent" || history[0].From == nil || history[0].From.ID != "source" { - t.Fatalf("restore changed saved history: %+v %v", history, err) - } -} - -// WP-F1 (b): restoring a descendant of a deleted parent restores only its own -// group subtree, appends at the root, and remains permitted on a done workstream. -func TestWorkstreamDeletedDescendantRestoresAtRoot(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Detached restore") - root := createWorkstreamPage(t, s, w.ID, "", "Root") - child := createWorkstreamPage(t, s, w.ID, root.ID, "Child") - grandchild := createWorkstreamPage(t, s, w.ID, child.ID, "Grandchild") - other := createWorkstreamPage(t, s, w.ID, root.ID, "Other") - last := createWorkstreamPage(t, s, w.ID, "", "Last") - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - if _, _, err := s.DeletePage(ctx, root.ID); err != nil { - t.Fatal(err) - } - restored, _, err := s.UndeletePage(ctx, child.ID) - if err != nil || restored.ParentID != "" || restored.Position != 2 || restored.Version != 1 { - t.Fatalf("descendant restore: %+v %v", restored, err) - } - nodes, err := s.Pages(ctx, w.ID) - if err != nil || len(nodes) != 4 || nodes[1].ID != last.ID || nodes[2].ID != child.ID || nodes[3].ID != grandchild.ID { - t.Fatalf("detached tree: %+v %v", nodes, err) - } - for _, id := range []string{root.ID, other.ID} { - p, err := s.Page(ctx, id, false) - if err != nil || p.DeletedAt.IsZero() { - t.Fatalf("unrelated deleted page: %+v %v", p, err) - } - } - rootRestored, _, err := s.UndeletePage(ctx, root.ID) - if err != nil || rootRestored.ParentID != "" || rootRestored.Position != 3 { - t.Fatalf("root append: %+v %v", rootRestored, err) - } - p, err := s.Page(ctx, child.ID, false) - if err != nil || p.ParentID != "" || p.Position != 2 { - t.Fatalf("later root restore moved detached child: %+v %v", p, err) - } - stream, err := s.Get(ctx, w.ID) - if err != nil || stream.DoneAt.IsZero() { - t.Fatalf("restore changed done state: %+v %v", stream, err) - } -} - -// WP-F1 (b): explicitly purging a workstream and its collection removes deleted -// pages and their saved versions through the cascade, while page.delete retains both. -func TestWorkstreamDeletedPagesCollectionCascade(t *testing.T) { - s, storage, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Page retention") - root := createWorkstreamPage(t, s, w.ID, "", "Root") - child := createWorkstreamPage(t, s, w.ID, root.ID, "Child") - if _, _, err := s.DeletePage(ctx, root.ID); err != nil { - t.Fatal(err) - } - for _, id := range []string{root.ID, child.ID} { - if p, err := s.PageAtVersion(ctx, id, 1, false); err != nil || p.DeletedAt.IsZero() { - t.Fatalf("retained version: %+v %v", p, err) - } - } - if err := storage.Write(ctx, func(tx *sql.Tx) error { - if _, err := tx.ExecContext(ctx, "DELETE FROM workstreams WHERE id=?", w.ID); err != nil { - return err - } - _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", w.ID) - return err - }); err != nil { - t.Fatal(err) - } - if err := storage.Read(ctx, func(tx *sql.Tx, _ int) error { - for _, table := range []string{"workstream_pages", "workstream_page_versions"} { - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM "+table).Scan(&count); err != nil { - return err - } - if count != 0 { - t.Errorf("%s retained %d rows after collection purge", table, count) - } - } - return nil - }); err != nil { - t.Fatal(err) - } -} - -// WP-F1 (c): the exported creation default trims Unicode space and uses case -// folding, while the store's existing explicit boolean remains authoritative. -func TestWorkstreamReadByJobsDefault(t *testing.T) { - for _, tc := range []struct { - title string - want bool - }{ - {"Spec", true}, {" decisions ", true}, {"DECISIONS", true}, {"\u2003sPeC\u2003", true}, - {"Spec notes", false}, {"Overview", false}, {"Notes", false}, {"", false}, - } { - if got := ReadByJobsDefault(tc.title); got != tc.want { - t.Errorf("%q: %v, want %v", tc.title, got, tc.want) - } - } - s, _, _ := testStore(t) - w := createStream(t, s, "Explicit store default") - p := createWorkstreamPage(t, s, w.ID, "", "Spec") - if p.ReadByJobs { - t.Fatal("store changed an explicit false to the title default") - } -} diff --git a/internal/workstream/plan.go b/internal/workstream/plan.go deleted file mode 100644 index 8dd7142ddd..0000000000 --- a/internal/workstream/plan.go +++ /dev/null @@ -1,197 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "math" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func planIn(ctx context.Context, tx *sql.Tx, id string) (Plan, error) { - p := Plan{WorkstreamID: id, Milestones: []Milestone{}, Waves: []Wave{}, UpdatedBy: "you"} - var at int64 - var milestones, waves string - err := tx.QueryRowContext(ctx, "SELECT version,updated_at,updated_by,milestones,waves FROM workstream_plans WHERE workstream_id=?", id).Scan(&p.Version, &at, &p.UpdatedBy, &milestones, &waves) - if errors.Is(err, sql.ErrNoRows) { - return p, nil - } - if err != nil { - return p, err - } - p.UpdatedAt = instant(at) - if err := json.Unmarshal([]byte(milestones), &p.Milestones); err != nil { - return p, err - } - err = json.Unmarshal([]byte(waves), &p.Waves) - return p, err -} -func (s *Store) Plan(ctx context.Context, id string) (Plan, error) { - var p Plan - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - var err error - p, err = planIn(ctx, tx, id) - return err - }) - return p, err -} -func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []Milestone, waves []Wave, who Attribution) (Plan, Change, error) { - by, err := actor(who.By) - if err != nil { - return Plan{}, Change{}, err - } - if base < 0 { - return Plan{}, Change{}, invalid("base_version must be non-negative") - } - // Copy slices because assigned ids and normalized lists belong to the saved plan. - milestones = append([]Milestone{}, milestones...) - waves = append([]Wave{}, waves...) - for i := range waves { - waves[i].Items = append([]PlanItem{}, waves[i].Items...) - } - var out Plan - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if !w.DoneAt.IsZero() { - return ErrConflict - } - if len(milestones) > PlanMilestonesMax { - return fmt.Errorf("%w: a plan holds at most %d milestones", ErrLimit, PlanMilestonesMax) - } - if len(waves) > PlanWavesMax { - return fmt.Errorf("%w: a plan holds at most %d waves", ErrLimit, PlanWavesMax) - } - items := 0 - for _, wave := range waves { - items += len(wave.Items) - if items > PlanItemsMax { - return fmt.Errorf("%w: a plan holds at most %d items", ErrLimit, PlanItemsMax) - } - } - p, err := planIn(ctx, tx, id) - if err != nil { - return err - } - if base != p.Version { - at := p.UpdatedAt - if at.IsZero() { - at = w.UpdatedAt - } - return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: at, UpdatedBy: p.UpdatedBy} - } - ids := map[string]bool{} - for i := range milestones { - m := &milestones[i] - if err := validTitle(m.Title); err != nil { - return err - } - if m.ID == "" { - m.ID, err = s.newID() - if err != nil { - return err - } - } - if ids[m.ID] { - return invalid("milestone ids must be unique") - } - ids[m.ID] = true - } - jobs := map[Job]bool{} - for _, j := range w.Jobs() { - jobs[j] = true - } - last := 0 - for _, wave := range waves { - if wave.N <= last { - return invalid("waves must be positive, unique and ascending") - } - last = wave.N - if !lineWithin(wave.Title, 200) { - return invalid("wave title must be at most 200 bytes without control characters") - } - if wave.MilestoneID != "" && !ids[wave.MilestoneID] { - return invalid("wave milestone does not exist") - } - for _, item := range wave.Items { - if err := workstreamDraftFields(item); err != nil { - return err - } - switch item.Kind { - case "job": - if item.Job <= 0 || !jobs[Job{Factory: item.Factory, Number: item.Job}] { - return invalid("plan job must be a member") - } - case "draft": - if item.DraftID == "" || item.Title == "" { - return invalid("draft needs draft_id and title") - } - if err := validTitle(item.Title); err != nil { - return err - } - // Control characters stay invalid whatever the length. - if !textWithin(item.Text, len(item.Text)) { - return invalid("draft text allows only newline and tab control characters") - } - if len(item.Text) > PlanDraftTextMax { - return fmt.Errorf("%w: draft text must be at most %d bytes", ErrLimit, PlanDraftTextMax) - } - default: - return invalid("unknown plan item kind") - } - } - } - m, err := json.Marshal(milestones) - if err != nil { - return err - } - v, err := json.Marshal(waves) - if err != nil { - return err - } - if len(m)+len(v) > PlanEncodedMax { - return fmt.Errorf("%w: a plan's milestones and waves must encode to at most %d bytes", ErrLimit, PlanEncodedMax) - } - at := s.now().UnixMilli() - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES (?,?,?,?,?,?) - ON CONFLICT(workstream_id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,updated_by=excluded.updated_by,milestones=excluded.milestones,waves=excluded.waves`, id, p.Version+1, at, by, string(m), string(v)); err != nil { - return err - } - if err := touch(ctx, tx, id, at); err != nil { - return err - } - out, err = planIn(ctx, tx, id) - c = changed(id, PartPlan) - return err - }) - if err != nil { - return Plan{}, Change{}, err - } - return out, c, nil -} - -func workstreamDraftFields(item PlanItem) error { - if item.Kind != "draft" && (item.Size != "" || item.BudgetUSD != 0 || item.Source != nil) { - return invalid("size, budget_usd and source are allowed only on drafts") - } - if item.Size != "" && item.Size != "S" && item.Size != "M" && item.Size != "L" { - return invalid("draft size must be S, M or L") - } - if math.IsNaN(item.BudgetUSD) || math.IsInf(item.BudgetUSD, 0) || item.BudgetUSD < 0 || item.BudgetUSD > workspace.WorkstreamGoAheadCapMax { - return invalid(fmt.Sprintf("draft budget_usd must be greater than 0 and at most %d when set", workspace.WorkstreamGoAheadCapMax)) - } - _, err := sourceJSON(item.Source) - return err -} diff --git a/internal/workstream/records.go b/internal/workstream/records.go deleted file mode 100644 index 05de613c91..0000000000 --- a/internal/workstream/records.go +++ /dev/null @@ -1,310 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" -) - -const recordSelect = `SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,coalesce(supersedes,''),coalesce(superseded_by,''),goahead,coalesce(goahead_id,'') FROM workstream_records` - -func recordIn(ctx context.Context, tx *sql.Tx, id string) (Record, error) { - var r Record - var source sql.NullString - var proposal sql.NullString - var at int64 - var decided sql.NullInt64 - err := tx.QueryRowContext(ctx, recordSelect+" WHERE id=?", id).Scan(&r.ID, &r.WorkstreamID, &r.Kind, &r.Text, &r.State, &r.By, &source, &at, &decided, &r.Supersedes, &r.SupersededBy, &proposal, &r.GoAheadID) - if err != nil { - return r, found(err) - } - r.CreatedAt, r.DecidedAt = instant(at), optionalInstant(decided) - if proposal.Valid { - if err := json.Unmarshal([]byte(proposal.String), &r.GoAhead); err != nil { - return r, err - } - } - r.Source, err = readSource(source) - return r, err -} -func recordText(kind, text string) error { - if kind != "decision" && kind != "question" && kind != "goahead" { - return invalid("record kind must be decision, question or goahead") - } - if strings.TrimSpace(text) == "" || !textWithin(text, 2000) { - return invalid("record text must be 1–2000 bytes") - } - return nil -} - -func workstreamRecordLimitIn(ctx context.Context, tx *sql.Tx, id string) error { - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_records WHERE workstream_id=?", id).Scan(&count); err != nil { - return err - } - if count >= RecordsMax { - return fmt.Errorf("%w: a workstream holds at most %d records", ErrLimit, RecordsMax) - } - return nil -} - -func (s *Store) Records(ctx context.Context, id, kind, state string, includeGoAheads ...bool) ([]Record, error) { - if kind != "" && kind != "decision" && kind != "question" && kind != "goahead" { - return nil, invalid("unknown record kind") - } - if state != "" && state != "proposed" && state != "kept" && state != "dropped" && state != "superseded" { - return nil, invalid("unknown record state") - } - out := []Record{} - include := len(includeGoAheads) > 0 && includeGoAheads[0] - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, `SELECT id FROM workstream_records WHERE workstream_id=? AND (?='' OR kind=?) AND (?!='' OR ? OR kind!='goahead') AND (?='' OR state=?) ORDER BY created_at DESC,rowid DESC`, id, kind, kind, kind, include, state, state) - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err - } - for _, id := range ids { - r, err := recordIn(ctx, tx, id) - if err != nil { - return err - } - out = append(out, r) - } - return nil - }) - return out, err -} -func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Change, error) { - if err := recordText(p.Kind, p.Text); err != nil { - return Record{}, Change{}, err - } - if (p.Kind == "goahead") != (p.GoAhead != nil) || p.Kind == "goahead" && p.Kept { - return Record{}, Change{}, invalid("goahead is required only for kind goahead, which cannot be proposed kept") - } - if p.GoAhead != nil { - // Store the end at the grant's millisecond precision, so keeping it grants the same end. - proposal := *p.GoAhead - proposal.EndsAt = instant(proposal.EndsAt.UnixMilli()) - p.GoAhead = &proposal - } - // Decisions and questions preserve the wire's unattributed done behavior. - // Go-ahead proposals check done with the grant rules inside the transaction. - asked := p.By - if p.By == "" { - if p.Kept { - p.By = "you" - } else { - p.By = "agent" - } - } - by, err := actor(p.By) - if err != nil { - return Record{}, Change{}, err - } - source, err := sourceJSON(p.Source) - if err != nil { - return Record{}, Change{}, err - } - var out Record - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if p.Kind != "goahead" { - if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, asked); err != nil { - return err - } - } - now := s.now() - var proposal any - if p.GoAhead != nil { - if err := workstreamGoAheadAllowedIn(ctx, tx, p.GoAhead.grant(p.WorkstreamID), now); err != nil { - return err - } - encoded, err := json.Marshal(p.GoAhead) - if err != nil { - return err - } - proposal = string(encoded) - } - if err := workstreamRecordLimitIn(ctx, tx, p.WorkstreamID); err != nil { - return err - } - id, err := s.newID() - if err != nil { - return err - } - at := now.UnixMilli() - state := "proposed" - var decided any - if p.Kept { - state = "kept" - decided = at - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at,goahead) VALUES (?,?,?,?,?,?,?,?,?,?)`, id, p.WorkstreamID, p.Kind, p.Text, state, by, source, at, decided, proposal); err != nil { - return err - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - out, err = recordIn(ctx, tx, id) - c = changed(p.WorkstreamID, PartRecords) - return err - }) - if err != nil { - return Record{}, Change{}, err - } - return out, c, nil -} -func (s *Store) KeepRecord(ctx context.Context, id string) (Record, Change, error) { - return s.setRecordState(ctx, id, "kept") -} -func (s *Store) DropRecord(ctx context.Context, id string) (Record, Change, error) { - return s.setRecordState(ctx, id, "dropped") -} -func (s *Store) setRecordState(ctx context.Context, id, state string) (Record, Change, error) { - var out Record - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - r, err := recordIn(ctx, tx, id) - if err != nil { - return err - } - if r.State == "superseded" { - return ErrConflict - } - if r.State == state { - out = r - return nil - } - now := s.now() - goaheadChanged, err := s.workstreamRecordGoAheadIn(ctx, tx, &r, state, now) - if err != nil { - return err - } - at := now.UnixMilli() - if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state=?,decided_at=?,goahead_id=? WHERE id=?", state, at, nullString(r.GoAheadID), id); err != nil { - return err - } - if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { - return err - } - out, err = recordIn(ctx, tx, id) - c = changed(r.WorkstreamID, PartRecords) - if goaheadChanged { - c.Parts = append(c.Parts, PartGoAheads) - } - return err - }) - if err != nil { - return Record{}, Change{}, err - } - return out, c, nil -} -func (s *Store) SupersedeRecord(ctx context.Context, id string, p RecordReplacement) (Record, Record, Change, error) { - by := p.By - if by == "" { - by = "agent" - } - by, err := actor(by) - if err != nil { - return Record{}, Record{}, Change{}, err - } - source, err := sourceJSON(p.Source) - if err != nil { - return Record{}, Record{}, Change{}, err - } - var out, old Record - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - r, err := recordIn(ctx, tx, id) - if err != nil { - return err - } - if r.Kind == "goahead" || p.Kind == "goahead" { - return invalid("goahead records cannot supersede or be superseded") - } - if r.State == "superseded" { - return ErrConflict - } - kind := p.Kind - if kind == "" { - kind = r.Kind - } - if err := recordText(kind, p.Text); err != nil { - return err - } - if err := workstreamRecordLimitIn(ctx, tx, r.WorkstreamID); err != nil { - return err - } - replacement, err := s.newID() - if err != nil { - return err - } - at := s.now().UnixMilli() - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes) VALUES (?,?,?,?,'kept',?,?,?,?,?)`, replacement, r.WorkstreamID, kind, p.Text, by, source, at, at, id); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state='superseded',decided_at=?,superseded_by=? WHERE id=?", at, replacement, id); err != nil { - return err - } - if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { - return err - } - out, err = recordIn(ctx, tx, replacement) - if err != nil { - return err - } - old, err = recordIn(ctx, tx, id) - c = changed(r.WorkstreamID, PartRecords) - return err - }) - if err != nil { - return Record{}, Record{}, Change{}, err - } - return out, old, c, nil -} - -func (s *Store) workstreamRecordGoAheadIn(ctx context.Context, tx *sql.Tx, r *Record, state string, now time.Time) (bool, error) { - if r.Kind != "goahead" { - return false, nil - } - if state == "kept" { - if r.GoAhead == nil { - return false, invalid("goahead record requires a proposal") - } - g, err := s.grantGoAheadIn(ctx, tx, r.GoAhead.grant(r.WorkstreamID), now) - if err != nil { - return false, err - } - r.GoAheadID = g.ID - return true, nil - } - if r.State == "kept" && r.GoAheadID != "" { - _, ended, err := revokeGoAheadIn(ctx, tx, r.GoAheadID, now) - return ended, err - } - return false, nil -} diff --git a/internal/workstream/records_goaheads_test.go b/internal/workstream/records_goaheads_test.go deleted file mode 100644 index 3641a63594..0000000000 --- a/internal/workstream/records_goaheads_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package workstream - -import ( - "context" - "errors" - "math" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -// Part A (g): keep/drop support undo; supersede creates a kept replacement with -// both links, and every attempt to change the old record conflicts. -func TestPartARecordLifecycle(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Records") - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Count retries?"}) - if err != nil || r.State != "proposed" || r.By != "agent" { - t.Fatalf("propose: %+v %v", r, err) - } - r, _, err = s.KeepRecord(ctx, r.ID) - if err != nil || r.State != "kept" || r.DecidedAt.IsZero() { - t.Fatalf("keep: %+v %v", r, err) - } - r, _, err = s.DropRecord(ctx, r.ID) - if err != nil || r.State != "dropped" { - t.Fatalf("drop: %+v %v", r, err) - } - r, _, err = s.KeepRecord(ctx, r.ID) - if err != nil || r.State != "kept" { - t.Fatalf("undo: %+v %v", r, err) - } - replacement, old, _, err := s.SupersedeRecord(ctx, r.ID, RecordReplacement{Kind: "decision", Text: "Retries do not count"}) - if err != nil || replacement.State != "kept" || replacement.Supersedes != r.ID || old.State != "superseded" || old.SupersededBy != replacement.ID { - t.Fatalf("supersede: %+v %+v %v", replacement, old, err) - } - if _, _, err := s.KeepRecord(ctx, r.ID); !errors.Is(err, ErrConflict) { - t.Fatalf("keep superseded: %v", err) - } - if _, _, err := s.DropRecord(ctx, r.ID); !errors.Is(err, ErrConflict) { - t.Fatalf("drop superseded: %v", err) - } - if _, _, _, err := s.SupersedeRecord(ctx, r.ID, RecordReplacement{Text: "Again"}); !errors.Is(err, ErrConflict) { - t.Fatalf("supersede superseded: %v", err) - } - kept, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "My decision", Kept: true}) - if err != nil || kept.By != "you" || kept.State != "kept" || kept.DecidedAt.IsZero() { - t.Fatalf("kept: %+v %v", kept, err) - } - all, err := s.Records(ctx, w.ID, "", "") - if err != nil || len(all) != 3 { - t.Fatalf("records: %+v %v", all, err) - } - proposed, err := s.Records(ctx, w.ID, "question", "proposed") - if err != nil || len(proposed) != 0 { - t.Fatalf("filter: %+v %v", proposed, err) - } -} - -// Part A (h): each malformed grant is refused and stores nothing. -func TestPartAGoAheadBoundsAndTargets(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Bounds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) - good := GoAheadGrant{WorkstreamID: w.ID, Scope: "job", Factory: "f", Job: 4, CapUSD: 1, EndsAt: now.Add(time.Hour)} - for _, tc := range []struct { - name string - change func(*GoAheadGrant) - }{ - {"scope", func(p *GoAheadGrant) { p.Scope = "" }}, - {"cap absent", func(p *GoAheadGrant) { p.CapUSD = 0 }}, - {"negative cap", func(p *GoAheadGrant) { p.CapUSD = -1 }}, - {"large cap", func(p *GoAheadGrant) { p.CapUSD = 10000.01 }}, - {"nan cap", func(p *GoAheadGrant) { p.CapUSD = math.NaN() }}, - {"infinite cap", func(p *GoAheadGrant) { p.CapUSD = math.Inf(1) }}, - {"end absent", func(p *GoAheadGrant) { p.EndsAt = time.Time{} }}, - {"end past", func(p *GoAheadGrant) { p.EndsAt = now.Add(-time.Second) }}, - {"end now", func(p *GoAheadGrant) { p.EndsAt = *now }}, - {"end too far", func(p *GoAheadGrant) { p.EndsAt = now.Add(90*24*time.Hour + time.Millisecond) }}, - {"factory", func(p *GoAheadGrant) { p.Factory = "" }}, - {"job", func(p *GoAheadGrant) { p.Job = 0 }}, - {"not member", func(p *GoAheadGrant) { p.Job = 5 }}, - {"wave missing", func(p *GoAheadGrant) { p.Scope = "wave"; p.Factory = ""; p.Job = 0; p.Wave = 1 }}, - {"milestone missing", func(p *GoAheadGrant) { p.Scope = "milestone"; p.Factory = ""; p.Job = 0; p.MilestoneID = "missing" }}, - } { - t.Run(tc.name, func(t *testing.T) { - isolated, _, _ := testStore(t) - stream := createStream(t, isolated, "Bounds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) - p := good - p.WorkstreamID = stream.ID - tc.change(&p) - _, c, err := isolated.GrantGoAhead(ctx, p) - if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("grant: %+v %v", c, err) - } - all, err := isolated.GoAheads(ctx, stream.ID, true) - if err != nil || len(all) != 0 { - t.Fatalf("stored invalid grant: %+v %v", all, err) - } - }) - } - good.CapUSD = 10000 - good.EndsAt = now.Add(90 * 24 * time.Hour) - if _, _, err := s.GrantGoAhead(ctx, good); err != nil { - t.Fatalf("inclusive limits: %v", err) - } -} - -// Part A (e): MarkDone ends every scope together, is idempotent, and preserves -// prior revocations. Expiry is derived by reads without mutating storage. -func TestPartAMarkDoneEndsGoAheadsAndExpiry(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "GoAheads", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) - plan, _, err := s.SetPlan(ctx, w.ID, 0, []Milestone{{ID: "ship", Title: "Ship"}}, []Wave{{N: 1}}, Attribution{}) - if err != nil || plan.Version != 1 { - t.Fatal(err) - } - for _, p := range []GoAheadGrant{ - {Scope: "job", Factory: "f", Job: 4}, {Scope: "wave", Wave: 1}, {Scope: "milestone", MilestoneID: "ship"}, - } { - p.WorkstreamID = w.ID - p.CapUSD = 20 - p.EndsAt = now.Add(time.Hour) - if _, _, err := s.GrantGoAhead(ctx, p); err != nil { - t.Fatal(err) - } - } - all, _ := s.GoAheads(ctx, w.ID, true) - if _, _, err := s.RevokeGoAhead(ctx, all[0].ID); err != nil { - t.Fatal(err) - } - _, c, err := s.MarkDone(ctx, w.ID, "Shipped") - if err != nil || len(c.Parts) != 2 { - t.Fatalf("done: %+v %v", c, err) - } - active, _ := s.GoAheads(ctx, w.ID, false) - if len(active) != 0 { - t.Fatalf("active: %+v", active) - } - all, _ = s.GoAheads(ctx, w.ID, true) - ended := 0 - for _, g := range all { - if g.State != "ended" || g.EndedAt.IsZero() { - t.Fatalf("not ended: %+v", g) - } - if g.EndReason == "workstream_done" { - ended++ - } - } - if ended != 2 { - t.Fatalf("ended %d active scopes", ended) - } - _, c, err = s.MarkDone(ctx, w.ID, "Changed") - if err != nil || len(c.Parts) != 0 { - t.Fatalf("repeated done: %+v %v", c, err) - } - if _, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: w.ID, Scope: "wave", Wave: 1, CapUSD: 2, EndsAt: now.Add(time.Hour)}); !errors.Is(err, ErrConflict) { - t.Fatalf("grant on done: %v", err) - } - fresh := createStream(t, s, "Expiry", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "9", Factory: "f"}) - g, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: fresh.ID, Scope: "job", Factory: "f", Job: 9, CapUSD: 2, EndsAt: now.Add(time.Minute)}) - if err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - all, err = s.GoAheads(ctx, fresh.ID, true) - if err != nil || len(all) != 1 || all[0].EndReason != "expired" || !all[0].EndedAt.Equal(g.EndsAt) { - t.Fatalf("expiry: %+v %v", all, err) - } - active, _ = s.GoAheads(ctx, fresh.ID, false) - if len(active) != 0 { - t.Fatal("expired shown active") - } - g, c, err = s.RevokeGoAhead(ctx, g.ID) - if err != nil || g.EndReason != "expired" || len(c.Parts) != 0 { - t.Fatalf("expired revoke: %+v %+v %v", g, c, err) - } - if _, _, err := s.MarkDone(ctx, fresh.ID, ""); err != nil { - t.Fatal(err) - } - all, err = s.GoAheads(ctx, fresh.ID, true) - if err != nil || len(all) != 1 || all[0].EndReason != "expired" { - t.Fatalf("done changed expiry: %+v %v", all, err) - } -} - -// Part A (j): unknown readings stay absent and measured zero stays present. -func TestPartANumbersUnknownAndMeasuredZero(t *testing.T) { - jobs := []Job{{Factory: "f", Number: 1}, {Factory: "f", Number: 2}} - zero := 0.0 - no := false - yes := true - n := CountNumbers(jobs, nil, &zero, 3) - if n.Total != 2 || n.Merged != nil || n.NeedsYou != nil || n.SpendUSD != nil { - t.Fatalf("unknown: %+v", n) - } - facts := map[Job]JobFacts{jobs[0]: {State: "backlog", Merged: &no, CostUSD: &zero}, jobs[1]: {State: "backlog", Merged: &no, CostUSD: &zero}} - n = CountNumbers(jobs, facts, &zero, 0) - if n.Merged == nil || *n.Merged != 0 || n.NeedsYou == nil || *n.NeedsYou != 0 || n.SpendUSD == nil || *n.SpendUSD != 0 { - t.Fatalf("zero: %+v", n) - } - cost := 2.5 - agent := 1.0 - facts[jobs[0]] = JobFacts{State: "needs_you", Merged: &yes, CostUSD: &cost} - n = CountNumbers(jobs, facts, &agent, 3) - if *n.Merged != 1 || *n.NeedsYou != 4 || *n.SpendUSD != 3.5 { - t.Fatalf("numbers: %+v", n) - } - n = CountNumbers(jobs, facts, nil, 0) - if n.SpendUSD != nil || n.Merged == nil || n.NeedsYou == nil { - t.Fatalf("unknown agent: %+v", n) - } -} - -func TestPartANumbersWithPartiallyUnknownFacts(t *testing.T) { - jobs := []Job{{Factory: "f", Number: 1}, {Factory: "f", Number: 2}} - zero := 0.0 - no := false - known := JobFacts{State: "done", Merged: &no, CostUSD: &zero} - for _, facts := range []map[Job]JobFacts{ - {jobs[0]: known}, - {jobs[0]: known, jobs[1]: {State: "unknown", Merged: &no, CostUSD: &zero}}, - } { - n := CountNumbers(jobs, facts, &zero, 0) - if n.Total != 2 || n.Merged != nil || n.NeedsYou != nil || n.SpendUSD != nil { - t.Fatalf("partial unknown: %+v", n) - } - } - n := CountNumbers(jobs, map[Job]JobFacts{jobs[0]: known, jobs[1]: {State: "done", Merged: &no}}, &zero, 0) - if n.Merged == nil || n.NeedsYou == nil || n.SpendUSD != nil { - t.Fatalf("unknown cost: %+v", n) - } - n = CountNumbers(nil, nil, &zero, 2) - if n.Total != 0 || n.Merged == nil || *n.Merged != 0 || n.NeedsYou == nil || *n.NeedsYou != 2 || n.SpendUSD == nil || *n.SpendUSD != 0 { - t.Fatalf("no jobs: %+v", n) - } -} diff --git a/internal/workstream/sources.go b/internal/workstream/sources.go deleted file mode 100644 index 3d75422ca0..0000000000 --- a/internal/workstream/sources.go +++ /dev/null @@ -1,391 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "os" - "path/filepath" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const ( - SourceFile = "file" - SourceRepo = "repo" - SourceIssue = "issue" - SourceLink = "link" - SourceKept = "kept" - SourcesMax = 2000 - CreateSourcesMax = 200 - SourceTitleMax = 200 - SourcePathMax = 4096 - SourceURLMax = 2048 - SourceIssueMax = 2147483647 - SourceRefMax = 128 - SourceWhyMax = 500 -) - -type KeptRef struct { - Kind string `json:"kind"` - ID string `json:"id"` - ChatID *string `json:"chat_id,omitempty"` - Factory *string `json:"factory,omitempty"` -} - -// Pointers distinguish an absent field from a supplied field of another kind. -type SourceAdd struct { - Kind string `json:"kind"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *KeptRef `json:"ref,omitempty"` - Title *string `json:"title,omitempty"` - ReadByJobs bool `json:"read_by_jobs,omitempty"` -} - -type SourceItem struct { - ID string - WorkstreamID string - Kind string - Title string - Path string - Repo string - Number int - URL string - Ref *KeptRef - ReadByJobs bool - Proposed bool - Why string - AddedBy string - AddedAt time.Time - Gone bool - target string -} - -type SourcePatch struct { - Title *string - ReadByJobs *bool - Keep *bool -} - -type preparedSource struct { - SourceAdd - target string - title string -} - -const sourceItemSelect = `SELECT id,workstream_id,kind,title,coalesce(path,''),coalesce(repo,''),coalesce(number,0),coalesce(url,''),ref,read_by_jobs,proposed,coalesce(why,''),added_by,added_at,target FROM workstream_sources` - -type sourceScanner interface{ Scan(...any) error } - -func scanSourceItem(row sourceScanner) (SourceItem, error) { - var out SourceItem - var ref sql.NullString - var at int64 - err := row.Scan(&out.ID, &out.WorkstreamID, &out.Kind, &out.Title, &out.Path, &out.Repo, &out.Number, &out.URL, &ref, &out.ReadByJobs, &out.Proposed, &out.Why, &out.AddedBy, &at, &out.target) - if err != nil { - return SourceItem{}, found(err) - } - out.AddedAt = instant(at) - if ref.Valid { - err = json.Unmarshal([]byte(ref.String), &out.Ref) - } - return out, err -} - -func sourceItemIn(ctx context.Context, tx *sql.Tx, id string) (SourceItem, error) { - return scanSourceItem(tx.QueryRowContext(ctx, sourceItemSelect+" WHERE id=?", id)) -} - -func (s *Store) GetSource(ctx context.Context, id string) (SourceItem, error) { - var out SourceItem - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < workspace.WorkstreamSourcesSchemaVersion { - return ErrNotFound - } - var err error - out, err = sourceItemIn(ctx, tx, id) - return err - }) - return out, err -} - -func (s *Store) Sources(ctx context.Context, id string) ([]SourceItem, error) { - out := []SourceItem{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - if version < workspace.WorkstreamSourcesSchemaVersion { - return nil - } - rows, err := tx.QueryContext(ctx, sourceItemSelect+" WHERE workstream_id=? ORDER BY seq", id) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - item, err := scanSourceItem(rows) - if err != nil { - return err - } - out = append(out, item) - } - return rows.Err() - }) - if err != nil { - return nil, err - } - // Filesystem observations must not hold the database's read transaction. - for i := range out { - path := sourceItemPath(out[i]) - if path == "" { - continue - } - _, err := os.Stat(path) - out[i].Gone = errors.Is(err, os.ErrNotExist) - } - return out, nil -} - -func sourceItemPath(item SourceItem) string { - if item.Kind == SourceFile { - return item.Path - } - if item.Kind == SourceRepo && filepath.IsAbs(item.Repo) { - return item.Repo - } - return "" -} - -func sourceWriteAllowed(ctx context.Context, tx *sql.Tx, id string, proposed bool) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if w.DoneAt.IsZero() { - return nil - } - if proposed { - return ErrDone - } - return ErrConflict -} - -func (s *Store) AddSource(ctx context.Context, id string, p SourceAdd) (SourceItem, bool, Change, error) { - return s.addSource(ctx, id, p, false, "") -} - -func (s *Store) ProposeSource(ctx context.Context, id string, p SourceAdd, why string) (SourceItem, bool, Change, error) { - if err := ValidateSourceWhy(why); err != nil { - return SourceItem{}, false, Change{}, err - } - if p.ReadByJobs { - return SourceItem{}, false, Change{}, invalid("a proposal cannot be read by jobs") - } - return s.addSource(ctx, id, p, true, why) -} - -func (s *Store) addSource(ctx context.Context, id string, p SourceAdd, proposed bool, why string) (SourceItem, bool, Change, error) { - prepared, err := prepareSource(p) - if err != nil { - return SourceItem{}, false, Change{}, err - } - // Check references before a writer can initialize or migrate the file. - err = s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - if err := sourceWriteAllowed(ctx, tx, id, proposed); err != nil { - return err - } - return sourceKeptIn(ctx, tx, id, &prepared) - }) - if err != nil { - return SourceItem{}, false, Change{}, err - } - var out SourceItem - var already bool - var change Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if err := sourceWriteAllowed(ctx, tx, id, proposed); err != nil { - return err - } - if err := sourceKeptIn(ctx, tx, id, &prepared); err != nil { - return err - } - var wrote bool - var err error - out, already, wrote, err = s.addSourceIn(ctx, tx, id, prepared, proposed, why) - if err != nil || !wrote { - return err - } - if err := touch(ctx, tx, id, s.now().UnixMilli()); err != nil { - return err - } - change = changed(id, PartSources) - return nil - }) - if err != nil { - return SourceItem{}, false, Change{}, err - } - return out, already, change, nil -} - -func (s *Store) addSourceIn(ctx context.Context, tx *sql.Tx, id string, p preparedSource, proposed bool, why string) (SourceItem, bool, bool, error) { - existing, err := scanSourceItem(tx.QueryRowContext(ctx, sourceItemSelect+" WHERE workstream_id=? AND kind=? AND target=?", id, p.Kind, p.target)) - if err == nil { - if proposed || !existing.Proposed { - return existing, true, false, nil - } - title := existing.Title - if p.Title != nil { - title = *p.Title - } - _, err = tx.ExecContext(ctx, "UPDATE workstream_sources SET proposed=0,title=?,read_by_jobs=? WHERE id=?", title, p.ReadByJobs, existing.ID) - if err != nil { - return SourceItem{}, false, false, err - } - out, err := sourceItemIn(ctx, tx, existing.ID) - return out, true, true, err - } - if !errors.Is(err, ErrNotFound) { - return SourceItem{}, false, false, err - } - if err := sourceLimitIn(ctx, tx, id); err != nil { - return SourceItem{}, false, false, err - } - return s.insertSourceIn(ctx, tx, id, p, proposed, why) -} - -func sourceLimitIn(ctx context.Context, tx *sql.Tx, id string) error { - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_sources WHERE workstream_id=?", id).Scan(&count); err != nil { - return err - } - if count >= SourcesMax { - return ErrLimit - } - return nil -} - -func (s *Store) insertSourceIn(ctx context.Context, tx *sql.Tx, id string, p preparedSource, proposed bool, why string) (SourceItem, bool, bool, error) { - sourceID, err := s.newID() - if err != nil { - return SourceItem{}, false, false, err - } - var ref any - if p.Ref != nil { - b, err := json.Marshal(p.Ref) - if err != nil { - return SourceItem{}, false, false, err - } - ref = string(b) - } - by := "you" - if proposed { - by = "agent" - } - _, err = tx.ExecContext(ctx, `INSERT INTO workstream_sources(id,workstream_id,kind,target,title,path,repo,number,url,ref,why,read_by_jobs,proposed,added_by,added_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, sourceID, id, p.Kind, p.target, p.title, p.Path, p.Repo, p.Number, p.URL, ref, nullString(why), p.ReadByJobs, proposed, by, s.now().UnixMilli()) - if err != nil { - return SourceItem{}, false, false, err - } - out, err := sourceItemIn(ctx, tx, sourceID) - return out, false, true, err -} - -func ValidateSourcePatch(p SourcePatch) error { - if p.Title == nil && p.ReadByJobs == nil && p.Keep == nil { - return invalid("source update needs title, read_by_jobs or keep") - } - if p.Keep != nil && !*p.Keep { - return invalid("keep can only be true") - } - return sourceTitle(p.Title) -} - -func (s *Store) UpdateSource(ctx context.Context, id string, p SourcePatch) (SourceItem, Change, error) { - if err := ValidateSourcePatch(p); err != nil { - return SourceItem{}, Change{}, err - } - var out SourceItem - var change Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - item, err := sourceItemIn(ctx, tx, id) - if err != nil { - return err - } - if err := sourceWriteAllowed(ctx, tx, item.WorkstreamID, false); err != nil { - return err - } - out = item - if err := sourcePatchItem(&out, p); err != nil { - return err - } - if out.Title == item.Title && out.ReadByJobs == item.ReadByJobs && out.Proposed == item.Proposed { - return nil - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_sources SET title=?,read_by_jobs=?,proposed=? WHERE id=?", out.Title, out.ReadByJobs, out.Proposed, id); err != nil { - return err - } - if err := touch(ctx, tx, item.WorkstreamID, s.now().UnixMilli()); err != nil { - return err - } - change = changed(item.WorkstreamID, PartSources) - return nil - }) - if err != nil { - return SourceItem{}, Change{}, err - } - return out, change, nil -} - -func sourcePatchItem(item *SourceItem, p SourcePatch) error { - if p.Keep != nil { - item.Proposed = false - } - if p.ReadByJobs != nil { - if *p.ReadByJobs && item.Proposed { - return invalid("keep a proposal before enabling read_by_jobs") - } - item.ReadByJobs = *p.ReadByJobs - } - if p.Title != nil { - item.Title = *p.Title - } - return nil -} - -func (s *Store) RemoveSource(ctx context.Context, id string) (SourceItem, Change, error) { - var out SourceItem - var change Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - var err error - out, err = sourceItemIn(ctx, tx, id) - if err != nil { - return err - } - if err := sourceWriteAllowed(ctx, tx, out.WorkstreamID, false); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, "DELETE FROM workstream_sources WHERE id=?", id); err != nil { - return err - } - if err := touch(ctx, tx, out.WorkstreamID, s.now().UnixMilli()); err != nil { - return err - } - change = changed(out.WorkstreamID, PartSources) - return nil - }) - if err != nil { - return SourceItem{}, Change{}, err - } - return out, change, nil -} diff --git a/internal/workstream/sources_migration_test.go b/internal/workstream/sources_migration_test.go deleted file mode 100644 index a0c3581f3b..0000000000 --- a/internal/workstream/sources_migration_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func workstreamSourceStoredRows(t *testing.T, collections *workspace.Store) map[string][][]any { - t.Helper() - out := map[string][][]any{} - err := collections.Read(context.Background(), func(tx *sql.Tx, version int) error { - tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} - if version >= workspace.WorkstreamWakeSchemaVersion { - tables = append(tables, "workstream_wakes", "workstream_wake_items") - } - for _, table := range tables { - rows, err := tx.Query("SELECT * FROM " + table + " ORDER BY 1,2") - if err != nil { - return err - } - columns, err := rows.Columns() - if err != nil { - rows.Close() - return err - } - out[table] = nil - for rows.Next() { - values := make([]any, len(columns)) - pointers := make([]any, len(columns)) - for i := range values { - pointers[i] = &values[i] - } - if err := rows.Scan(pointers...); err != nil { - rows.Close() - return err - } - out[table] = append(out[table], values) - } - err = rows.Err() - rows.Close() - if err != nil { - return err - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } - return out -} - -func workstreamSourceSchemaVersion(t *testing.T, collections *workspace.Store) int { - t.Helper() - var version int - if err := collections.Read(context.Background(), func(tx *sql.Tx, _ int) error { return tx.QueryRow("PRAGMA user_version").Scan(&version) }); err != nil { - t.Fatal(err) - } - return version -} - -// WP-12 (i): the real source.add path reads older schemas without writing, then -// migrates to schema five while retaining every earlier row except its required -// workstream updated_at touch. Every existing domain read keeps its behavior. -func TestWorkstreamSourceAddMigratesSchemaThreeAndPreservesRows(t *testing.T) { - workstreamSourceMigration(t, 3) -} - -func TestWorkstreamSourceAddMigratesSchemaFourAndPreservesRows(t *testing.T) { - workstreamSourceMigration(t, 4) -} - -func workstreamSourceMigration(t *testing.T, version int) { - t.Helper() - ctx := context.Background() - path := filepath.Join(t.TempDir(), "collections.db") - collections, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) - s := New(collections, Options{Now: func() time.Time { return now }}) - w := createStream(t, s, "Migrated sources", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}) - pages, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.SavePage(ctx, pages[0].ID, 1, json.RawMessage(`{"type":"doc","content":[]}`), Attribution{}); err != nil { - t.Fatal(err) - } - if _, _, err := s.SetPlan(ctx, w.ID, 0, []Milestone{{ID: "m", Title: "Milestone"}}, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { - t.Fatal(err) - } - if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Keep rows.", Kept: true}); err != nil { - t.Fatal(err) - } - proposal, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Allow wave.", GoAhead: &GoAheadProposal{Scope: "wave", Wave: 1, CapUSD: 1, EndsAt: now.Add(time.Hour)}}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.KeepRecord(ctx, proposal.ID); err != nil { - t.Fatal(err) - } - if err := collections.Write(ctx, func(tx *sql.Tx) error { - query := "DROP TABLE workstream_sources; DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3" - if version == 4 { - if _, err := tx.Exec(`INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,cost_usd,started_at,settled_at) - VALUES ('wake',?,'morning:2026-11-03','{"kind":"morning","at":"2026-11-03T08:00:00Z"}','settled','Review this.',0.04,100,200)`, w.ID); err != nil { - return err - } - if err := workstreamSaveWakeItemIn(ctx, tx, "wake", 0, WakeItem{ID: "wake-item", Kind: "decision", State: "proposed", Text: "Keep the context.", Why: "For review."}); err != nil { - return err - } - query = "DROP TABLE workstream_sources; PRAGMA user_version=4" - } - query = "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions; " + query - _, err := tx.Exec(query) - return err - }); err != nil { - t.Fatal(err) - } - if err := collections.Close(); err != nil { - t.Fatal(err) - } - bytesBefore, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - collections, err = workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer collections.Close() - s = New(collections, Options{Now: func() time.Time { return now }}) - before := workstreamSourceStoredRows(t, collections) - items, err := s.Sources(ctx, w.ID) - if err != nil || items == nil || len(items) != 0 || workstreamSourceSchemaVersion(t, collections) != version { - t.Fatalf("schema %d sources: %v %v", version, items, err) - } - if _, err := s.Get(ctx, w.ID); err != nil { - t.Fatal(err) - } - if got, err := collections.Members(ctx, w.ID); err != nil || len(got) != 2 || got[0].Factory != "f" { - t.Fatalf("schema %d members: %+v %v", version, got, err) - } - if _, err := s.Pages(ctx, w.ID); err != nil { - t.Fatal(err) - } - if _, err := s.PageHistory(ctx, pages[0].ID, 50); err != nil { - t.Fatal(err) - } - if _, err := s.Records(ctx, w.ID, "", "", true); err != nil { - t.Fatal(err) - } - if _, err := s.GoAheads(ctx, w.ID, true); err != nil { - t.Fatal(err) - } - if _, err := s.Plan(ctx, w.ID); err != nil { - t.Fatal(err) - } - wakes, err := s.Wakes(ctx, w.ID, 20) - if err != nil || version == 3 && len(wakes) != 0 || version == 4 && (len(wakes) != 1 || len(wakes[0].Items) != 1 || wakes[0].Items[0].ID != "wake-item") { - t.Fatalf("schema %d wakes: %+v %v", version, wakes, err) - } - cost, err := s.WakeSpendByAgent(ctx, w.AgentID) - if err != nil || version == 3 && cost != 0 || version == 4 && cost != 0.04 { - t.Fatalf("schema %d wake cost: %v %v", version, cost, err) - } - if changes, err := s.ExpireWakes(ctx); err != nil || len(changes) != 0 { - t.Fatalf("settled wakes changed: %+v %v", changes, err) - } - bytesAfter, err := os.ReadFile(path) - if err != nil || string(bytesBefore) != string(bytesAfter) { - t.Fatalf("schema %d reads wrote: %v", version, err) - } - item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")}) - if err != nil || workstreamSourceSchemaVersion(t, collections) != 6 { - t.Fatalf("migration: %+v %v", item, err) - } - after := workstreamSourceStoredRows(t, collections) - if version == 3 { - for _, table := range []string{"workstream_wakes", "workstream_wake_items"} { - if len(after[table]) != 0 { - t.Fatalf("migration populated %s", table) - } - delete(after, table) - } - } - // updated_at is the tenth column in the unchanged workstreams DDL. Source writes - // advance that timestamp; migration itself changes no existing value. - if after["workstreams"][0][9].(int64) <= before["workstreams"][0][9].(int64) { - t.Fatal("source write did not touch updated_at") - } - after["workstreams"][0][9] = before["workstreams"][0][9] - if !reflect.DeepEqual(after, before) { - t.Fatalf("migration changed existing rows: before=%v after=%v", before, after) - } - items, err = s.Sources(ctx, w.ID) - if err != nil || len(items) != 1 || items[0].ID != item.ID { - t.Fatalf("migrated sources: %v %v", items, err) - } -} diff --git a/internal/workstream/sources_test.go b/internal/workstream/sources_test.go deleted file mode 100644 index 7d8b1da726..0000000000 --- a/internal/workstream/sources_test.go +++ /dev/null @@ -1,556 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func workstreamSourcePtr[T any](v T) *T { return &v } - -func workstreamSourceFile(t *testing.T) string { - t.Helper() - p := filepath.Join(t.TempDir(), "usage.md") - if err := os.WriteFile(p, []byte("Recorded usage.\n"), 0600); err != nil { - t.Fatal(err) - } - return p -} - -// WP-12 (a): all nine target forms derive titles and retain only their kind's -// fields, in added order; every duplicate returns the original row. -func TestWorkstreamSourcesEveryKind(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Kinds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}) - record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Use totals.\nDetails.", Kept: true}) - if err != nil { - t.Fatal(err) - } - pages, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - path := workstreamSourceFile(t) - folder := t.TempDir() - cases := []struct { - add SourceAdd - title string - }{ - {SourceAdd{Kind: SourceFile, Path: &path}, "usage.md"}, - {SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing")}, "ACME/Billing"}, - {SourceAdd{Kind: SourceRepo, Repo: &folder}, filepath.Base(folder)}, - {SourceAdd{Kind: SourceIssue, Repo: workstreamSourcePtr("acme/billing"), Number: workstreamSourcePtr(412)}, "acme/billing#412"}, - {SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("HTTPS://Docs.Example.com/x/?query=yes#part")}, "docs.example.com/x"}, - {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: record.ID}}, "Use totals."}, - {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "page", ID: pages[0].ID}}, "Overview"}, - {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "message", ID: "message", ChatID: workstreamSourcePtr("chat")}}, "Kept message"}, - {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "factory_job", ID: "7", Factory: workstreamSourcePtr("f")}}, "Job 7"}, - } - want := []SourceItem{} - for _, tc := range cases { - item, already, _, err := s.AddSource(ctx, w.ID, tc.add) - if err != nil || already || item.Title != tc.title || item.Proposed || item.ReadByJobs || item.AddedBy != "you" || item.Why != "" { - t.Fatalf("%+v: %+v %v %v", tc.add, item, already, err) - } - duplicate := tc.add - if tc.add.Kind == SourceRepo && tc.add.Repo != &folder { - duplicate.Repo = workstreamSourcePtr("acme/billing") - } - if tc.add.Kind == SourceIssue { - duplicate.Repo = workstreamSourcePtr("ACME/Billing") - } - if tc.add.Kind == SourceLink { - duplicate.URL = workstreamSourcePtr("https://docs.example.com/x/?query=yes#part") - } - again, already, change, err := s.AddSource(ctx, w.ID, duplicate) - if err != nil || !already || !reflect.DeepEqual(item, again) || len(change.Parts) != 0 { - t.Fatalf("duplicate: %+v %v %+v %v", again, already, change, err) - } - want = append(want, item) - } - list, err := s.Sources(ctx, w.ID) - if err != nil || !reflect.DeepEqual(list, want) { - t.Fatalf("round trip: %+v %v", list, err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Remove(folder); err != nil { - t.Fatal(err) - } - list, err = s.Sources(ctx, w.ID) - if err != nil || !list[0].Gone || !list[2].Gone || list[1].Gone || list[3].Gone { - t.Fatalf("gone: %+v %v", list, err) - } -} - -// WP-12 (b), (c), (g): proposals cannot enable jobs, duplicates do not mutate -// them, and either person keep path preserves their attribution and reason. -func TestWorkstreamSourceProposalsAndKeeping(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Proposals") - add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/guide")} - proposal, already, _, err := s.ProposeSource(ctx, w.ID, add, "Read the guide.") - if err != nil || already || !proposal.Proposed || proposal.ReadByJobs || proposal.AddedBy != "agent" || proposal.Why != "Read the guide." { - t.Fatalf("proposal: %+v %v", proposal, err) - } - again, already, change, err := s.ProposeSource(ctx, w.ID, add, "Another reason.") - if err != nil || !already || !reflect.DeepEqual(again, proposal) || len(change.Parts) != 0 { - t.Fatalf("duplicate proposal: %+v %v", again, err) - } - if _, _, err := s.UpdateSource(ctx, proposal.ID, SourcePatch{ReadByJobs: workstreamSourcePtr(true)}); !errors.Is(err, ErrInvalid) { - t.Fatalf("proposal jobs: %v", err) - } - unchanged, err := s.GetSource(ctx, proposal.ID) - if err != nil || !reflect.DeepEqual(unchanged, proposal) { - t.Fatalf("refusal changed proposal: %+v %v", unchanged, err) - } - add.Title, add.ReadByJobs = workstreamSourcePtr("The guide"), true - kept, already, change, err := s.AddSource(ctx, w.ID, add) - if err != nil || !already || kept.ID != proposal.ID || kept.Title != "The guide" || kept.Proposed || !kept.ReadByJobs || kept.AddedBy != "agent" || kept.Why != proposal.Why || len(change.Parts) == 0 { - t.Fatalf("person keep: %+v %v %+v %v", kept, already, change, err) - } - if _, _, change, err := s.ProposeSource(ctx, w.ID, add, "Again."); !errors.Is(err, ErrInvalid) || len(change.Parts) != 0 { - t.Fatalf("agent cannot set jobs: %v", err) - } - add.ReadByJobs = false - if got, already, change, err := s.ProposeSource(ctx, w.ID, add, "Again."); err != nil || !already || got.Proposed || len(change.Parts) != 0 { - t.Fatalf("duplicate kept: %+v %v", got, err) - } - for _, patch := range []SourcePatch{{Keep: workstreamSourcePtr(true)}, {Title: workstreamSourcePtr(kept.Title)}, {ReadByJobs: workstreamSourcePtr(true)}} { - got, change, err := s.UpdateSource(ctx, kept.ID, patch) - if err != nil || !reflect.DeepEqual(got, kept) || len(change.Parts) != 0 { - t.Fatalf("no-op: %+v %+v %v", got, change, err) - } - } -} - -// WP-12 (b): every invalid kind field is refused before a write and leaves -// the source list and the workstream's timestamp unchanged. -func TestWorkstreamSourcesInvalidInputsStoreNothing(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Invalid sources") - path := workstreamSourceFile(t) - bad := []SourceAdd{ - {Kind: SourceFile, Path: workstreamSourcePtr("relative")}, - {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "/../usage.md")}, - {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "/./usage.md")}, - {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "//usage.md")}, - {Kind: SourceFile, Path: workstreamSourcePtr(path + "/")}, - {Kind: SourceFile, Path: workstreamSourcePtr(path + "\x00")}, - {Kind: SourceFile, Path: workstreamSourcePtr(path + "\n")}, - {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Join(t.TempDir(), "missing"))}, - {Kind: SourceFile, Path: workstreamSourcePtr("/" + strings.Repeat("x", 4096))}, - {Kind: SourceFile, Path: workstreamSourcePtr(string([]byte{'/', 255}))}, - {Kind: SourceFile, Path: &path, URL: workstreamSourcePtr("")}, - {Kind: SourceRepo, Repo: &path}, - {Kind: SourceIssue, Repo: &path, Number: workstreamSourcePtr(1)}, - {Kind: SourceKept, Ref: &KeptRef{Kind: "factory_job", ID: "7", Factory: workstreamSourcePtr("elsewhere")}}, - } - for _, repo := range []string{"../x", "owner/..", "-owner/x", "owner/name/extra", "relative", "owner/.", "owner/"} { - bad = append(bad, SourceAdd{Kind: SourceRepo, Repo: &repo}) - } - for _, number := range []int{0, -1, 2147483648} { - bad = append(bad, SourceAdd{Kind: SourceIssue, Repo: workstreamSourcePtr("owner/name"), Number: &number}) - } - for _, url := range []string{"file:///etc/passwd", "javascript:alert(1)", "ftp://h/x", "//h/x", "https:///x", "https://user:pw@h/x", "https://h/x y", "https://h/x\n", "https://h/" + strings.Repeat("x", 2039)} { - bad = append(bad, SourceAdd{Kind: SourceLink, URL: &url}) - } - for _, title := range []string{"", strings.Repeat("x", 201), "two\nlines", " untrimmed ", "two\u2028lines"} { - bad = append(bad, SourceAdd{Kind: SourceFile, Path: &path, Title: &title}) - } - for i, add := range bad { - _, _, change, err := s.AddSource(ctx, w.ID, add) - if !errors.Is(err, ErrInvalid) || len(change.Parts) != 0 { - t.Fatalf("invalid %d: %v %+v", i, err, change) - } - } - for _, ref := range []*KeptRef{{Kind: "record", ID: "missing"}, {Kind: "page", ID: "missing"}} { - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrNotFound) { - t.Fatalf("unknown kept: %v", err) - } - } - list, err := s.Sources(ctx, w.ID) - after, getErr := s.Get(ctx, w.ID) - if err != nil || getErr != nil || len(list) != 0 || !after.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("refused sources changed state: %+v %v %v", list, err, getErr) - } -} - -// WP-12 (b), (d): kept rows must belong here and removal never deletes a -// record or page. Deleted pages cannot be kept. -func TestWorkstreamSourcesKeptOwnershipAndRemoval(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Kept") - other := createStream(t, s, "Other") - record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Keep?"}) - if err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes"}) - if err != nil { - t.Fatal(err) - } - for _, ref := range []*KeptRef{{Kind: "record", ID: record.ID}, {Kind: "page", ID: page.ID}} { - if _, _, _, err := s.AddSource(ctx, other.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrNotFound) { - t.Fatalf("foreign ref: %v", err) - } - item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.RemoveSource(ctx, item.ID); err != nil { - t.Fatal(err) - } - } - if records, err := s.Records(ctx, w.ID, "", ""); err != nil || len(records) != 1 { - t.Fatalf("removed record: %v %v", records, err) - } - if _, err := s.Page(ctx, page.ID, false); err != nil { - t.Fatal(err) - } - if _, _, err := s.DeletePage(ctx, page.ID); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "page", ID: page.ID}}); !errors.Is(err, ErrNotFound) { - t.Fatalf("deleted page: %v", err) - } -} - -// WP-12 (e), (f): create validates all sources atomically, preserves why, -// folds duplicate targets, and ignores sources on a client retry. -func TestWorkstreamCreateSourcesAreAtomic(t *testing.T) { - s, collections, _ := testStore(t) - ctx := context.Background() - path := workstreamSourceFile(t) - p := Create{Name: "Created sources", AgentID: "agent", ClientID: "client", Why: " Exactly as sent.\n", Sources: []SourceAdd{{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing"), ReadByJobs: true}, {Kind: SourceFile, Path: &path}, {Kind: SourceRepo, Repo: workstreamSourcePtr("acme/billing")}}} - w, _, err := s.Create(ctx, p) - if err != nil || w.Why != p.Why { - t.Fatalf("create: %+v %v", w, err) - } - items, err := s.Sources(ctx, w.ID) - if err != nil || len(items) != 2 || !items[0].ReadByJobs || items[1].ReadByJobs { - t.Fatalf("create sources: %+v %v", items, err) - } - p.Sources = []SourceAdd{{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/new")}} - again, change, err := s.Create(ctx, p) - if err != nil || again.ID != w.ID || len(change.Parts) != 0 { - t.Fatalf("retry: %+v %v", again, err) - } - items, err = s.Sources(ctx, w.ID) - if err != nil || len(items) != 2 { - t.Fatalf("retry added sources: %v %v", items, err) - } - for _, bad := range []SourceAdd{{Kind: SourceFile, Path: workstreamSourcePtr("relative")}, {Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: "missing"}}} { - p.Name, p.ClientID, p.Sources = "Free name", "", []SourceAdd{{Kind: SourceFile, Path: &path}, bad} - if _, _, err := s.Create(ctx, p); !errors.Is(err, ErrInvalid) { - t.Fatalf("bad create: %v", err) - } - } - rows, err := collections.Collections(ctx) - if err != nil || len(rows) != 1 { - t.Fatalf("partial create: %v %v", rows, err) - } - p.Sources = nil - if _, _, err := s.Create(ctx, p); err != nil { - t.Fatalf("name was taken: %v", err) - } - if err := collections.Add(ctx, w.ID, workspace.Ref{Kind: workspace.ArtifactKind, ID: path}); err != nil { - t.Fatal(err) - } - items, err = s.Sources(ctx, w.ID) - if err != nil || len(items) != 2 { - t.Fatalf("artifact became a source: %v %v", items, err) - } -} - -// WP-12 Fix 1 Major 2 (e): an identical client retry returns its first row -// even after an initial source disappears; a new client still validates it. -func TestWorkstreamMajor2CreateRetryE(t *testing.T) { - s, collections, _ := testStore(t) - ctx := context.Background() - path := workstreamSourceFile(t) - p := Create{Name: "Retry with removed source", AgentID: "agent", ClientID: "retry-removed", Sources: []SourceAdd{{Kind: SourceFile, Path: &path}}} - w, _, err := s.Create(ctx, p) - if err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - before, err := s.Sources(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - again, change, err := s.Create(ctx, p) - if err != nil || !reflect.DeepEqual(again, w) || change.ID != "" || len(change.Parts) != 0 { - t.Fatalf("retry: %+v %+v %v", again, change, err) - } - after, err := s.Sources(ctx, w.ID) - if err != nil || len(after) != 1 || !reflect.DeepEqual(after, before) { - t.Fatalf("retry changed sources: %+v %v", after, err) - } - p.ClientID, p.Name = "new-client", "New client name" - if _, _, err := s.Create(ctx, p); !errors.Is(err, ErrInvalid) { - t.Fatalf("new client bypassed validation: %v", err) - } - rows, err := collections.Collections(ctx) - if err != nil || len(rows) != 1 { - t.Fatalf("new client wrote rows: %+v %v", rows, err) - } - p.Sources = nil - if _, _, err := s.Create(ctx, p); err != nil { - t.Fatalf("refusal reserved name or client: %v", err) - } -} - -// WP-12 Fix 1 Minor 1 (a), (b): the normalized URL obeys the byte bound and -// a fitting multibyte URL remains an idempotent target when re-added as returned. -func TestWorkstreamMinor1LinkBoundAB(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Normalized URL bounds") - tooLong := "https://h/" + strings.Repeat("é", 1019) - if len(tooLong) != 2048 { - t.Fatal("fixture must be exactly 2048 input bytes") - } - _, _, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &tooLong}) - if !errors.Is(err, ErrInvalid) || change.ID != "" || len(change.Parts) != 0 { - t.Fatalf("expanded URL accepted: %+v %v", change, err) - } - items, err := s.Sources(ctx, w.ID) - if err != nil || len(items) != 0 { - t.Fatalf("invalid URL stored: %+v %v", items, err) - } - unchanged, err := s.Get(ctx, w.ID) - if err != nil || !unchanged.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("invalid URL touched workstream: %+v %v", unchanged, err) - } - fits := "https://h/" + strings.Repeat("é", 339) + "abcd" - want := "https://h/" + strings.Repeat("%C3%A9", 339) + "abcd" - item, already, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &fits}) - if err != nil || already || item.URL != want || len(item.URL) != 2048 { - t.Fatalf("fitting URL: %+v %v %v", item, already, err) - } - again, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &item.URL}) - if err != nil || !already || !reflect.DeepEqual(again, item) || change.ID != "" || len(change.Parts) != 0 { - t.Fatalf("returned URL retry: %+v %v %+v %v", again, already, change, err) - } -} - -// WP-12 (c): the Read by jobs toggle persists through reopening the file. -func TestWorkstreamSourcesToggleSurvivesReopen(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "collections.db") - collections, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - s := New(collections, Options{}) - w := createStream(t, s, "Persistence") - item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")}) - if err != nil { - t.Fatal(err) - } - for _, flag := range []bool{true, false, true} { - if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{ReadByJobs: &flag}); err != nil { - t.Fatal(err) - } - list, err := s.Sources(ctx, w.ID) - if err != nil || len(list) != 1 || list[0].ReadByJobs != flag { - t.Fatalf("toggle: %v %v", list, err) - } - } - if err := collections.Close(); err != nil { - t.Fatal(err) - } - collections, err = workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer collections.Close() - s = New(collections, Options{}) - list, err := s.Sources(ctx, w.ID) - if err != nil || len(list) != 1 || !list[0].ReadByJobs { - t.Fatalf("reopen: %+v %v", list, err) - } -} - -// WP-12 (h): done is read-only for every source write, and reopen restores them. -func TestWorkstreamSourcesDoneAndReopen(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Done sources") - add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")} - item, _, _, err := s.AddSource(ctx, w.ID, add) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.AddSource(ctx, w.ID, add); !errors.Is(err, ErrConflict) { - t.Fatalf("add done: %v", err) - } - if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("New")}); !errors.Is(err, ErrConflict) { - t.Fatalf("update done: %v", err) - } - if _, _, err := s.RemoveSource(ctx, item.ID); !errors.Is(err, ErrConflict) { - t.Fatalf("remove done: %v", err) - } - if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); !errors.Is(err, ErrDone) { - t.Fatalf("propose done: %v", err) - } - list, err := s.Sources(ctx, w.ID) - if err != nil || len(list) != 1 || !reflect.DeepEqual(item, list[0]) { - t.Fatalf("done list: %v %v", list, err) - } - if _, _, err := s.Reopen(ctx, w.ID); err != nil { - t.Fatal(err) - } - if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("New")}); err != nil { - t.Fatal(err) - } - if _, _, err := s.RemoveSource(ctx, item.ID); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); err != nil { - t.Fatal(err) - } -} - -// WP-12 (b): the 2,001st source refuses atomically, while duplicates still work. -func TestWorkstreamSourcesLimit(t *testing.T) { - s, collections, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Source limit") - if err := collections.Write(ctx, func(tx *sql.Tx) error { - statement, err := tx.Prepare("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,proposed,added_by,added_at) VALUES (?,?,'link',?,'Example',?,1,'agent',100)") - if err != nil { - return err - } - defer statement.Close() - for i := 0; i < SourcesMax; i++ { - url := fmt.Sprintf("https://example.com/%d", i) - if _, err := statement.Exec(fmt.Sprintf("limit-%d", i), w.ID, url, url); err != nil { - return err - } - } - return nil - }); err != nil { - t.Fatal(err) - } - add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/extra")} - if _, _, _, err := s.AddSource(ctx, w.ID, add); !errors.Is(err, ErrLimit) { - t.Fatalf("source limit: %v", err) - } - if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); !errors.Is(err, ErrLimit) { - t.Fatalf("proposal limit: %v", err) - } - add.URL = workstreamSourcePtr("https://example.com/0") - item, already, _, err := s.AddSource(ctx, w.ID, add) - if err != nil || !already || item.Proposed { - t.Fatalf("keep at limit: %+v %v", item, err) - } -} - -// WP-12 (a), (b): derived titles cut at a UTF-8 boundary, supplied titles and -// file base names preserve internal spaces, and kept ids enforce byte bounds. -func TestWorkstreamSourceTitlesAndKeptRefBounds(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Source text bounds") - record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: strings.Repeat("界", 67) + "\nDetails."}) - if err != nil { - t.Fatal(err) - } - item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: record.ID}}) - if err != nil || item.Title != strings.Repeat("界", 66) || !utf8.ValidString(item.Title) { - t.Fatalf("derived cut: %+v %v", item, err) - } - path := filepath.Join(t.TempDir(), "two spaces.md") - if err := os.WriteFile(path, []byte("Words"), 0600); err != nil { - t.Fatal(err) - } - item, _, _, err = s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceFile, Path: &path}) - if err != nil || item.Title != "two spaces.md" { - t.Fatalf("base title: %+v %v", item, err) - } - item, _, err = s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("Two spaces")}) - if err != nil || item.Title != "Two spaces" { - t.Fatalf("given title: %+v %v", item, err) - } - for _, length := range []int{0, 129} { - ref := &KeptRef{Kind: "message", ID: strings.Repeat("x", length), ChatID: workstreamSourcePtr("chat")} - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrInvalid) { - t.Fatalf("kept id %d: %v", length, err) - } - ref.ID = "message" - ref.ChatID = workstreamSourcePtr(strings.Repeat("x", length)) - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrInvalid) { - t.Fatalf("kept chat %d: %v", length, err) - } - } - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "message", ID: strings.Repeat("x", 128), ChatID: workstreamSourcePtr(strings.Repeat("c", 128))}}); err != nil { - t.Fatal(err) - } -} - -// WP-12 (a), (c), (d), (f): sources retain identity, flags and order, without -// becoming members or changing the things they point at. -func TestWorkstreamSourcesRoundTripAndRemove(t *testing.T) { - s, collections, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Sources") - before, err := collections.Members(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - path := workstreamSourceFile(t) - file, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceFile, Path: &path, ReadByJobs: true}) - if err != nil || already || file.Title != "usage.md" || file.AddedBy != "you" || file.Proposed || !file.ReadByJobs || len(change.Parts) == 0 { - t.Fatalf("file: %+v %v %+v %v", file, already, change, err) - } - repo, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing")}) - if err != nil { - t.Fatal(err) - } - duplicate, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("acme/billing")}) - if err != nil || !already || duplicate.ID != repo.ID || len(change.Parts) != 0 { - t.Fatalf("duplicate: %+v %v %+v %v", duplicate, already, change, err) - } - for _, flag := range []bool{false, true} { - if _, _, err := s.UpdateSource(ctx, file.ID, SourcePatch{ReadByJobs: &flag}); err != nil { - t.Fatal(err) - } - list, err := s.Sources(ctx, w.ID) - if err != nil || len(list) != 2 || list[0].ReadByJobs != flag || list[1].ID != repo.ID { - t.Fatalf("list: %+v %v", list, err) - } - } - after, err := collections.Members(ctx, w.ID) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("sources changed members: %+v %v", after, err) - } - removed, _, err := s.RemoveSource(ctx, file.ID) - if err != nil || !reflect.DeepEqual(removed, file) { - t.Fatalf("remove: %+v %v", removed, err) - } - data, err := os.ReadFile(path) - if err != nil || string(data) != "Recorded usage.\n" { - t.Fatalf("source removal changed file: %q %v", data, err) - } -} diff --git a/internal/workstream/sources_validation.go b/internal/workstream/sources_validation.go deleted file mode 100644 index 484e0cad90..0000000000 --- a/internal/workstream/sources_validation.go +++ /dev/null @@ -1,297 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "net/url" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "unicode" - "unicode/utf8" -) - -var sourceRepoPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$`) - -// ValidateSourceAdd checks kind fields and local existence without writing or reading contents. -func ValidateSourceAdd(p SourceAdd) error { - _, err := prepareSource(p) - return err -} - -func ValidateCreateSources(sources []SourceAdd) error { - _, err := prepareCreateSources(sources) - return err -} - -func prepareCreateSources(sources []SourceAdd) ([]preparedSource, error) { - if len(sources) > CreateSourcesMax { - return nil, invalid("create takes at most 200 sources") - } - out := make([]preparedSource, 0, len(sources)) - for _, p := range sources { - if p.Kind == SourceKept { - return nil, invalid("create cannot keep a source") - } - prepared, err := prepareSource(p) - if err != nil { - return nil, err - } - out = append(out, prepared) - } - return out, nil -} - -func sourceTitle(title *string) error { - if title == nil { - return nil - } - if len(*title) == 0 || !lineWithin(*title, SourceTitleMax) || strings.TrimSpace(*title) != *title || strings.TrimSpace(*title) == "" || strings.ContainsAny(*title, "\u2028\u2029") { - return invalid("source title must be 1–200 bytes of trimmed single-line text") - } - return nil -} - -func ValidateSourceWhy(why string) error { - if len(why) == 0 || !textWithin(why, SourceWhyMax) { - return invalid("a source proposal needs why of 1–500 bytes") - } - return nil -} - -func sourcePath(path string, folder bool) error { - if len(path) == 0 || !lineWithin(path, SourcePathMax) || !filepath.IsAbs(path) || filepath.Clean(path) != path { - return invalid("source path must be an existing clean absolute path of 1–4096 bytes") - } - info, err := os.Stat(path) - if err != nil { - return invalid("nothing readable exists at the source path") - } - if folder && !info.IsDir() { - return invalid("a local repo must be a folder") - } - return nil -} - -func sourceRepoName(repo string) bool { - if !sourceRepoPattern.MatchString(repo) { - return false - } - _, name, _ := strings.Cut(repo, "/") - return name != "." && name != ".." -} - -func sourceURL(raw string) (string, string, error) { - if len(raw) == 0 || len(raw) > SourceURLMax || !utf8.ValidString(raw) || strings.IndexFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { - return "", "", invalid("source URL must be 1–2048 bytes without whitespace or controls") - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" || u.User != nil || !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { - return "", "", invalid("source URL needs an http or https scheme, a host and no credentials") - } - u.Scheme, u.Host = strings.ToLower(u.Scheme), strings.ToLower(u.Host) - stored := u.String() - if len(stored) > SourceURLMax { - return "", "", invalid("normalized source URL must be at most " + strconv.Itoa(SourceURLMax) + " bytes") - } - return stored, strings.TrimRight(u.Host+u.Path, "/"), nil -} - -func sourceFields(p SourceAdd) error { - if p.Path != nil && p.Kind != SourceFile || p.Repo != nil && p.Kind != SourceRepo && p.Kind != SourceIssue || p.Number != nil && p.Kind != SourceIssue || p.URL != nil && p.Kind != SourceLink || p.Ref != nil && p.Kind != SourceKept { - return invalid("source has a field used by another kind") - } - return sourceTitle(p.Title) -} - -func prepareSource(p SourceAdd) (preparedSource, error) { - out := preparedSource{SourceAdd: p} - if err := sourceFields(p); err != nil { - return out, err - } - var err error - switch p.Kind { - case SourceFile: - err = prepareFileSource(&out) - case SourceRepo: - err = prepareRepoSource(&out) - case SourceIssue: - err = prepareIssueSource(&out) - case SourceLink: - err = prepareLinkSource(&out) - case SourceKept: - err = prepareKeptSource(&out) - default: - err = invalid("unknown source kind") - } - if p.Title != nil { - out.title = *p.Title - } else { - out.title = sourceDerivedTitle(out.title) - } - return out, err -} - -func prepareFileSource(p *preparedSource) error { - if p.Path == nil { - return invalid("file source needs path") - } - if err := sourcePath(*p.Path, false); err != nil { - return err - } - p.target, p.title = *p.Path, filepath.Base(*p.Path) - return nil -} - -func prepareRepoSource(p *preparedSource) error { - if p.Repo == nil { - return invalid("repo source needs repo") - } - p.target, p.title = strings.ToLower(*p.Repo), *p.Repo - if sourceRepoName(*p.Repo) { - return nil - } - if err := sourcePath(*p.Repo, true); err != nil { - return err - } - p.target, p.title = *p.Repo, filepath.Base(*p.Repo) - return nil -} - -func prepareIssueSource(p *preparedSource) error { - if p.Repo == nil || !sourceRepoName(*p.Repo) || p.Number == nil || *p.Number < 1 || *p.Number > SourceIssueMax { - return invalid("issue needs owner/name and number 1–2147483647") - } - p.title = *p.Repo + "#" + strconv.Itoa(*p.Number) - p.target = strings.ToLower(p.title) - return nil -} - -func prepareLinkSource(p *preparedSource) error { - if p.URL == nil { - return invalid("link source needs url") - } - stored, title, err := sourceURL(*p.URL) - if err != nil { - return err - } - p.URL, p.target, p.title = &stored, stored, title - return nil -} - -func prepareKeptSource(p *preparedSource) error { - if err := sourceKeptRef(p.Ref); err != nil { - return err - } - b, err := json.Marshal(p.Ref) - p.target = string(b) - return err -} - -func sourceRefID(id string) bool { - return len(id) > 0 && len(id) <= SourceRefMax && utf8.ValidString(id) -} - -func sourceKeptRef(ref *KeptRef) error { - if ref == nil || !sourceRefID(ref.ID) { - return invalid("kept source needs a ref with an id of 1–128 bytes") - } - if ref.Kind != "message" && ref.ChatID != nil || ref.Kind != "factory_job" && ref.Factory != nil { - return invalid("kept ref has fields used by another kind") - } - switch ref.Kind { - case "record", "page": - case "message": - if ref.ChatID == nil || !sourceRefID(*ref.ChatID) { - return invalid("kept message needs chat_id of 1–128 bytes") - } - case "factory_job": - if ref.Factory == nil || !sourceRefID(*ref.Factory) { - return invalid("kept job needs factory of 1–128 bytes") - } - if _, err := sourceJobNumber(ref.ID); err != nil { - return err - } - default: - return invalid("unknown kept ref kind") - } - return nil -} - -func sourceJobNumber(id string) (int, error) { - if strings.IndexFunc(id, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { - return 0, invalid("kept job needs a positive decimal job number") - } - n, err := strconv.Atoi(id) - if err != nil || n < 1 { - return 0, invalid("kept job needs a positive decimal job number") - } - return n, nil -} - -func sourceKeptIn(ctx context.Context, tx *sql.Tx, id string, p *preparedSource) error { - if p.Kind != SourceKept { - return nil - } - var title string - switch p.Ref.Kind { - case "record": - if err := tx.QueryRowContext(ctx, "SELECT text FROM workstream_records WHERE id=? AND workstream_id=?", p.Ref.ID, id).Scan(&title); err != nil { - return found(err) - } - title, _, _ = strings.Cut(title, "\n") - case "page": - if err := tx.QueryRowContext(ctx, "SELECT title FROM workstream_pages WHERE id=? AND workstream_id=? AND deleted_at IS NULL", p.Ref.ID, id).Scan(&title); err != nil { - return found(err) - } - case "message": - title = "Kept message" - case "factory_job": - if err := sourceKeptJobIn(ctx, tx, id, p.Ref); err != nil { - return err - } - title = "Job " + p.Ref.ID - } - if p.Title == nil { - p.title = sourceDerivedTitle(title) - } - return nil -} - -func sourceKeptJobIn(ctx context.Context, tx *sql.Tx, id string, ref *KeptRef) error { - number, err := sourceJobNumber(ref.ID) - if err != nil { - return err - } - var member bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_jobs WHERE workstream_id=? AND factory=? AND job=?)", id, *ref.Factory, strconv.Itoa(number)).Scan(&member); err != nil { - return err - } - if !member { - return invalid("kept job is not a member of this workstream") - } - return nil -} - -func sourceDerivedTitle(title string) string { - title = strings.TrimSpace(strings.Map(func(r rune) rune { - if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' { - return ' ' - } - return r - }, title)) - if title == "" { - title = "Source" - } - if len(title) <= SourceTitleMax { - return title - } - end := SourceTitleMax - for end > 0 && !utf8.RuneStart(title[end]) { - end-- - } - return strings.TrimSpace(title[:end]) -} diff --git a/internal/workstream/store.go b/internal/workstream/store.go deleted file mode 100644 index ab9f0104fe..0000000000 --- a/internal/workstream/store.go +++ /dev/null @@ -1,565 +0,0 @@ -package workstream - -import ( - "context" - "crypto/rand" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math" - "strconv" - "strings" - "time" - "unicode" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -type Options struct { - Now func() time.Time - NewID func() (string, error) -} -type Store struct { - workspace *workspace.Store - now func() time.Time - newID func() (string, error) -} - -func New(w *workspace.Store, opts Options) *Store { - if opts.Now == nil { - opts.Now = time.Now - } - if opts.NewID == nil { - opts.NewID = func() (string, error) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return "", err - } - return hex.EncodeToString(b[:]), nil - } - } - return &Store{workspace: w, now: opts.Now, newID: opts.NewID} -} - -func instant(ms int64) time.Time { return time.UnixMilli(ms).UTC() } -func optionalInstant(ms sql.NullInt64) time.Time { - if !ms.Valid { - return time.Time{} - } - return instant(ms.Int64) -} -func nullString(s string) any { - if s == "" { - return nil - } - return s -} -func nullInt(n int) any { - if n == 0 { - return nil - } - return n -} -func invalid(message string) error { return fmt.Errorf("%w: %s", ErrInvalid, message) } -func textWithin(s string, limit int) bool { - return len(s) <= limit && utf8.ValidString(s) && - strings.IndexFunc(s, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\t' }) < 0 -} -func lineWithin(s string, limit int) bool { - return len(s) <= limit && utf8.ValidString(s) && strings.IndexFunc(s, unicode.IsControl) < 0 -} -func actor(by string) (string, error) { - if by == "" { - by = "you" - } - if by != "you" && by != "agent" { - return "", invalid("actor must be you or agent") - } - return by, nil -} -func validTitle(title string) error { - if strings.TrimSpace(title) == "" || !lineWithin(title, 200) { - return invalid("title must be 1–200 bytes") - } - return nil -} -func sourceJSON(source *Source) (any, error) { - if source == nil { - return nil, nil - } - if source.ID == "" || !textWithin(source.ID, 4096) || !lineWithin(source.Label, 200) { - return nil, invalid("source needs an id and a label of at most 200 bytes") - } - if source.Kind != "message" && source.ChatID != "" || source.Kind != "factory_job" && source.Factory != "" { - return nil, invalid("source chat_id is only for message and factory is only for factory_job") - } - if source.ChatID != "" { - if err := (workspace.Ref{Kind: workspace.ConversationKind, ID: source.ChatID}).Validate(); err != nil { - return nil, err - } - } - switch source.Kind { - case "chat", "page", "url": - case "message": - if source.ChatID == "" { - return nil, invalid("message source needs chat_id") - } - case "factory_job": - if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: source.ID, Factory: source.Factory}).Validate(); err != nil { - return nil, err - } - default: - return nil, invalid("unknown source kind") - } - b, err := json.Marshal(source) - return string(b), err -} -func readSource(raw sql.NullString) (*Source, error) { - if !raw.Valid { - return nil, nil - } - var s Source - err := json.Unmarshal([]byte(raw.String), &s) - return &s, err -} -func found(err error) error { - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - return err -} -func touch(ctx context.Context, tx *sql.Tx, id string, at int64) error { - _, err := tx.ExecContext(ctx, "UPDATE workstreams SET updated_at=max(updated_at+1,?) WHERE id=?", at, id) - return err -} -func changed(id string, part ChangedPart) Change { - c := Change{ID: id, Parts: []ChangedPart{PartWorkstream}} - if part != PartWorkstream { - c.Parts = append(c.Parts, part) - } - return c -} - -const workstreamSelect = `SELECT w.id,c.name,w.why,w.origin_chat_id,w.agent_id,w.autonomy,w.wake_morning,w.wake_related_issues, - w.budget_usd,w.created_at,w.updated_at,w.started_at,w.done_at,w.how_it_went,w.client_id, - (SELECT count(*) FROM workstream_records r WHERE r.workstream_id=w.id AND r.state='proposed') - FROM workstreams w JOIN collections c ON c.id=w.id WHERE w.id=?` - -func getIn(ctx context.Context, tx *sql.Tx, id string) (Workstream, error) { - var w Workstream - var created, updated int64 - var start, done sql.NullInt64 - var budget sql.NullFloat64 - err := tx.QueryRowContext(ctx, workstreamSelect, id).Scan(&w.ID, &w.Name, &w.Why, &w.OriginChatID, &w.AgentID, &w.Autonomy, &w.Wake.Morning, &w.Wake.RelatedIssues, &budget, &created, &updated, &start, &done, &w.HowItWent, &w.ClientID, &w.Proposed) - if err != nil { - return w, found(err) - } - w.CreatedAt, w.UpdatedAt, w.StartedAt, w.DoneAt = instant(created), instant(updated), optionalInstant(start), optionalInstant(done) - if budget.Valid { - w.BudgetUSD = &budget.Float64 - } - w.Stage = Stage(w.DoneAt, w.StartedAt, nil) - rows, err := tx.QueryContext(ctx, "SELECT kind,ref_id,session_id,factory FROM memberships WHERE collection_id=? ORDER BY seq", id) - if err != nil { - return w, err - } - defer rows.Close() - w.Members = make([]workspace.Ref, 0) - for rows.Next() { - var ref workspace.Ref - if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID, &ref.Factory); err != nil { - return w, err - } - w.Members = append(w.Members, ref) - if ref.Kind == workspace.FactoryJobKind { - w.Total++ - } - } - return w, rows.Err() -} - -func (s *Store) Get(ctx context.Context, id string) (Workstream, error) { - var out Workstream - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - var err error - out, err = getIn(ctx, tx, id) - return err - }) - return out, err -} - -// ByClientID lets an adapter recognize a retry before creating its agent. -// Create checks it again inside the writer transaction to settle races. -func (s *Store) ByClientID(ctx context.Context, clientID string) (Workstream, error) { - var out Workstream - if clientID == "" { - return out, ErrNotFound - } - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - var id string - if err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", clientID).Scan(&id); err != nil { - return found(err) - } - var err error - out, err = getIn(ctx, tx, id) - return err - }) - return out, err -} - -func (s *Store) List(ctx context.Context) ([]Workstream, error) { - out := make([]Workstream, 0) - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return nil - } - rows, err := tx.QueryContext(ctx, "SELECT id FROM workstreams ORDER BY updated_at DESC,id") - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err - } - for _, id := range ids { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - out = append(out, w) - } - return nil - }) - return out, err -} - -func (s *Store) Create(ctx context.Context, p Create) (Workstream, Change, error) { - p.Name = strings.TrimSpace(p.Name) - if err := workspace.ValidateName(p.Name); err != nil { - return Workstream{}, Change{}, err - } - if !textWithin(p.Why, 4000) || !lineWithin(p.ClientID, 128) || len(p.Refs) > 200 { - return Workstream{}, Change{}, invalid("create exceeds workstream bounds") - } - // A retry does not insert sources, so their paths need no revalidation. - if p.ClientID != "" { - w, err := s.ByClientID(ctx, p.ClientID) - if err == nil { - return w, Change{}, nil - } - if !errors.Is(err, ErrNotFound) { - return Workstream{}, Change{}, err - } - } - sources, err := prepareCreateSources(p.Sources) - if err != nil { - return Workstream{}, Change{}, err - } - refs := append([]workspace.Ref(nil), p.Refs...) - refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.AgentID}) - if p.OriginChatID != "" { - refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.OriginChatID}) - } - additions := make([]workspace.Addition, 0, len(refs)) - for _, ref := range refs { - if err := ref.Validate(); err != nil { - return Workstream{}, Change{}, err - } - additions = append(additions, workspace.Addition{Ref: ref}) - } - var out Workstream - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if p.ClientID != "" { - var id string - err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", p.ClientID).Scan(&id) - if err == nil { - out, err = getIn(ctx, tx, id) - return err - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - } - id, err := s.newID() - if err != nil { - return err - } - at := s.now().UnixMilli() - if err := workspace.CreateIn(ctx, tx, id, p.Name, at); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstreams(id,why,origin_chat_id,agent_id,created_at,updated_at,client_id) VALUES (?,?,?,?,?,?,?)`, id, p.Why, p.OriginChatID, p.AgentID, at, at, p.ClientID); err != nil { - return err - } - if _, _, err := workspace.AddRefsIn(ctx, tx, id, additions, at); err != nil { - return err - } - pageID, err := s.newID() - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc,overview) VALUES (?,?,'Overview','you',0,1,?,'you',?,1)`, pageID, id, at, emptyDoc); err != nil { - return err - } - if err := saveVersion(ctx, tx, pageID); err != nil { - return err - } - for _, source := range sources { - if _, _, _, err := s.addSourceIn(ctx, tx, id, source, false, ""); err != nil { - return err - } - } - out, err = getIn(ctx, tx, id) - c = Change{ID: id, Parts: []ChangedPart{PartWorkstream, PartPages, PartJobs}} - if len(sources) > 0 { - c.Parts = append(c.Parts, PartSources) - } - return err - }) - if err != nil { - return Workstream{}, Change{}, err - } - return out, c, nil -} - -func (s *Store) Update(ctx context.Context, id string, p Patch) (Workstream, Change, error) { - if p.Name != nil { - name := strings.TrimSpace(*p.Name) - p.Name = &name - if err := workspace.ValidateName(name); err != nil { - return Workstream{}, Change{}, err - } - } - if p.Why != nil && !textWithin(*p.Why, 4000) { - return Workstream{}, Change{}, invalid("why exceeds 4000 bytes") - } - if p.Autonomy != nil && *p.Autonomy != "propose" && *p.Autonomy != "small" && *p.Autonomy != "off" { - return Workstream{}, Change{}, invalid("unknown autonomy") - } - if p.BudgetUSD != nil && (math.IsNaN(*p.BudgetUSD) || math.IsInf(*p.BudgetUSD, 0) || *p.BudgetUSD < 0) { - return Workstream{}, Change{}, invalid("budget must be finite and non-negative") - } - var out Workstream - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - before := w - if p.Name != nil { - w.Name = *p.Name - } - if p.Why != nil { - w.Why = *p.Why - } - if p.Autonomy != nil { - w.Autonomy = *p.Autonomy - } - if p.Wake != nil { - w.Wake = *p.Wake - } - if p.BudgetUSD != nil { - w.BudgetUSD = p.BudgetUSD - if *p.BudgetUSD == 0 { - w.BudgetUSD = nil - } - } - sameBudget := (w.BudgetUSD == nil && before.BudgetUSD == nil) || (w.BudgetUSD != nil && before.BudgetUSD != nil && *w.BudgetUSD == *before.BudgetUSD) - if w.Name == before.Name && w.Why == before.Why && w.Autonomy == before.Autonomy && w.Wake == before.Wake && sameBudget { - out = w - return nil - } - if w.Name != before.Name { - if err := workspace.RenameIn(ctx, tx, id, w.Name); err != nil { - return err - } - } - if _, err := tx.ExecContext(ctx, `UPDATE workstreams SET why=?,autonomy=?,wake_morning=?,wake_related_issues=?,budget_usd=?,updated_at=max(updated_at+1,?) WHERE id=?`, w.Why, w.Autonomy, w.Wake.Morning, w.Wake.RelatedIssues, w.BudgetUSD, s.now().UnixMilli(), id); err != nil { - return err - } - out, err = getIn(ctx, tx, id) - c = changed(id, PartWorkstream) - return err - }) - if err != nil { - return Workstream{}, Change{}, err - } - return out, c, nil -} - -// Stage follows the wire rule; dropped and unknown states do not start a workstream. -func Stage(done, started time.Time, states []string) string { - if !done.IsZero() { - return "done" - } - if !started.IsZero() { - return "building" - } - for _, state := range states { - switch state { - case "queued", "running", "needs_you", "done": - return "building" - } - } - return "shaping" -} - -// Jobs returns direct factory job members in collection order. -func (w Workstream) Jobs() []Job { - out := make([]Job, 0, w.Total) - for _, ref := range w.Members { - if ref.Kind == workspace.FactoryJobKind { - n, _ := strconv.Atoi(ref.ID) - out = append(out, Job{Factory: ref.Factory, Number: n}) - } - } - return out -} -func (s *Store) ObserveJobStates(ctx context.Context, id string, states map[Job]string) (Change, error) { - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if !w.StartedAt.IsZero() { - return nil - } - memberStates := make([]string, 0, w.Total) - for _, job := range w.Jobs() { - memberStates = append(memberStates, states[job]) - } - if Stage(time.Time{}, time.Time{}, memberStates) != "building" { - return nil - } - at := s.now().UnixMilli() - _, err = tx.ExecContext(ctx, "UPDATE workstreams SET started_at=?,updated_at=max(updated_at+1,?) WHERE id=?", at, at, id) - if err == nil { - c = changed(id, PartWorkstream) - } - return err - }) - if err != nil { - return Change{}, err - } - return c, nil -} -func (s *Store) MarkDone(ctx context.Context, id, how string) (Workstream, Change, error) { - if !textWithin(how, 4000) { - return Workstream{}, Change{}, invalid("how_it_went exceeds 4000 bytes") - } - return s.setDone(ctx, id, how, true) -} -func (s *Store) Reopen(ctx context.Context, id string) (Workstream, Change, error) { - return s.setDone(ctx, id, "", false) -} -func (s *Store) setDone(ctx context.Context, id, how string, done bool) (Workstream, Change, error) { - var out Workstream - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if done == !w.DoneAt.IsZero() { - out = w - return nil - } - at := s.now().UnixMilli() - if done { - if _, err := tx.ExecContext(ctx, "UPDATE workstreams SET done_at=?,how_it_went=?,updated_at=max(updated_at+1,?) WHERE id=?", at, how, at, id); err != nil { - return err - } - result, err := tx.ExecContext(ctx, `UPDATE workstream_goaheads SET state='ended', - end_reason=CASE WHEN ends_at<=? THEN 'expired' ELSE 'workstream_done' END, - ended_at=CASE WHEN ends_at<=? THEN ends_at ELSE ? END - WHERE workstream_id=? AND state='active'`, at, at, at, id) - if err != nil { - return err - } - c = changed(id, PartWorkstream) - n, err := result.RowsAffected() - if err != nil { - return err - } - if n > 0 { - c.Parts = append(c.Parts, PartGoAheads) - } - } else { - if _, err := tx.ExecContext(ctx, "UPDATE workstreams SET done_at=NULL,started_at=coalesce(started_at,?),updated_at=max(updated_at+1,?) WHERE id=?", at, at, id); err != nil { - return err - } - c = changed(id, PartWorkstream) - } - out, err = getIn(ctx, tx, id) - return err - }) - if err != nil { - return Workstream{}, Change{}, err - } - return out, c, nil -} - -// CountNumbers retains measured zeroes and withholds any total needing unknown -// facts. Job state must be known even for spend, as required by the contract. -func CountNumbers(jobs []Job, facts map[Job]JobFacts, agentSpend *float64, proposed int) Numbers { - out := Numbers{Total: len(jobs)} - merged, needs, spend := 0, proposed, 0.0 - mergedKnown, statesKnown, costKnown := true, true, true - for _, job := range jobs { - f, ok := facts[job] - known := ok && (f.State == "backlog" || f.State == "queued" || f.State == "running" || f.State == "needs_you" || f.State == "done" || f.State == "dropped") - if !known { - statesKnown = false - } - if !known || f.Merged == nil { - mergedKnown = false - } else if *f.Merged { - merged++ - } - if f.State == "needs_you" { - needs++ - } - if !known || f.CostUSD == nil || math.IsNaN(*f.CostUSD) || math.IsInf(*f.CostUSD, 0) || *f.CostUSD < 0 { - costKnown = false - } else { - spend += *f.CostUSD - } - } - if mergedKnown { - out.Merged = &merged - } - if statesKnown { - out.NeedsYou = &needs - } - if costKnown && agentSpend != nil && !math.IsNaN(*agentSpend) && !math.IsInf(*agentSpend, 0) && *agentSpend >= 0 { - spend += *agentSpend - if !math.IsInf(spend, 0) { - out.SpendUSD = &spend - } - } - return out -} diff --git a/internal/workstream/store_test.go b/internal/workstream/store_test.go deleted file mode 100644 index 3995666199..0000000000 --- a/internal/workstream/store_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package workstream - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func testStore(t *testing.T) (*Store, *workspace.Store, *time.Time) { - t.Helper() - w, err := workspace.Open(filepath.Join(t.TempDir(), "collections.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := w.Close(); err != nil { - t.Error(err) - } - }) - now := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC) - n := 0 - s := New(w, Options{Now: func() time.Time { return now }, NewID: func() (string, error) { n++; return fmt.Sprintf("id-%d", n), nil }}) - return s, w, &now -} - -func createStream(t *testing.T, s *Store, name string, refs ...workspace.Ref) Workstream { - t.Helper() - row, _, err := s.Create(context.Background(), Create{Name: name, AgentID: "agent-" + name, Refs: refs}) - if err != nil { - t.Fatal(err) - } - return row -} - -// Part A create: storage creates exactly one collection, row and Overview; -// client retries and refused job ownership leave no partial creation. -func TestPartACreateIsAtomicAndIdempotent(t *testing.T) { - s, w, _ := testStore(t) - ctx := context.Background() - args := Create{Name: "Billing", AgentID: "agent", OriginChatID: "origin", ClientID: "retry", Refs: []workspace.Ref{{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}}} - first, c, err := s.Create(ctx, args) - if err != nil || len(c.Parts) == 0 { - t.Fatalf("create: %+v %v", c, err) - } - indexed, err := s.ByClientID(ctx, "retry") - if err != nil || indexed.ID != first.ID { - t.Fatalf("client lookup: %+v %v", indexed, err) - } - if _, err := s.ByClientID(ctx, ""); !errors.Is(err, ErrNotFound) { - t.Fatalf("empty client id: %v", err) - } - args.Name = "Other" - args.AgentID = "other" - again, c, err := s.Create(ctx, args) - if err != nil || again.ID != first.ID || len(c.Parts) != 0 { - t.Fatalf("retry: %+v %+v %v", again, c, err) - } - all, err := s.List(ctx) - if err != nil || len(all) != 1 { - t.Fatalf("rows: %v %v", all, err) - } - collections, err := w.Collections(ctx) - if err != nil || len(collections) != 1 || !collections[0].Workstream { - t.Fatalf("collections: %v %v", collections, err) - } - pages, err := s.Pages(ctx, first.ID) - if err != nil || len(pages) != 1 || pages[0].Title != "Overview" || pages[0].Owner != "you" || pages[0].Version != 1 || pages[0].Position != 0 { - t.Fatalf("pages: %v %v", pages, err) - } - p, err := s.Page(ctx, pages[0].ID, true) - if err != nil || string(p.Doc) != `{"type":"doc","content":[]}` || p.Markdown == nil || *p.Markdown != "" { - t.Fatalf("overview: %+v %v", p, err) - } - members, err := w.Members(ctx, first.ID) - if err != nil || len(members) != 3 { - t.Fatalf("members: %v %v", members, err) - } - _, _, err = s.Create(ctx, Create{Name: "Conflict", AgentID: "third", Refs: args.Refs}) - var taken *workspace.WorkstreamTaken - if !errors.As(err, &taken) || taken.WorkstreamID != first.ID || taken.WorkstreamName != "Billing" { - t.Fatalf("taken: %v", err) - } - all, _ = s.List(ctx) - collections, _ = w.Collections(ctx) - if len(all) != 1 || len(collections) != 1 { - t.Fatal("failed create left state") - } -} - -// Part A (b): a batch add rolls back every ref and both workstream timestamps. -func TestPartAJobOwnershipRefusalAndPlainDuplicate(t *testing.T) { - s, w, now := testStore(t) - ctx := context.Background() - job := workspace.Ref{Kind: workspace.FactoryJobKind, ID: "17", Factory: "factory"} - a := createStream(t, s, "First", job) - b := createStream(t, s, "Second") - before, _ := w.Members(ctx, b.ID) - *now = now.Add(time.Hour) - _, _, err := w.AddRefs(ctx, b.ID, []workspace.Addition{{Ref: workspace.Ref{Kind: workspace.ConversationKind, ID: "extra"}}, {Ref: job}}) - var taken *workspace.WorkstreamTaken - if !errors.As(err, &taken) || taken.Factory != "factory" || taken.Job != 17 || taken.WorkstreamID != a.ID || taken.WorkstreamName != "First" { - t.Fatalf("taken: %+v %v", taken, err) - } - after, _ := w.Members(ctx, b.ID) - a2, _ := s.Get(ctx, a.ID) - b2, _ := s.Get(ctx, b.ID) - if !reflect.DeepEqual(before, after) || !a.UpdatedAt.Equal(a2.UpdatedAt) || !b.UpdatedAt.Equal(b2.UpdatedAt) { - t.Fatal("refused add changed state") - } - plain, err := w.Create(ctx, "Plain") - if err != nil { - t.Fatal(err) - } - if err := w.Add(ctx, plain.ID, job); err != nil { - t.Fatal(err) - } - duplicate, err := w.Duplicate(ctx, a.ID, "Copy") - if err != nil || duplicate.Workstream { - t.Fatalf("duplicate: %+v %v", duplicate, err) - } - if _, err := s.Get(ctx, duplicate.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("duplicate became workstream: %v", err) - } - if _, err := w.Delete(ctx, a.ID); !errors.Is(err, workspace.ErrWorkstreamDelete) { - t.Fatalf("delete: %v", err) - } - if err := w.Remove(ctx, a.ID, job); err != nil { - t.Fatal(err) - } - if err := w.Add(ctx, b.ID, job); err != nil { - t.Fatal(err) - } - b2, _ = s.Get(ctx, b.ID) - if !b2.UpdatedAt.After(b.UpdatedAt) { - t.Fatal("membership did not update timestamp") - } -} - -// Part A (d): stage persists the first departure, all merged is not Done, -// and reopening an unstarted workstream gives Building even with Backlog. -func TestPartAStageAndObservation(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - job := workspace.Ref{Kind: workspace.FactoryJobKind, ID: "1", Factory: "f"} - row := createStream(t, s, "Stage", job) - for _, state := range []string{"", "backlog", "dropped", "unknown"} { - if got := Stage(time.Time{}, time.Time{}, []string{state}); got != "shaping" { - t.Fatalf("%s: %s", state, got) - } - } - for _, state := range []string{"queued", "running", "needs_you", "done"} { - if got := Stage(time.Time{}, time.Time{}, []string{state}); got != "building" { - t.Fatalf("%s: %s", state, got) - } - } - c, err := s.ObserveJobStates(ctx, row.ID, map[Job]string{{Factory: "f", Number: 1}: "queued"}) - if err != nil || len(c.Parts) == 0 { - t.Fatalf("observe: %+v %v", c, err) - } - got, _ := s.Get(ctx, row.ID) - if got.StartedAt.IsZero() || got.Stage != "building" { - t.Fatalf("started: %+v", got) - } - c, err = s.ObserveJobStates(ctx, row.ID, map[Job]string{{Factory: "f", Number: 1}: "backlog"}) - if err != nil || len(c.Parts) != 0 { - t.Fatal("observation reset start") - } - done, _, err := s.MarkDone(ctx, row.ID, "") - if err != nil || done.Stage != "done" { - t.Fatalf("done: %+v %v", done, err) - } - open, _, err := s.Reopen(ctx, row.ID) - if err != nil || open.Stage != "building" { - t.Fatalf("reopen: %+v %v", open, err) - } - fresh := createStream(t, s, "Unstarted") - _, _, err = s.MarkDone(ctx, fresh.ID, "") - if err != nil { - t.Fatal(err) - } - open, _, err = s.Reopen(ctx, fresh.ID) - if err != nil || open.StartedAt.IsZero() || open.Stage != "building" { - t.Fatalf("unstarted reopen: %+v %v", open, err) - } -} - -func TestPartAUpdateClearsSettingsAndKeepsNameRules(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - row := createStream(t, s, "Settings") - why := "Because" - budget := 20.0 - small := "small" - row, _, err := s.Update(ctx, row.ID, Patch{Why: &why, BudgetUSD: &budget, Autonomy: &small, Wake: &Wake{Morning: true}}) - if err != nil || row.BudgetUSD == nil || *row.BudgetUSD != 20 || !row.Wake.Morning { - t.Fatalf("update: %+v %v", row, err) - } - why = "" - budget = 0 - row, _, err = s.Update(ctx, row.ID, Patch{Why: &why, BudgetUSD: &budget}) - if err != nil || row.Why != "" || row.BudgetUSD != nil { - t.Fatalf("clear: %+v %v", row, err) - } - createStream(t, s, "Taken") - name := "taken" - if _, _, err := s.Update(ctx, row.ID, Patch{Name: &name}); !errors.Is(err, workspace.ErrNameTaken) { - t.Fatalf("name: %v", err) - } -} diff --git a/internal/workstream/suggestions.go b/internal/workstream/suggestions.go deleted file mode 100644 index 2652c8548a..0000000000 --- a/internal/workstream/suggestions.go +++ /dev/null @@ -1,532 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strings" - "time" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -const ( - SuggestionsMax = 5_000 - SuggestionEditsMax = 50 - SuggestionTextMax = 4_000 - SuggestionWhyMax = 200 -) - -var ErrNotPersonPage = errors.New("That page is yours; change it with write_agent_page. Suggest edits only on the person's pages.") - -type Suggestion struct { - ID, WorkstreamID, PageID string - BaseVersion int - Why, By string - Source *Source - CreatedAt time.Time - Edits []SuggestionEdit -} - -type SuggestionEdit struct { - ID string - Block []int - Offset int - Delete, Insert, BlockText, Why, State string - Applies *bool - DecidedAt time.Time -} - -type SuggestionFind struct { - Find string `json:"find"` - Replace string `json:"replace"` - Why string `json:"why"` -} - -type SuggestionCreate struct { - WorkstreamID, PageID string - BaseVersion int - Why string - Edits []SuggestionFind - Attribution -} - -// ValidateSuggestion checks byte bounds independently of any provider schema. -func ValidateSuggestion(pageID string, base int, why string, edits []SuggestionFind) error { - if strings.TrimSpace(pageID) == "" || base < 1 { - return invalid("page_id and a positive base_version are required") - } - if len(why) < 1 || !lineWithin(why, SuggestionWhyMax) { - return invalid("why must be one line of 1–200 bytes") - } - if len(edits) < 1 || len(edits) > SuggestionEditsMax { - return invalid("edits must contain 1–50 edits") - } - for _, edit := range edits { - if len(edit.Find) < 1 || len(edit.Find) > SuggestionTextMax || !utf8.ValidString(edit.Find) || len(edit.Replace) > SuggestionTextMax || !utf8.ValidString(edit.Replace) { - return invalid("find must be 1–4000 bytes and replace 0–4000 bytes") - } - if len(edit.Why) < 1 || !lineWithin(edit.Why, SuggestionWhyMax) { - return invalid("edit why must be one line of 1–200 bytes") - } - } - return nil -} - -// SuggestionTextError is a normal tool refusal, with the caller's edit numbers. -type SuggestionTextError struct{ Message string } - -func (e *SuggestionTextError) Error() string { return e.Message } - -func suggestionTextError(n int, message string) error { - return &SuggestionTextError{Message: fmt.Sprintf("Edit %d: %s", n, message)} -} - -func resolveSuggestionEdit(blocks []pagedoc.TextBlock, ask SuggestionFind, n int) (SuggestionEdit, error) { - if ask.Find == ask.Replace { - return SuggestionEdit{}, suggestionTextError(n, "replace is the same as find.") - } - var match pagedoc.TextBlock - at, count := 0, 0 - for _, block := range blocks { - for from := 0; from <= len(block.Text)-len(ask.Find); { - index := strings.Index(block.Text[from:], ask.Find) - if index < 0 { - break - } - at, match = from+index, block - count++ - // Count overlapping occurrences too; an ambiguous quote is never guessed. - _, width := utf8.DecodeRuneInString(block.Text[at:]) - from = at + width - } - } - if count == 0 { - return SuggestionEdit{}, suggestionTextError(n, "that text is not on the page. Quote plain text from one paragraph, heading, list item or code block exactly as it reads, without markdown marks.") - } - if count != 1 { - return SuggestionEdit{}, suggestionTextError(n, "that text appears more than once. Quote more of it so it is unique.") - } - if match.Kind != "codeBlock" && strings.ContainsAny(ask.Replace, "\r\n") { - return SuggestionEdit{}, suggestionTextError(n, "only a code block's text may contain a line break.") - } - offset, del, ins := pagedoc.TrimEdit(ask.Find, ask.Replace) - return SuggestionEdit{Block: match.Path, Offset: utf8.RuneCountInString(match.Text[:at]) + offset, Delete: del, Insert: ins, BlockText: match.Text, Why: ask.Why, State: "proposed"}, nil -} - -func resolveSuggestion(doc json.RawMessage, asks []SuggestionFind) ([]SuggestionEdit, error) { - root, err := pagedoc.Decode(doc) - if err != nil { - return nil, err - } - blocks := pagedoc.TextBlocks(root) - edits := make([]SuggestionEdit, 0, len(asks)) - for i, ask := range asks { - edit, err := resolveSuggestionEdit(blocks, ask, i+1) - if err != nil { - return nil, err - } - for j, other := range edits { - if slices.Equal(edit.Block, other.Block) && edit.Offset <= other.Offset+utf8.RuneCountInString(other.Delete) && other.Offset <= edit.Offset+utf8.RuneCountInString(edit.Delete) { - return nil, &SuggestionTextError{Message: fmt.Sprintf("Edits %d and %d touch the same text. Merge them into one edit.", j+1, i+1)} - } - } - edits = append(edits, edit) - } - sort.SliceStable(edits, func(i, j int) bool { return suggestionEditLess(edits[i], edits[j]) }) - for i := range edits { - edits[i].ID = fmt.Sprintf("e%d", i+1) - } - return edits, nil -} - -func suggestionEditLess(a, b SuggestionEdit) bool { - if order := slices.Compare(a.Block, b.Block); order != 0 { - return order < 0 - } - return a.Offset < b.Offset -} - -func (s *Store) CreateSuggestion(ctx context.Context, p SuggestionCreate) (Suggestion, Change, error) { - var out Suggestion - if p.By != "agent" { - return out, Change{}, invalid("only the agent suggests edits") - } - if err := ValidateSuggestion(p.PageID, p.BaseVersion, p.Why, p.Edits); err != nil { - return out, Change{}, err - } - source, err := sourceJSON(p.From) - if err != nil { - return out, Change{}, err - } - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - page, err := suggestionCreatePage(ctx, tx, p) - if err != nil { - return err - } - edits, err := resolveSuggestion(page.Doc, p.Edits) - if err != nil { - return err - } - id, err := s.newID() - if err != nil { - return err - } - out = Suggestion{ID: "sg_" + id, WorkstreamID: page.WorkstreamID, PageID: page.ID, BaseVersion: p.BaseVersion, Why: p.Why, By: "agent", Source: p.From, CreatedAt: instant(s.now().UnixMilli()), Edits: edits} - if err := insertSuggestion(ctx, tx, out, source); err != nil { - return err - } - if err := touch(ctx, tx, out.WorkstreamID, out.CreatedAt.UnixMilli()); err != nil { - return err - } - return locateSuggestion(&out, page.Doc) - }) - if err != nil { - return Suggestion{}, Change{}, err - } - return out, suggestionChanged(out.WorkstreamID), nil -} - -func suggestionCreatePage(ctx context.Context, tx *sql.Tx, p SuggestionCreate) (Page, error) { - page, _, err := livePageIn(ctx, tx, p.PageID) - if err != nil { - return page, err - } - if page.WorkstreamID != p.WorkstreamID { - return page, ErrNotFound - } - if err := workstreamWriteAllowed(ctx, tx, page.WorkstreamID, "agent"); err != nil { - return page, err - } - if page.Owner != "you" { - return page, ErrNotPersonPage - } - if page.Version != p.BaseVersion { - return page, suggestionVersionConflict(page) - } - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_suggestions WHERE workstream_id=?", page.WorkstreamID).Scan(&count); err != nil { - return page, err - } - if count >= SuggestionsMax { - return page, ErrTooLarge - } - return page, nil -} - -func insertSuggestion(ctx context.Context, tx *sql.Tx, suggestion Suggestion, source any) error { - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,source,created_at) VALUES (?,?,?,?,?,?,?,?)`, suggestion.ID, suggestion.WorkstreamID, suggestion.PageID, suggestion.BaseVersion, suggestion.Why, suggestion.By, source, suggestion.CreatedAt.UnixMilli()); err != nil { - return err - } - for i, edit := range suggestion.Edits { - block, err := json.Marshal(edit.Block) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) VALUES (?,?,?,?,?,?,?,?,?,'proposed')`, suggestion.ID, edit.ID, i, string(block), edit.Offset, edit.Delete, edit.Insert, edit.BlockText, edit.Why); err != nil { - return err - } - } - return nil -} - -func suggestionChanged(id string) Change { return Change{ID: id, Parts: []ChangedPart{PartPages}} } -func suggestionVersionConflict(p Page) error { - return &VersionConflict{ID: p.ID, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} -} - -func suggestionIn(ctx context.Context, tx *sql.Tx, id string) (Suggestion, error) { - var out Suggestion - var source sql.NullString - var at int64 - err := tx.QueryRowContext(ctx, `SELECT id,workstream_id,page_id,base_version,why,actor,source,created_at FROM workstream_suggestions WHERE id=?`, id).Scan(&out.ID, &out.WorkstreamID, &out.PageID, &out.BaseVersion, &out.Why, &out.By, &source, &at) - if err != nil { - return out, found(err) - } - out.CreatedAt = instant(at) - out.Source, err = readSource(source) - if err != nil { - return out, err - } - rows, err := tx.QueryContext(ctx, `SELECT id,block,at_offset,del,ins,block_text,why,state,decided_at FROM workstream_suggestion_edits WHERE suggestion_id=? ORDER BY position`, id) - if err != nil { - return out, err - } - defer rows.Close() - out.Edits = []SuggestionEdit{} - for rows.Next() { - var edit SuggestionEdit - var block string - var decided sql.NullInt64 - if err := rows.Scan(&edit.ID, &block, &edit.Offset, &edit.Delete, &edit.Insert, &edit.BlockText, &edit.Why, &edit.State, &decided); err != nil { - return out, err - } - if err := json.Unmarshal([]byte(block), &edit.Block); err != nil { - return out, err - } - edit.DecidedAt = optionalInstant(decided) - out.Edits = append(out.Edits, edit) - } - return out, rows.Err() -} - -func locateSuggestion(suggestion *Suggestion, doc json.RawMessage) error { - root, err := pagedoc.Decode(doc) - if err != nil { - return err - } - for i := range suggestion.Edits { - edit := &suggestion.Edits[i] - edit.Applies = nil - if edit.State != "proposed" { - continue - } - block, ok := pagedoc.Locate(root, edit.Block, edit.BlockText) - edit.Applies = &ok - if ok { - edit.Block = block.Path - } - } - return nil -} - -func (s *Store) Suggestions(ctx context.Context, pageID string) ([]Suggestion, error) { - out := []Suggestion{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - page, _, err := livePageIn(ctx, tx, pageID) - if err != nil || version < workspace.WorkstreamSuggestionSchemaVersion { - return err - } - rows, err := tx.QueryContext(ctx, `SELECT s.id FROM workstream_suggestions s WHERE s.page_id=? AND EXISTS(SELECT 1 FROM workstream_suggestion_edits e WHERE e.suggestion_id=s.id AND e.state='proposed') ORDER BY s.created_at,s.rowid`, pageID) - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - err = rows.Err() - if closeErr := rows.Close(); err == nil { - err = closeErr - } - if err != nil { - return err - } - for _, id := range ids { - suggestion, err := suggestionIn(ctx, tx, id) - if err != nil { - return err - } - if err := locateSuggestion(&suggestion, page.Doc); err != nil { - return err - } - out = append(out, suggestion) - } - return nil - }) - return out, err -} - -func suggestionTargets(suggestion Suggestion, editID string) ([]int, error) { - targets := []int{} - found := editID == "" - for i, edit := range suggestion.Edits { - if editID != "" && edit.ID != editID { - continue - } - found = true - if edit.State == "proposed" { - targets = append(targets, i) - } else if editID != "" { - return nil, ErrConflict - } - } - if !found { - return nil, ErrNotFound - } - if len(targets) == 0 { - return nil, ErrConflict - } - return targets, nil -} - -func suggestionDecisionIn(ctx context.Context, tx *sql.Tx, id string) (Suggestion, Page, error) { - suggestion, err := suggestionIn(ctx, tx, id) - if err != nil { - return suggestion, Page{}, err - } - page, _, err := livePageIn(ctx, tx, suggestion.PageID) - if err != nil { - return suggestion, page, err - } - w, err := getIn(ctx, tx, suggestion.WorkstreamID) - if err == nil && !w.DoneAt.IsZero() { - err = ErrConflict - } - return suggestion, page, err -} - -// keepSuggestionDoc checks every selected anchor before applying any edit. -func keepSuggestionDoc(suggestion *Suggestion, page Page, targets []int, at time.Time) (json.RawMessage, error) { - if err := locateSuggestion(suggestion, page.Doc); err != nil { - return nil, err - } - for _, i := range targets { - if !*suggestion.Edits[i].Applies { - return nil, suggestionVersionConflict(page) - } - } - sort.SliceStable(targets, func(i, j int) bool { - return suggestionEditLess(suggestion.Edits[targets[i]], suggestion.Edits[targets[j]]) - }) - root, err := pagedoc.Decode(page.Doc) - if err != nil { - return nil, err - } - for _, i := range targets { - edit := &suggestion.Edits[i] - block, ok := pagedoc.Locate(root, edit.Block, edit.BlockText) - if !ok { - return nil, suggestionVersionConflict(page) - } - if err := pagedoc.Apply(block, edit.Offset, edit.Delete, edit.Insert); err != nil { - return nil, suggestionVersionConflict(page) - } - edit.Block, edit.State, edit.DecidedAt, edit.Applies = block.Path, "kept", at, nil - rebaseSuggestion(suggestion, *edit, block.Path) - } - doc, err := json.Marshal(root) - if err != nil { - return nil, err - } - if err := checkDoc(doc); err != nil { - return nil, err - } - return doc, locateSuggestion(suggestion, doc) -} - -func rebaseSuggestion(suggestion *Suggestion, kept SuggestionEdit, path []int) { - text := []rune(kept.BlockText) - end := kept.Offset + utf8.RuneCountInString(kept.Delete) - newText := string(text[:kept.Offset]) + kept.Insert + string(text[end:]) - delta := utf8.RuneCountInString(kept.Insert) - utf8.RuneCountInString(kept.Delete) - for i := range suggestion.Edits { - edit := &suggestion.Edits[i] - if edit.State != "proposed" || !slices.Equal(edit.Block, path) || edit.BlockText != kept.BlockText { - continue - } - edit.BlockText = newText - if edit.Offset > kept.Offset { - edit.Offset += delta - } - } -} - -func saveSuggestionEdits(ctx context.Context, tx *sql.Tx, suggestion Suggestion) error { - for _, edit := range suggestion.Edits { - block, err := json.Marshal(edit.Block) - if err != nil { - return err - } - var decided any - if !edit.DecidedAt.IsZero() { - decided = edit.DecidedAt.UnixMilli() - } - if _, err := tx.ExecContext(ctx, `UPDATE workstream_suggestion_edits SET block=?,at_offset=?,block_text=?,state=?,decided_at=? WHERE suggestion_id=? AND id=?`, string(block), edit.Offset, edit.BlockText, edit.State, decided, suggestion.ID, edit.ID); err != nil { - return err - } - } - return nil -} - -func (s *Store) KeepSuggestion(ctx context.Context, id, editID string) (Page, Suggestion, Change, error) { - var page Page - var suggestion Suggestion - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - var err error - suggestion, page, err = suggestionDecisionIn(ctx, tx, id) - if err != nil { - return err - } - targets, err := suggestionTargets(suggestion, editID) - if err != nil { - return err - } - label := suggestion.Why - if editID != "" { - label = suggestion.Edits[targets[0]].Why - } - at := instant(s.now().UnixMilli()) - doc, err := keepSuggestionDoc(&suggestion, page, targets, at) - if err != nil { - return err - } - source, err := json.Marshal(Source{Kind: "suggestion", ID: id, Label: label}) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `UPDATE workstream_pages SET doc=?,version=version+1,updated_at=?,updated_by='you',updated_from=? WHERE id=?`, string(doc), at.UnixMilli(), string(source), page.ID); err != nil { - return err - } - if err := saveVersion(ctx, tx, page.ID); err != nil { - return err - } - if err := saveSuggestionEdits(ctx, tx, suggestion); err != nil { - return err - } - if err := touch(ctx, tx, suggestion.WorkstreamID, at.UnixMilli()); err != nil { - return err - } - page, _, err = livePageIn(ctx, tx, page.ID) - return err - }) - if err != nil { - return Page{}, Suggestion{}, Change{}, err - } - return page, suggestion, suggestionChanged(suggestion.WorkstreamID), nil -} - -func (s *Store) DropSuggestion(ctx context.Context, id, editID string) (Suggestion, Change, error) { - var suggestion Suggestion - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - var page Page - var err error - suggestion, page, err = suggestionDecisionIn(ctx, tx, id) - if err != nil { - return err - } - targets, err := suggestionTargets(suggestion, editID) - if err != nil { - return err - } - at := instant(s.now().UnixMilli()) - for _, i := range targets { - suggestion.Edits[i].State, suggestion.Edits[i].DecidedAt = "dropped", at - } - if err := locateSuggestion(&suggestion, page.Doc); err != nil { - return err - } - if err := saveSuggestionEdits(ctx, tx, suggestion); err != nil { - return err - } - return touch(ctx, tx, suggestion.WorkstreamID, at.UnixMilli()) - }) - if err != nil { - return Suggestion{}, Change{}, err - } - return suggestion, suggestionChanged(suggestion.WorkstreamID), nil -} diff --git a/internal/workstream/suggestions_test.go b/internal/workstream/suggestions_test.go deleted file mode 100644 index f5c5ad047f..0000000000 --- a/internal/workstream/suggestions_test.go +++ /dev/null @@ -1,554 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -type workstreamSuggestionFixture struct { - Before json.RawMessage `json:"doc_before"` - After json.RawMessage `json:"doc_after"` - OnlyE3 json.RawMessage `json:"doc_after_e3"` - Edits []SuggestionFind `json:"edits"` -} - -func workstreamSuggestionSeed(t *testing.T, s *Store) (Page, SuggestionCreate, workstreamSuggestionFixture) { - t.Helper() - raw, err := os.ReadFile("pagedoc/testdata/suggestion-keep.json") - if err != nil { - t.Fatal(err) - } - var fixture workstreamSuggestionFixture - if err := json.Unmarshal(raw, &fixture); err != nil { - t.Fatal(err) - } - w := createStream(t, s, "Suggestions") - page, _, err := s.CreatePage(context.Background(), PageCreate{WorkstreamID: w.ID, Title: "Signing", Doc: fixture.Before}) - if err != nil { - t.Fatal(err) - } - ask := SuggestionCreate{WorkstreamID: w.ID, PageID: page.ID, BaseVersion: page.Version, Why: "Explain signing and retries", Edits: fixture.Edits, Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: w.AgentID}}} - return page, ask, fixture -} - -func workstreamSuggestionDocEqual(t *testing.T, got, want json.RawMessage) { - t.Helper() - a, err := pagedoc.Decode(got) - if err != nil { - t.Fatal(err) - } - b, err := pagedoc.Decode(want) - if err != nil || !reflect.DeepEqual(a, b) { - t.Fatalf("document differs: %s; want %s; %v", got, want, err) - } -} - -func workstreamSuggestionCreate(t *testing.T, s *Store, ask SuggestionCreate) Suggestion { - t.Helper() - suggestion, change, err := s.CreateSuggestion(context.Background(), ask) - if err != nil || !reflect.DeepEqual(change, suggestionChanged(ask.WorkstreamID)) { - t.Fatalf("create: %+v %v", change, err) - } - return suggestion -} - -// WP-6d (b): a single keep makes exactly one attributed version; drop leaves -// every page field and history untouched and decides only its selected edit. -func TestWorkstreamSuggestionKeepOneAndDropOne(t *testing.T) { - s, _, _ := testStore(t) - page, ask, fixture := workstreamSuggestionSeed(t, s) - suggestion := workstreamSuggestionCreate(t, s, ask) - ctx := context.Background() - historyBefore, err := s.PageHistory(ctx, page.ID, 50) - if err != nil { - t.Fatal(err) - } - kept, after, change, err := s.KeepSuggestion(ctx, suggestion.ID, "e3") - if err != nil || kept.Version != page.Version+1 || kept.UpdatedBy != "you" || !reflect.DeepEqual(change, suggestionChanged(page.WorkstreamID)) { - t.Fatalf("keep: %+v %+v %v", kept, change, err) - } - workstreamSuggestionDocEqual(t, kept.Doc, fixture.OnlyE3) - history, err := s.PageHistory(ctx, page.ID, 50) - wantSource := &Source{Kind: "suggestion", ID: suggestion.ID, Label: ask.Edits[2].Why} - if err != nil || len(history) != len(historyBefore)+1 || !reflect.DeepEqual(history[1:], historyBefore) || !reflect.DeepEqual(history[0].From, wantSource) || !reflect.DeepEqual(kept.UpdatedFrom, wantSource) || history[0].SavedBy != "you" { - t.Fatalf("history: %+v %v", history, err) - } - for i, edit := range after.Edits { - if i == 2 { - if edit.State != "kept" || edit.DecidedAt.IsZero() || edit.Applies != nil { - t.Fatalf("kept edit: %+v", edit) - } - } else if edit.State != "proposed" || edit.Applies == nil || !*edit.Applies || !edit.DecidedAt.IsZero() { - t.Fatalf("proposed edit: %+v", edit) - } - } - dropped, change, err := s.DropSuggestion(ctx, suggestion.ID, "e1") - if err != nil || dropped.Edits[0].State != "dropped" || dropped.Edits[0].DecidedAt.IsZero() || dropped.Edits[0].Applies != nil || dropped.Edits[1].State != "proposed" || dropped.Edits[2].State != "kept" || !reflect.DeepEqual(change, suggestionChanged(page.WorkstreamID)) { - t.Fatalf("drop: %+v %v", dropped, err) - } - unchanged, err := s.Page(ctx, page.ID, false) - if err != nil || !reflect.DeepEqual(unchanged, kept) { - t.Fatal("drop changed page", err) - } - historyAfter, err := s.PageHistory(ctx, page.ID, 50) - if err != nil || !reflect.DeepEqual(historyAfter, history) { - t.Fatal("drop changed history", err) - } -} - -// WP-6d (c): keep all saves one version and closes the suggestion; drop all -// closes it without touching the page, history or updated_at. -func TestWorkstreamSuggestionKeepAllAndDropAll(t *testing.T) { - for _, keep := range []bool{true, false} { - t.Run(map[bool]string{true: "keep", false: "drop"}[keep], func(t *testing.T) { - s, _, _ := testStore(t) - page, ask, fixture := workstreamSuggestionSeed(t, s) - suggestion := workstreamSuggestionCreate(t, s, ask) - ctx := context.Background() - before, err := s.PageHistory(ctx, page.ID, 50) - if err != nil { - t.Fatal(err) - } - var decided Suggestion - if keep { - var saved Page - saved, decided, _, err = s.KeepSuggestion(ctx, suggestion.ID, "") - if err != nil { - t.Fatal(err) - } - workstreamSuggestionDocEqual(t, saved.Doc, fixture.After) - want := &Source{Kind: "suggestion", ID: suggestion.ID, Label: ask.Why} - if saved.Version != page.Version+1 || saved.UpdatedBy != "you" || !reflect.DeepEqual(saved.UpdatedFrom, want) { - t.Fatalf("keep all: %+v", saved) - } - } else { - decided, _, err = s.DropSuggestion(ctx, suggestion.ID, "") - if err != nil { - t.Fatal(err) - } - saved, err := s.Page(ctx, page.ID, false) - if err != nil || !reflect.DeepEqual(saved, page) { - t.Fatal("drop all changed page", err) - } - } - for _, edit := range decided.Edits { - want := "dropped" - if keep { - want = "kept" - } - if edit.State != want || edit.DecidedAt.IsZero() || edit.Applies != nil { - t.Fatalf("decision: %+v", edit) - } - } - after, err := s.PageHistory(ctx, page.ID, 50) - if err != nil { - t.Fatal(err) - } - if keep { - if len(after) != len(before)+1 || after[0].From.Label != ask.Why { - t.Fatalf("keep all history: %+v", after) - } - } else if !reflect.DeepEqual(before, after) { - t.Fatal("drop all changed history") - } - open, err := s.Suggestions(ctx, page.ID) - if err != nil || open == nil || len(open) != 0 { - t.Fatalf("closed still open: %+v %v", open, err) - } - for _, editID := range []string{"e1", ""} { - if _, _, c, err := s.KeepSuggestion(ctx, suggestion.ID, editID); !errors.Is(err, ErrConflict) || len(c.Parts) != 0 { - t.Fatalf("repeat keep: %v", err) - } - if _, c, err := s.DropSuggestion(ctx, suggestion.ID, editID); !errors.Is(err, ErrConflict) || len(c.Parts) != 0 { - t.Fatalf("repeat drop: %v", err) - } - } - }) - } -} - -// WP-6d (c): keeping either end of one block rebases the remaining anchor. -func TestWorkstreamSuggestionEditsMoveWithText(t *testing.T) { - for _, first := range []string{"e1", "e2", ""} { - t.Run("first="+first, func(t *testing.T) { - s, _, _ := testStore(t) - page, ask, _ := workstreamSuggestionSeed(t, s) - doc := pagedoc.FromMarkdown("Alpha old words and omega old words.") - page, _, err := s.SavePage(context.Background(), page.ID, page.Version, doc, Attribution{}) - if err != nil { - t.Fatal(err) - } - ask.BaseVersion = page.Version - ask.Edits = []SuggestionFind{{Find: "Alpha old", Replace: "Alpha several new", Why: "Earlier"}, {Find: "omega old", Replace: "omega fresh", Why: "Later"}} - suggestion := workstreamSuggestionCreate(t, s, ask) - saved, after, _, err := s.KeepSuggestion(context.Background(), suggestion.ID, first) - if err != nil { - t.Fatal(err) - } - if first != "" { - remaining := "e2" - if first == "e2" { - remaining = "e1" - } - for _, edit := range after.Edits { - if edit.State == "proposed" && (edit.Applies == nil || !*edit.Applies) { - t.Fatalf("lost anchor: %+v", edit) - } - } - saved, _, _, err = s.KeepSuggestion(context.Background(), suggestion.ID, remaining) - if err != nil { - t.Fatal(err) - } - } - workstreamSuggestionDocEqual(t, saved.Doc, pagedoc.FromMarkdown("Alpha several new words and omega fresh words.")) - }) - } -} - -// WP-6d (d): stale single/all keeps are atomic, carry page details, and expose -// applies false. Moving an intact paragraph reports and persists its new path. -func TestWorkstreamSuggestionStaleAndMovedBlocks(t *testing.T) { - for _, moved := range []bool{false, true} { - t.Run(map[bool]string{true: "moved", false: "stale"}[moved], func(t *testing.T) { - s, _, _ := testStore(t) - page, ask, fixture := workstreamSuggestionSeed(t, s) - suggestion := workstreamSuggestionCreate(t, s, ask) - root, err := pagedoc.Decode(fixture.Before) - if err != nil { - t.Fatal(err) - } - content := root["content"].([]any) - if moved { - root["content"] = append([]any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Inserted above"}}}}, content...) - } else { - content[1].(map[string]any)["content"] = []any{map[string]any{"type": "text", "text": "Person changed the paragraph"}} - } - doc, err := json.Marshal(root) - if err != nil { - t.Fatal(err) - } - page, _, err = s.SavePage(context.Background(), page.ID, page.Version, doc, Attribution{}) - if err != nil { - t.Fatal(err) - } - open, err := s.Suggestions(context.Background(), page.ID) - if err != nil || len(open) != 1 { - t.Fatal("list", err) - } - if moved { - if !*open[0].Edits[0].Applies || !reflect.DeepEqual(open[0].Edits[0].Block, []int{1}) { - t.Fatalf("path did not move: %+v", open) - } - _, after, _, err := s.KeepSuggestion(context.Background(), suggestion.ID, "e1") - if err != nil || !reflect.DeepEqual(after.Edits[0].Block, []int{1}) || !reflect.DeepEqual(after.Edits[1].Block, []int{2}) { - t.Fatalf("located paths not saved: %+v %v", after, err) - } - return - } - if !*open[0].Edits[0].Applies || *open[0].Edits[1].Applies { - t.Fatalf("applies: %+v", open) - } - history, err := s.PageHistory(context.Background(), page.ID, 50) - if err != nil { - t.Fatal(err) - } - for _, target := range []string{"e2", ""} { - _, _, change, err := s.KeepSuggestion(context.Background(), suggestion.ID, target) - var stale *VersionConflict - if !errors.As(err, &stale) || stale.ID != page.ID || stale.Version != page.Version || !stale.UpdatedAt.Equal(page.UpdatedAt) || stale.UpdatedBy != page.UpdatedBy || len(change.Parts) != 0 { - t.Fatalf("stale: %+v %v", stale, err) - } - after, _ := s.Page(context.Background(), page.ID, false) - afterHistory, _ := s.PageHistory(context.Background(), page.ID, 50) - afterOpen, _ := s.Suggestions(context.Background(), page.ID) - if !reflect.DeepEqual(after, page) || !reflect.DeepEqual(afterHistory, history) || !reflect.DeepEqual(afterOpen, open) { - t.Fatal("refused keep changed state") - } - } - }) - } -} - -// WP-6d (a), (h), Tool: ownership, actor, version, done, missing targets, -// deletion and the all-states suggestion cap refuse without a receipt. -func TestWorkstreamSuggestionRefusals(t *testing.T) { - s, workspace, _ := testStore(t) - page, ask, _ := workstreamSuggestionSeed(t, s) - ctx := context.Background() - owner := "agent" - if _, _, err := s.UpdatePage(ctx, page.ID, PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - if _, c, err := s.CreateSuggestion(ctx, ask); !errors.Is(err, ErrNotPersonPage) || len(c.Parts) != 0 { - t.Fatalf("own page: %v", err) - } - open, err := s.Suggestions(ctx, page.ID) - if err != nil || len(open) != 0 { - t.Fatal("ownership refusal stored suggestion", err) - } - owner = "you" - if _, _, err := s.UpdatePage(ctx, page.ID, PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - stale := ask - stale.BaseVersion++ - if _, c, err := s.CreateSuggestion(ctx, stale); err == nil || len(c.Parts) != 0 { - t.Fatal("accepted stale create") - } else { - var conflict *VersionConflict - if !errors.As(err, &conflict) || conflict.ID != page.ID || conflict.Version != page.Version { - t.Fatalf("create conflict: %v", err) - } - } - person := ask - person.By = "you" - if _, _, err := s.CreateSuggestion(ctx, person); !errors.Is(err, ErrInvalid) { - t.Fatal("person created suggestion", err) - } - suggestion := workstreamSuggestionCreate(t, s, ask) - for _, ids := range [][2]string{{"missing", "e1"}, {suggestion.ID, "missing"}} { - if _, _, _, err := s.KeepSuggestion(ctx, ids[0], ids[1]); !errors.Is(err, ErrNotFound) { - t.Fatalf("missing keep: %v", err) - } - if _, _, err := s.DropSuggestion(ctx, ids[0], ids[1]); !errors.Is(err, ErrNotFound) { - t.Fatalf("missing drop: %v", err) - } - } - if err := workspace.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.Exec(`WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x 0 { - return len(p.Waves) - 1 - } - for i, w := range p.Waves { - if w.N == n { - return i - } - } - return -1 -} -func workstreamWakeTarget(p Plan, item WakeItem) (int, int) { - for wi, w := range p.Waves { - for pos, v := range w.Items { - if item.Factory != "" && v.Kind == "job" && v.Factory == item.Factory && v.Job == item.Job { - return wi, pos - } - if item.DraftID != "" && v.Kind == "draft" && v.DraftID == item.DraftID { - return wi, pos - } - } - } - return -1, -1 -} -func (s *Store) workstreamWakeSmallIn(ctx context.Context, tx *sql.Tx, w Workstream, item WakeItem) (bool, error) { - switch item.Kind { - case "page": - return true, nil - case "draft", "move": - p, err := planIn(ctx, tx, w.ID) - if err != nil { - return false, err - } - target := workstreamWakeWave(p, item.Wave) - if item.Kind == "draft" { - budget := item.BudgetUSD - if budget == nil || *budget <= 0 || *budget >= 5 { - return false, nil - } - if len(p.Waves) == 0 { - return item.Wave == 0 || item.Wave == 1, nil - } - return target >= 0 && !p.Waves[target].AutoStart, nil - } - from, _ := workstreamWakeTarget(p, item) - if target < 0 || from < 0 || target == from || p.Waves[target].AutoStart { - return false, nil - } - var granted bool - err = tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_goaheads WHERE workstream_id=? AND scope='wave' AND wave=? AND state='active' AND ends_at>?)", w.ID, item.Wave, s.now().UnixMilli()).Scan(&granted) - return !granted, err - } - return false, nil -} - -func workstreamWakeSource(w Workstream, event WakeEvent) *Source { - for _, job := range w.Jobs() { - if event.Kind != "morning" && job.Factory == event.Factory && job.Number == event.Job { - return &Source{Kind: "factory_job", Factory: job.Factory, ID: strconv.Itoa(job.Number), Label: event.Label} - } - } - return &Source{Kind: "chat", ID: w.AgentID, Label: event.Label} -} - -func (s *Store) workstreamWakeApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) (ChangedPart, error) { - var part ChangedPart - var err error - switch item.Kind { - case "move", "draft": - part = PartPlan - err = s.workstreamWakePlanApplyIn(ctx, tx, w, event, item) - case "page": - part = PartPages - err = s.workstreamWakePageApplyIn(ctx, tx, w, event, item) - case "decision", "question": - part = PartRecords - err = s.workstreamWakeRecordApplyIn(ctx, tx, w, event, item) - default: - return "", ErrConflict - } - if err != nil { - return "", err - } - item.State = "applied" - item.Note = "" - item.AppliedAt = instant(s.now().UnixMilli()) - item.Text = workstreamWakeItemText(*item) - return part, nil -} - -func (s *Store) workstreamWakeSavePlanIn(ctx context.Context, tx *sql.Tx, p Plan) error { - if len(p.Waves) > PlanWavesMax { - return ErrLimit - } - count := 0 - for _, w := range p.Waves { - count += len(w.Items) - } - if count > PlanItemsMax { - return ErrLimit - } - milestones, err := json.Marshal(p.Milestones) - if err != nil { - return err - } - waves, err := json.Marshal(p.Waves) - if err != nil { - return err - } - if len(milestones)+len(waves) > PlanEncodedMax { - return ErrLimit - } - _, err = tx.ExecContext(ctx, `INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES (?,?,?,'agent',?,?) - ON CONFLICT(workstream_id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,updated_by='agent',milestones=excluded.milestones,waves=excluded.waves`, p.WorkstreamID, p.Version+1, s.now().UnixMilli(), string(milestones), string(waves)) - return err -} - -func (s *Store) workstreamWakePlanApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { - p, err := planIn(ctx, tx, w.ID) - if err != nil { - return err - } - target := workstreamWakeWave(p, item.Wave) - if item.Kind == "move" { - from, pos := workstreamWakeTarget(p, *item) - if target < 0 || from < 0 || from == target { - return ErrConflict - } - moved := p.Waves[from].Items[pos] - item.Undo = workstreamWakeUndo{Wave: p.Waves[from].N, Position: pos, PlanItem: moved} - p.Waves[from].Items = append(p.Waves[from].Items[:pos], p.Waves[from].Items[pos+1:]...) - p.Waves[target].Items = append(p.Waves[target].Items, moved) - } else { - if len(p.Waves) == 0 && (item.Wave == 0 || item.Wave == 1) { - p.Waves = append(p.Waves, Wave{N: 1, Items: []PlanItem{}}) - target = 0 - item.Undo.CreatedWave = true - wave := p.Waves[target] - item.Undo.CreatedWaveImage = &wave - } - if target < 0 { - return ErrConflict - } - id, err := s.newID() - if err != nil { - return err - } - item.DraftID = "draft_" + id - item.Wave = p.Waves[target].N - v := PlanItem{Kind: "draft", DraftID: item.DraftID, Title: item.Proposal.Title, Repo: item.Proposal.Repo, Text: item.Proposal.Text, Size: item.Proposal.Size, Source: workstreamWakeSource(w, event)} - if item.BudgetUSD != nil { - v.BudgetUSD = *item.BudgetUSD - } - item.Undo.PlanItem = v - p.Waves[target].Items = append(p.Waves[target].Items, v) - } - return s.workstreamWakeSavePlanIn(ctx, tx, p) -} - -func (s *Store) workstreamWakePageApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { - source := workstreamWakeSource(w, event) - if source.Label == "" { - source.Label = "Workstream wake" - } - who := Attribution{By: "agent", From: source} - doc := pagedoc.FromMarkdown(item.Proposal.Markdown) - if item.PageID == "" { - ask, raw, err := workstreamPreparePageCreate(PageCreate{WorkstreamID: w.ID, Title: item.Proposal.Title, Owner: "agent", ReadByJobs: ReadByJobsDefault(item.Proposal.Title), Doc: doc, Attribution: who}) - if err != nil { - return err - } - page, err := s.workstreamCreatePageIn(ctx, tx, ask, raw) - if err != nil { - return err - } - item.PageID = page.ID - item.Undo = workstreamWakeUndo{Created: true, Version: page.Version, Page: &page.PageNode} - return nil - } - page, _, err := livePageIn(ctx, tx, item.PageID) - if errors.Is(err, ErrNotFound) { - return ErrConflict - } - if err != nil { - return err - } - if page.WorkstreamID != w.ID || page.Owner != "agent" { - return ErrConflict - } - previousVersion := page.Version - raw, err := sourceJSON(source) - if err != nil { - return err - } - if err := workstreamSavePageIn(ctx, tx, page, page.Title, doc, "agent", raw, s.now().UnixMilli()); err != nil { - return err - } - item.Proposal.Title = page.Title - page, _, err = livePageIn(ctx, tx, page.ID) - if err != nil { - return err - } - item.Undo = workstreamWakeUndo{Version: page.Version, PreviousVersion: previousVersion, Page: &page.PageNode} - return nil -} - -func (s *Store) workstreamWakeRecordApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { - if err := workstreamRecordLimitIn(ctx, tx, w.ID); err != nil { - return err - } - source, err := sourceJSON(workstreamWakeSource(w, event)) - if err != nil { - return err - } - id, err := s.newID() - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, "INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at) VALUES (?,?,?,?,'kept','agent',?,?,?)", id, w.ID, item.Kind, item.Proposal.Text, source, s.now().UnixMilli(), s.now().UnixMilli()) - if err == nil { - item.RecordID = id - } - return err -} - -func (s *Store) workstreamWakeUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) (ChangedPart, error) { - var part ChangedPart - var err error - switch item.Kind { - case "move", "draft": - part = PartPlan - err = s.workstreamWakePlanUndoIn(ctx, tx, w, item) - case "page": - part = PartPages - err = s.workstreamWakePageUndoIn(ctx, tx, w, event, item) - case "decision", "question": - part = PartRecords - r, readErr := recordIn(ctx, tx, item.RecordID) - if errors.Is(readErr, ErrNotFound) { - return "", ErrConflict - } - if readErr != nil { - return "", readErr - } - if r.WorkstreamID != w.ID || r.State != "kept" || r.SupersededBy != "" { - return "", ErrConflict - } - _, err = tx.ExecContext(ctx, "UPDATE workstream_records SET state='dropped',decided_at=? WHERE id=?", s.now().UnixMilli(), r.ID) - default: - return "", ErrConflict - } - if err != nil { - return "", err - } - item.State = "undone" - item.Note = "" - item.UndoneAt = instant(s.now().UnixMilli()) - item.Text = workstreamWakeItemText(*item) - return part, nil -} -func (s *Store) workstreamWakePlanUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, item *WakeItem) error { - p, err := planIn(ctx, tx, w.ID) - if err != nil { - return err - } - from, pos := workstreamWakeTarget(p, *item) - if from < 0 { - return ErrConflict - } - if p.Waves[from].N != item.Wave { - return ErrConflict - } - if item.Kind == "draft" && !reflect.DeepEqual(p.Waves[from].Items[pos], item.Undo.PlanItem) { - return ErrConflict - } - target := -1 - if item.Kind == "move" { - target = workstreamWakeWave(p, item.Undo.Wave) - if target < 0 { - return ErrConflict - } - } - v := p.Waves[from].Items[pos] - p.Waves[from].Items = append(p.Waves[from].Items[:pos], p.Waves[from].Items[pos+1:]...) - if item.Kind == "move" { - items := p.Waves[target].Items - at := min(item.Undo.Position, len(items)) - items = append(items, PlanItem{}) - copy(items[at+1:], items[at:]) - items[at] = v - p.Waves[target].Items = items - } else if item.Undo.CreatedWave && item.Undo.CreatedWaveImage != nil && len(p.Waves[from].Items) == 0 && reflect.DeepEqual(p.Waves[from], *item.Undo.CreatedWaveImage) { - p.Waves = append(p.Waves[:from], p.Waves[from+1:]...) - } - return s.workstreamWakeSavePlanIn(ctx, tx, p) -} -func (s *Store) workstreamWakePageUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { - p, _, err := livePageIn(ctx, tx, item.PageID) - if errors.Is(err, ErrNotFound) { - return ErrConflict - } - if err != nil { - return err - } - if p.WorkstreamID != w.ID || p.Owner != "agent" || p.Version != item.Undo.Version || item.Undo.Page == nil { - return ErrConflict - } - // Sibling compaction or reordering alone must not block undo. - postImage := *item.Undo.Page - postImage.Position = p.Position - if !reflect.DeepEqual(p.PageNode, postImage) { - return ErrConflict - } - if item.Undo.Created { - // A newly attached child is a later change; never delete another person's page. - var children bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_pages WHERE parent_id=? AND deleted_at IS NULL)", p.ID).Scan(&children); err != nil { - return err - } - if children { - return ErrConflict - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET deleted_at=?,deleted_root=? WHERE id=?", s.now().UnixMilli(), p.ID, p.ID); err != nil { - return err - } - _, err = tx.ExecContext(ctx, "UPDATE workstream_pages SET position=position-1 WHERE workstream_id=? AND parent_id IS ? AND position>? AND deleted_at IS NULL", w.ID, nullString(p.ParentID), p.Position) - return err - } - // Wake updates never change titles, so undo restores only the document. - var doc string - if err := tx.QueryRowContext(ctx, "SELECT doc FROM workstream_page_versions WHERE page_id=? AND version=?", p.ID, item.Undo.PreviousVersion).Scan(&doc); err != nil { - return found(err) - } - source := workstreamWakeSource(w, event) - if source.Label == "" { - source.Label = "Workstream wake" - } - raw, err := sourceJSON(source) - if err != nil { - return err - } - return workstreamSavePageIn(ctx, tx, p, p.Title, json.RawMessage(doc), "agent", raw, s.now().UnixMilli()) -} - -// ActOnWake applies one item atomically or all eligible items best effort. -// Undo walks backwards so dependent changes on the same target unwind in order. -func (s *Store) ActOnWake(ctx context.Context, id, itemID, action string) (WakeRun, Change, error) { - var out WakeRun - var change Change - if action != "apply" && action != "undo" && action != "not_now" { - return out, change, invalid("unknown wake action") - } - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - run, err := workstreamWakeIn(ctx, tx, id) - if err != nil { - return err - } - w, err := getIn(ctx, tx, run.WorkstreamID) - if err != nil { - return err - } - if itemID != "" { - found := false - for _, item := range run.Items { - found = found || item.ID == itemID - } - if !found { - return ErrNotFound - } - } - if action != "not_now" && w.Stage == "done" { - return ErrConflict - } - parts := map[ChangedPart]bool{} - changed := false - for step := range run.Items { - pos := step - if action == "undo" { - pos = len(run.Items) - 1 - step - } - item := run.Items[pos] - if itemID != "" && item.ID != itemID { - continue - } - source := "proposed" - if action == "undo" { - source = "applied" - } - if item.State != source { - continue - } - before := item - if action == "not_now" { - item.State = "dismissed" - run.DismissedAt = instant(s.now().UnixMilli()) - } else { - var part ChangedPart - if action == "apply" { - part, err = s.workstreamWakeApplyIn(ctx, tx, w, run.Event, &item) - } else { - part, err = s.workstreamWakeUndoIn(ctx, tx, w, run.Event, &item) - } - if errors.Is(err, ErrConflict) && itemID == "" { - item = before - item.Note = "Its target is gone; not applied." - if action == "undo" { - item.Note = "Changed since; not undone." - } - err = nil - } else if err != nil { - return err - } else { - parts[part] = true - } - } - if item == before { - continue - } - if err := workstreamSaveWakeItemIn(ctx, tx, id, pos, item); err != nil { - return err - } - run.Items[pos] = item - changed = true - } - if changed { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_wakes SET dismissed_at=? WHERE id=?", workstreamWakeTime(run.DismissedAt), id); err != nil { - return err - } - if err := touch(ctx, tx, w.ID, s.now().UnixMilli()); err != nil { - return err - } - change = workstreamWakeChange(w.ID, parts) - } - out = run - return nil - }) - if err != nil { - return WakeRun{}, Change{}, err - } - return out, change, nil -} diff --git a/internal/workstream/wake_answer.go b/internal/workstream/wake_answer.go deleted file mode 100644 index 7b74bcdf46..0000000000 --- a/internal/workstream/wake_answer.go +++ /dev/null @@ -1,132 +0,0 @@ -package workstream - -import ( - "encoding/json" - "errors" - "math" - "strings" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -const WakeItemsMax = 10 - -// WakeProposal contains only model suggestions; result references are engine owned. -type WakeProposal struct { - Kind string `json:"kind"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - DraftID string `json:"draft_id,omitempty"` - ToWave int `json:"to_wave,omitempty"` - Wave int `json:"wave,omitempty"` - Title string `json:"title,omitempty"` - Repo string `json:"repo,omitempty"` - Text string `json:"text,omitempty"` - Size string `json:"size,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - PageID string `json:"page_id,omitempty"` - Markdown string `json:"markdown,omitempty"` - Why string `json:"why,omitempty"` -} - -type WakeAnswer struct { - Summary string - Items []WakeProposal -} - -// ParseWakeAnswer reads the first object, including an object inside a JSON fence. -// Invalid items are dropped independently so one bad suggestion cannot hide good ones. -func ParseWakeAnswer(text string) (WakeAnswer, error) { - start := strings.IndexByte(text, '{') - if start < 0 { - return WakeAnswer{}, errors.New("no answer object") - } - var raw struct { - Summary *string `json:"summary"` - Items []json.RawMessage `json:"items"` - } - if err := json.NewDecoder(strings.NewReader(text[start:])).Decode(&raw); err != nil { - return WakeAnswer{}, err - } - if raw.Summary == nil || raw.Items == nil { - return WakeAnswer{}, errors.New("answer needs summary and items") - } - out := WakeAnswer{Summary: workstreamWakeClip(*raw.Summary, 2000), Items: []WakeProposal{}} - for _, item := range raw.Items { - var p WakeProposal - if json.Unmarshal(item, &p) != nil { - continue - } - p = workstreamWakeProposal(p) - if !workstreamWakeValid(p) { - continue - } - p.Why = workstreamWakeClip(strings.Join(strings.Fields(p.Why), " "), 200) - out.Items = append(out.Items, p) - if len(out.Items) == WakeItemsMax { - break - } - } - return out, nil -} - -func workstreamWakeClip(text string, limit int) string { - if !utf8.ValidString(text) { - text = strings.ToValidUTF8(text, "") - } - if len(text) <= limit { - return text - } - for limit > 0 && !utf8.RuneStart(text[limit]) { - limit-- - } - return text[:limit] -} - -func workstreamWakeValid(p WakeProposal) bool { - switch p.Kind { - case "move": - job := p.Factory != "" && p.Job > 0 && p.DraftID == "" && lineWithin(p.Factory, 4096) - draft := p.DraftID != "" && p.Factory == "" && p.Job == 0 && lineWithin(p.DraftID, 4096) - return p.ToWave > 0 && (job || draft) - case "draft": - return workstreamWakeDraftValid(p) - case "page": - return (p.PageID != "" || validTitle(p.Title) == nil) && (p.Title == "" || validTitle(p.Title) == nil) && lineWithin(p.PageID, 4096) && textWithin(p.Markdown, DocMax) && len(pagedoc.FromMarkdown(p.Markdown)) <= DocMax - case "decision", "question": - return recordText(p.Kind, p.Text) == nil - } - return false -} - -// Fields belonging to another kind are ignored, never result identities or undo targets. -func workstreamWakeProposal(p WakeProposal) WakeProposal { - base := WakeProposal{Kind: p.Kind, Why: p.Why} - switch p.Kind { - case "move": - base.Factory, base.Job, base.DraftID, base.ToWave = p.Factory, p.Job, p.DraftID, p.ToWave - case "draft": - base.Title, base.Repo, base.Text, base.Size, base.BudgetUSD, base.Wave = p.Title, p.Repo, p.Text, p.Size, p.BudgetUSD, p.Wave - case "page": - base.PageID, base.Title, base.Markdown = p.PageID, p.Title, p.Markdown - case "decision", "question": - base.Text = p.Text - } - return base -} - -func workstreamWakeDraftValid(p WakeProposal) bool { - if validTitle(p.Title) != nil || !lineWithin(p.Repo, 200) || !textWithin(p.Text, PlanDraftTextMax) || p.Wave < 0 { - return false - } - if p.Size != "" && p.Size != "S" && p.Size != "M" && p.Size != "L" { - return false - } - if p.BudgetUSD == nil { - return true - } - b := *p.BudgetUSD - return !math.IsNaN(b) && !math.IsInf(b, 0) && b > 0 && b <= workspace.WorkstreamGoAheadCapMax -} diff --git a/internal/workstream/wake_answer_test.go b/internal/workstream/wake_answer_test.go deleted file mode 100644 index 77e0618e9b..0000000000 --- a/internal/workstream/wake_answer_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package workstream - -import ( - "strings" - "testing" -) - -func TestWakeC_ParseAnswer(t *testing.T) { - answer, err := ParseWakeAnswer("Words ```json\n" + `{"summary":"Next steps","ignored":true,"items":[{"kind":"move","factory":"f","job":1,"to_wave":3,"why":"one\nline"},{"kind":"draft","title":"Follow up","budget_usd":3},{"kind":"draft","title":"Invalid","budget_usd":0},{"kind":"page","page_id":"p","markdown":"Updated"},{"kind":"decision","text":"Keep this"},{"kind":"unknown"}]}` + "\n```") - if err != nil || len(answer.Items) != 4 || answer.Items[0].Why != "one line" { - t.Fatalf("answer=%+v err=%v", answer, err) - } - if _, err := ParseWakeAnswer("no answer"); err == nil { - t.Fatal("unreadable answer accepted") - } - if _, err := ParseWakeAnswer(`{"summary":"oops"`); err == nil { - t.Fatal("partial answer accepted") - } -} - -func TestWakeC_ParseBoundsAndOrder(t *testing.T) { - text := `{"summary":"` + strings.Repeat("é", 1100) + `","items":[` - for i := 0; i < 12; i++ { - if i > 0 { - text += "," - } - text += `{"kind":"question","text":"What next?","why":"` + strings.Repeat("é", 110) + `"}` - } - answer, err := ParseWakeAnswer(text + `],"unknown":123}`) - if err != nil || len(answer.Items) != 10 || len(answer.Summary) > 2000 || len(answer.Items[0].Why) > 200 { - t.Fatalf("bounds: %+v %v", answer, err) - } -} - -func TestWakeE_AnswerFieldsCannotChooseAnotherKindsUndoTarget(t *testing.T) { - answer, err := ParseWakeAnswer(`{"summary":"Next","items":[{"kind":"draft","title":"Follow up","factory":"f","job":7,"draft_id":"existing","page_id":"person","budget_usd":3},{"kind":"decision","text":"Review","page_id":"person"}]}`) - if err != nil || len(answer.Items) != 2 { - t.Fatal(answer, err) - } - p := answer.Items[0] - if p.Factory != "" || p.Job != 0 || p.DraftID != "" || p.PageID != "" || answer.Items[1].PageID != "" { - t.Fatal("foreign kind fields survived", answer) - } -} diff --git a/internal/workstream/wakes.go b/internal/workstream/wakes.go deleted file mode 100644 index 0f1fb6644f..0000000000 --- a/internal/workstream/wakes.go +++ /dev/null @@ -1,500 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "math" - "sort" - "strconv" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const ( - PartWakes ChangedPart = "wakes" - WakeWorkstreamDayMax = 24 - WakeHomeDayMax = 200 - WakeAbandonedAfter = 15 * time.Minute - WakeStoppedError = "Stopped before it finished." - WakeAnswerError = "The agent's answer could not be read." -) - -var ErrWakeCap = errors.New("workstream wake daily cap reached") - -type WakeEvent struct { - Kind string `json:"kind"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - Label string `json:"label,omitempty"` - Detail string `json:"detail,omitempty"` - At time.Time `json:"at"` -} - -type WakeItem struct { - ID string - Kind string - State string - Text string - Why string - Factory string - Job int - DraftID string - Wave int - PageID string - RecordID string - BudgetUSD *float64 - Note string - AppliedAt time.Time - UndoneAt time.Time - Proposal WakeProposal - Undo workstreamWakeUndo -} - -type WakeRun struct { - ID string - WorkstreamID string - Event WakeEvent - State string - StartedAt time.Time - SettledAt time.Time - CostUSD *float64 - Summary string - Error string - DismissedAt time.Time - Items []WakeItem -} - -type workstreamWakeUndo struct { - Wave int - Position int - Version int - PreviousVersion int - Created bool - CreatedWave bool - CreatedWaveImage *Wave - PlanItem PlanItem - Page *PageNode -} - -// BeginWake claims the event before any model call, under the stage and cost guards. -// A losing claimant returns false, including another process using the same home. -func (s *Store) BeginWake(ctx context.Context, id, key string, event WakeEvent) (WakeRun, bool, error) { - var out WakeRun - if key == "" || !lineWithin(event.Label, 200) || !textWithin(event.Detail, 4000) { - return out, false, invalid("invalid wake event") - } - switch event.Kind { - case "morning", "finished", "merged", "failed", "needs_you": - default: - return out, false, invalid("unknown wake kind") - } - woke := false - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - if event.Kind != "morning" { - if event.Factory == "" || event.Job <= 0 { - return invalid("job event needs factory and positive job") - } - var owner string - err := tx.QueryRowContext(ctx, "SELECT workstream_id FROM workstream_jobs WHERE factory=? AND job=?", event.Factory, strconv.Itoa(event.Job)).Scan(&owner) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return err - } - if id != "" && id != owner { - return nil - } - id = owner - } - w, err := getIn(ctx, tx, id) - if errors.Is(err, ErrNotFound) { - return nil - } - if err != nil { - return err - } - if w.Stage == "done" || w.Autonomy == "off" { - return nil - } - if event.Kind == "morning" && (!w.Wake.Morning || event.At.Before(w.CreatedAt)) { - return nil - } - var exists bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_wakes WHERE workstream_id=? AND event_key=?)", id, key).Scan(&exists); err != nil { - return err - } - if exists { - return nil - } - now := s.now() - local := now.In(time.Local) - day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local) - var home, stream int - if err := tx.QueryRowContext(ctx, "SELECT count(*),coalesce(sum(workstream_id=?),0) FROM workstream_wakes WHERE started_at>=? AND started_at= WakeHomeDayMax { - return fmt.Errorf("%w: at most %d per home per local day", ErrWakeCap, WakeHomeDayMax) - } - if stream >= WakeWorkstreamDayMax { - return fmt.Errorf("%w: at most %d per local day for %s", ErrWakeCap, WakeWorkstreamDayMax, id) - } - wakeID, err := s.newID() - if err != nil { - return err - } - raw, err := json.Marshal(event) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, "INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,started_at) VALUES (?,?,?,?,'running',?)", wakeID, id, key, string(raw), now.UnixMilli()); err != nil { - return err - } - out = WakeRun{ID: wakeID, WorkstreamID: id, Event: event, State: "running", StartedAt: instant(now.UnixMilli()), Items: []WakeItem{}} - woke = true - return nil - }) - return out, woke, err -} - -func workstreamWakeIn(ctx context.Context, tx *sql.Tx, id string) (WakeRun, error) { - out := WakeRun{Items: []WakeItem{}} - var raw string - var started int64 - var settled, dismissed sql.NullInt64 - var cost sql.NullFloat64 - err := tx.QueryRowContext(ctx, "SELECT id,workstream_id,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at FROM workstream_wakes WHERE id=?", id).Scan(&out.ID, &out.WorkstreamID, &raw, &out.State, &out.Summary, &out.Error, &cost, &started, &settled, &dismissed) - if err != nil { - return out, found(err) - } - if err := json.Unmarshal([]byte(raw), &out.Event); err != nil { - return out, err - } - out.StartedAt, out.SettledAt, out.DismissedAt = instant(started), optionalInstant(settled), optionalInstant(dismissed) - if cost.Valid { - out.CostUSD = &cost.Float64 - } - rows, err := tx.QueryContext(ctx, "SELECT item_json FROM workstream_wake_items WHERE wake_id=? ORDER BY position", id) - if err != nil { - return out, err - } - defer rows.Close() - for rows.Next() { - var raw string - if err := rows.Scan(&raw); err != nil { - return out, err - } - var item WakeItem - if err := json.Unmarshal([]byte(raw), &item); err != nil { - return out, err - } - out.Items = append(out.Items, item) - } - return out, rows.Err() -} - -func workstreamSaveWakeItemIn(ctx context.Context, tx *sql.Tx, wake string, pos int, item WakeItem) error { - raw, err := json.Marshal(item) - if err != nil { - return err - } - undo, err := json.Marshal(item.Undo) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(id) DO UPDATE SET position=excluded.position,state=excluded.state,item_json=excluded.item_json,undo_json=excluded.undo_json,note=excluded.note,applied_at=excluded.applied_at,undone_at=excluded.undone_at`, item.ID, wake, pos, item.Kind, item.State, item.Why, string(raw), string(undo), item.Note, workstreamWakeTime(item.AppliedAt), workstreamWakeTime(item.UndoneAt)) - return err -} -func workstreamWakeTime(at time.Time) any { - if at.IsZero() { - return nil - } - return at.UnixMilli() -} - -// WakeRun lets the worker recheck a durable claim after waiting in its queue. -func (s *Store) WakeRun(ctx context.Context, id string) (WakeRun, error) { - var out WakeRun - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < workspace.WorkstreamWakeSchemaVersion { - return ErrNotFound - } - var err error - out, err = workstreamWakeIn(ctx, tx, id) - return err - }) - return out, err -} - -func (s *Store) Wakes(ctx context.Context, id string, limit int) ([]WakeRun, error) { - if limit == 0 { - limit = 20 - } - if limit < 1 || limit > 100 { - return nil, invalid("wake limit must be 1–100") - } - out := []WakeRun{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - if v < workspace.WorkstreamWakeSchemaVersion { - return nil - } - rows, err := tx.QueryContext(ctx, "SELECT id FROM workstream_wakes WHERE workstream_id=? ORDER BY started_at DESC,rowid DESC LIMIT ?", id, limit) - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - err = rows.Err() - rows.Close() - if err != nil { - return err - } - for _, id := range ids { - w, err := workstreamWakeIn(ctx, tx, id) - if err != nil { - return err - } - out = append(out, w) - } - return nil - }) - return out, err -} - -// WakeSpendByAgent preserves unknown conversation spend at the adapter boundary. -func (s *Store) WakeSpendByAgent(ctx context.Context, agent string) (float64, error) { - var cost float64 - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < workspace.WorkstreamWakeSchemaVersion { - return nil - } - return tx.QueryRowContext(ctx, "SELECT coalesce(sum(r.cost_usd),0) FROM workstream_wakes r JOIN workstreams w ON w.id=r.workstream_id WHERE w.agent_id=?", agent).Scan(&cost) - }) - return cost, err -} - -// ExpireWakes does not take a writer or migrate a schema-3 file unless a stale row exists. -func (s *Store) ExpireWakes(ctx context.Context) ([]Change, error) { - ids := []string{} - cutoff := s.now().Add(-WakeAbandonedAfter).UnixMilli() - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < workspace.WorkstreamWakeSchemaVersion { - return nil - } - rows, err := tx.QueryContext(ctx, "SELECT DISTINCT workstream_id FROM workstream_wakes WHERE state='running' AND started_at 0 { - if err := touch(ctx, tx, id, s.now().UnixMilli()); err != nil { - return err - } - changes = append(changes, changed(id, PartWakes)) - } - } - return nil - }) - return changes, err -} - -func workstreamWakeChange(id string, parts map[ChangedPart]bool) Change { - c := Change{ID: id, Parts: []ChangedPart{PartWakes}} - for _, p := range []ChangedPart{PartWorkstream, PartPlan, PartPages, PartRecords} { - if parts[p] { - c.Parts = append(c.Parts, p) - } - } - return c -} - -func workstreamWakeCost(cost *float64) *float64 { - if cost == nil || math.IsNaN(*cost) || math.IsInf(*cost, 0) || *cost < 0 { - return nil - } - return cost -} - -func (s *Store) SettleWake(ctx context.Context, id string, answer WakeAnswer, cost *float64, failure string) (WakeRun, Change, error) { - var out WakeRun - var change Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - run, err := workstreamWakeIn(ctx, tx, id) - if err != nil { - return err - } - out = run - if run.State != "running" { - return nil - } - w, err := getIn(ctx, tx, run.WorkstreamID) - if err != nil { - return err - } - parts := map[ChangedPart]bool{PartWorkstream: true} - if failure == "" { - pageTargets := map[string]bool{} - for _, p := range answer.Items { - p = workstreamWakeProposal(p) - if len(run.Items) == WakeItemsMax { - break - } - if !workstreamWakeValid(p) { - continue - } - if p.Kind == "page" && p.PageID != "" { - page, _, err := livePageIn(ctx, tx, p.PageID) - if errors.Is(err, ErrNotFound) { - continue - } - if err != nil { - return err - } - if page.WorkstreamID != w.ID || page.Owner != "agent" { - continue - } - p.Title = page.Title - } - if p.Kind == "page" { - target := "title:" + p.Title - if p.PageID != "" { - target = "id:" + p.PageID - } - if pageTargets[target] { - continue - } - pageTargets[target] = true - } - itemID, err := s.newID() - if err != nil { - return err - } - item := WakeItem{ID: itemID, Kind: p.Kind, State: "proposed", Why: p.Why, Factory: p.Factory, Job: p.Job, DraftID: p.DraftID, Wave: p.Wave, PageID: p.PageID, BudgetUSD: p.BudgetUSD, Proposal: p} - if p.Kind == "move" { - item.Wave = p.ToWave - } - if w.Autonomy == "small" && w.Stage != "done" { - ok, err := s.workstreamWakeSmallIn(ctx, tx, w, item) - if err != nil { - return err - } - if ok { - part, err := s.workstreamWakeApplyIn(ctx, tx, w, run.Event, &item) - if err != nil { - return err - } - parts[part] = true - } - } - item.Text = workstreamWakeItemText(item) - run.Items = append(run.Items, item) - } - sort.SliceStable(run.Items, func(i, j int) bool { return run.Items[i].State == "applied" && run.Items[j].State != "applied" }) - for i, item := range run.Items { - if err := workstreamSaveWakeItemIn(ctx, tx, id, i, item); err != nil { - return err - } - } - } - run.State = "settled" - if failure != "" { - run.State = "failed" - } - run.Summary = workstreamWakeClip(answer.Summary, 2000) - run.Error = failure - run.CostUSD = workstreamWakeCost(cost) - run.SettledAt = instant(s.now().UnixMilli()) - if _, err := tx.ExecContext(ctx, "UPDATE workstream_wakes SET state=?,summary=?,error=?,cost_usd=?,settled_at=? WHERE id=?", run.State, run.Summary, run.Error, run.CostUSD, run.SettledAt.UnixMilli(), id); err != nil { - return err - } - if err := touch(ctx, tx, w.ID, s.now().UnixMilli()); err != nil { - return err - } - out = run - change = workstreamWakeChange(w.ID, parts) - return nil - }) - if err != nil { - return WakeRun{}, Change{}, err - } - return out, change, nil -} - -func workstreamWakeItemText(item WakeItem) string { - applied := item.State == "applied" - switch item.Kind { - case "move": - if !applied { - return "Move the plan item to the proposed wave." - } - target := item.DraftID - if item.Factory != "" { - target = fmt.Sprintf("%s#%d", item.Factory, item.Job) - } - verb := "Move" - if applied { - verb = "Moved" - } - return fmt.Sprintf("%s %s to wave %d.", verb, target, item.Wave) - case "draft": - verb := "Add draft: " - if applied { - verb = "Added draft: " - } - return verb + item.Proposal.Title + "." - case "page": - verb := "Update agent page: " - if applied { - verb = "Updated agent page: " - } - return verb + item.Proposal.Title + "." - default: - verb := "Keep " - if applied { - verb = "Kept " - } - return verb + item.Kind + ": " + item.Proposal.Text - } -} diff --git a/internal/workstream/wakes_test.go b/internal/workstream/wakes_test.go deleted file mode 100644 index 159ef773b1..0000000000 --- a/internal/workstream/wakes_test.go +++ /dev/null @@ -1,434 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func workstreamWakeForTest(t *testing.T, s *Store, w Workstream, key string, items ...WakeProposal) WakeRun { - t.Helper() - ctx := context.Background() - run, woke, err := s.BeginWake(ctx, w.ID, key, WakeEvent{Kind: "merged", Factory: "f", Job: 7, At: s.now(), Label: "api#7 merged"}) - if err != nil || !woke { - t.Fatalf("begin: %+v %v %v", run, woke, err) - } - run, c, err := s.SettleWake(ctx, run.ID, WakeAnswer{Summary: "Review next steps.", Items: items}, nil, "") - if err != nil || c.ID != w.ID { - t.Fatalf("settle: %+v %+v %v", run, c, err) - } - return run -} -func workstreamWakeMember() workspace.Ref { - return workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"} -} -func workstreamWakeBudget(n float64) *float64 { return &n } - -func TestWakeC_ProposeChangesNothing(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Proposals", workstreamWakeMember()) - before, _ := s.Plan(ctx, w.ID) - pages, _ := s.Pages(ctx, w.ID) - run := workstreamWakeForTest(t, s, w, "c", WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}, WakeProposal{Kind: "draft", Title: "Follow up", BudgetUSD: workstreamWakeBudget(3)}, WakeProposal{Kind: "page", Title: "Where it stands", Markdown: "Review"}, WakeProposal{Kind: "decision", Text: "Review first"}) - if len(run.Items) != 4 { - t.Fatal(run) - } - for _, i := range run.Items { - if i.State != "proposed" { - t.Fatal(i) - } - } - after, _ := s.Plan(ctx, w.ID) - afterPages, _ := s.Pages(ctx, w.ID) - records, _ := s.Records(ctx, w.ID, "", "") - if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(pages, afterPages) || len(records) != 0 { - t.Fatal("propose changed workstream content") - } -} - -func TestWakeD_SmallClassificationAndUndo(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Small", workstreamWakeMember()) - small := "small" - s.Update(ctx, w.ID, Patch{Autonomy: &small}) - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Where it stands", Owner: "agent"}) - if err != nil { - t.Fatal(err) - } - waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d_before", Title: "Before"}, {Kind: "job", Factory: "f", Job: 7}, {Kind: "draft", DraftID: "d_after", Title: "After"}}}, {N: 2, Items: []PlanItem{}}, {N: 3, AutoStart: true, Items: []PlanItem{}}, {N: 4, Items: []PlanItem{}}} - before, _, err := s.SetPlan(ctx, w.ID, 0, nil, waves, Attribution{}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: w.ID, Scope: "wave", Wave: 4, CapUSD: 20, EndsAt: s.now().Add(time.Hour)}); err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "d", - WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}, - WakeProposal{Kind: "draft", Title: "Small follow-up", BudgetUSD: workstreamWakeBudget(3), Wave: 2}, - WakeProposal{Kind: "page", PageID: page.ID, Markdown: "Updated"}, - WakeProposal{Kind: "page", Title: "Agent notes", Markdown: "New"}, - WakeProposal{Kind: "draft", Title: "Large follow-up", BudgetUSD: workstreamWakeBudget(7), Wave: 2}, - WakeProposal{Kind: "draft", Title: "Unknown budget", Wave: 2}, - WakeProposal{Kind: "move", DraftID: "d_before", ToWave: 3}, - WakeProposal{Kind: "move", DraftID: "d_after", ToWave: 4}, - WakeProposal{Kind: "decision", Text: "Keep the scope"}, - WakeProposal{Kind: "question", Text: "Ready?"}) - for i, item := range run.Items { - want := "proposed" - if i < 4 { - want = "applied" - } - if item.State != want { - t.Fatalf("%d: %+v", i, item) - } - } - after, _ := s.Plan(ctx, w.ID) - if after.Waves[1].Items[1].Kind != "draft" || after.Waves[1].Items[1].Source.Kind != "factory_job" { - t.Fatal(after) - } - undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID { - t.Fatalf("undo: %+v %v", c, err) - } - restored, _ := s.Plan(ctx, w.ID) - restoredPage, _ := s.Page(ctx, page.ID, false) - pages, _ := s.Pages(ctx, w.ID) - if !reflect.DeepEqual(restored.Waves, before.Waves) || string(restoredPage.Doc) != emptyDoc || restoredPage.Version != 3 || len(pages) != 2 { - t.Fatalf("restore: %+v %+v %+v", restored, restoredPage, pages) - } - for i := 0; i < 4; i++ { - if undone.Items[i].State != "undone" { - t.Fatal(undone.Items) - } - } - if _, c, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil || c.ID != "" { - t.Fatalf("repeat undo: %+v %v", c, err) - } -} - -func TestWakeD_LaterPageAndPlanChangesRefuseUndo(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Stale", workstreamWakeMember()) - small := "small" - s.Update(ctx, w.ID, Patch{Autonomy: &small}) - p, _, _ := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent"}) - s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 7}}}, {N: 2, Items: []PlanItem{}}, {N: 3, Items: []PlanItem{}}}, Attribution{}) - run := workstreamWakeForTest(t, s, w, "stale", WakeProposal{Kind: "page", PageID: p.ID, Markdown: "Wake"}, WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}) - s.SavePage(ctx, p.ID, 2, []byte(emptyDoc), Attribution{}) - plan, _ := s.Plan(ctx, w.ID) - plan.Waves[2].Items = plan.Waves[1].Items - plan.Waves[1].Items = []PlanItem{} - s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}) - for _, item := range run.Items { - if _, c, err := s.ActOnWake(ctx, run.ID, item.ID, "undo"); !errors.Is(err, ErrConflict) || c.ID != "" { - t.Fatalf("single stale: %+v %v", c, err) - } - } - noted, _, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil { - t.Fatal(err) - } - for _, item := range noted.Items { - if item.State != "applied" || item.Note != "Changed since; not undone." { - t.Fatal(item) - } - } - _, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != "" { - t.Fatalf("same notes wrote again: %+v %v", c, err) - } -} - -func TestWakeE_PersonAndDeletedPagesAreDropped(t *testing.T) { - for _, autonomy := range []string{"propose", "small"} { - t.Run(autonomy, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Private", workstreamWakeMember()) - s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}) - pages, _ := s.Pages(ctx, w.ID) - person, _ := s.Page(ctx, pages[0].ID, false) - deleted, _, _ := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Deleted", Owner: "agent"}) - s.DeletePage(ctx, deleted.ID) - run := workstreamWakeForTest(t, s, w, "e", WakeProposal{Kind: "page", PageID: person.ID, Markdown: "Changed"}, WakeProposal{Kind: "page", PageID: deleted.ID, Markdown: "Changed"}) - after, _ := s.Page(ctx, person.ID, false) - if len(run.Items) != 0 || !reflect.DeepEqual(person, after) { - t.Fatal("page boundary violated") - } - if _, c, err := s.ActOnWake(ctx, run.ID, "", "apply"); err != nil || c.ID != "" { - t.Fatalf("empty apply: %+v %v", c, err) - } - }) - } -} - -func TestWakeG_ActionsAreAtomicAndIdempotent(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Actions", workstreamWakeMember()) - run := workstreamWakeForTest(t, s, w, "g", WakeProposal{Kind: "decision", Text: "Keep it"}, WakeProposal{Kind: "question", Text: "Ready?"}, WakeProposal{Kind: "move", DraftID: "gone", ToWave: 9}) - applied, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply") - if err != nil || c.ID != w.ID || applied.Items[0].RecordID == "" { - t.Fatalf("apply: %+v %v", applied, err) - } - row, _ := s.Get(ctx, w.ID) - again, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply") - after, _ := s.Get(ctx, w.ID) - if err != nil || c.ID != "" || !reflect.DeepEqual(again, applied) || !row.UpdatedAt.Equal(after.UpdatedAt) { - t.Fatal("repeated apply changed state") - } - all, _, err := s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil || all.Items[1].State != "applied" || all.Items[2].Note != "Its target is gone; not applied." { - t.Fatalf("all: %+v %v", all, err) - } - dismissed, _, err := s.ActOnWake(ctx, run.ID, "", "not_now") - if err != nil || dismissed.DismissedAt.IsZero() || dismissed.Items[2].State != "dismissed" { - t.Fatal(dismissed, err) - } - if _, c, err := s.ActOnWake(ctx, run.ID, "", "not_now"); err != nil || c.ID != "" { - t.Fatal("repeat dismissal changed state") - } - if _, _, err := s.ActOnWake(ctx, "unknown", "", "apply"); !errors.Is(err, ErrNotFound) { - t.Fatal(err) - } - if _, _, err := s.ActOnWake(ctx, run.ID, "unknown", "apply"); !errors.Is(err, ErrNotFound) { - t.Fatal(err) - } - if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { - t.Fatal(err) - } - records, _ := s.Records(ctx, w.ID, "", "") - for _, r := range records { - if r.State != "dropped" { - t.Fatal(r) - } - } - s.MarkDone(ctx, w.ID, "") - for _, action := range []string{"apply", "undo"} { - if _, _, err := s.ActOnWake(ctx, run.ID, "", action); !errors.Is(err, ErrConflict) { - t.Fatal(err) - } - } -} - -func TestWakeB_DeduplicationCapsAndAbandonment(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Events", workstreamWakeMember()) - event := WakeEvent{Kind: "finished", Factory: "f", Job: 7, At: *now} - first, woke, err := s.BeginWake(ctx, "", "once", event) - if err != nil || !woke { - t.Fatal(err) - } - if _, woke, err := s.BeginWake(ctx, "", "once", event); err != nil || woke { - t.Fatal("duplicate woke", err) - } - for i := 1; i < WakeWorkstreamDayMax; i++ { - if _, woke, err := s.BeginWake(ctx, w.ID, fmt.Sprint(i), event); err != nil || !woke { - t.Fatal(i, err) - } - } - if _, woke, err := s.BeginWake(ctx, w.ID, "capped", event); !errors.Is(err, ErrWakeCap) || woke { - t.Fatal("cap not enforced", err) - } - *now = now.Add(WakeAbandonedAfter + time.Millisecond) - changes, err := s.ExpireWakes(ctx) - if err != nil || len(changes) != 1 { - t.Fatal(changes, err) - } - runs, _ := s.Wakes(ctx, w.ID, 100) - if len(runs) != WakeWorkstreamDayMax { - t.Fatal(len(runs)) - } - for _, r := range runs { - if r.State != "failed" || r.Error != WakeStoppedError || r.CostUSD != nil { - t.Fatal(r) - } - } - if run, c, err := s.SettleWake(ctx, first.ID, WakeAnswer{}, workstreamWakeBudget(.04), ""); err != nil || c.ID != "" || run.CostUSD != nil { - t.Fatal("late worker overwrote expired wake") - } -} - -func TestWakeI_SchemaThreeWakeListIsReadOnly(t *testing.T) { - path := filepath.Join(t.TempDir(), "three.db") - ws, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - s := New(ws, Options{}) - w := createStream(t, s, "Earlier") - if err := ws.Write(context.Background(), func(tx *sql.Tx) error { - _, err := tx.Exec("DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3") - return err - }); err != nil { - t.Fatal(err) - } - ws.Close() - before, _ := os.ReadFile(path) - ws, err = workspace.Open(path) - if err != nil { - t.Fatal(err) - } - s = New(ws, Options{}) - wakes, err := s.Wakes(context.Background(), w.ID, 20) - if err != nil || len(wakes) != 0 { - t.Fatal(wakes, err) - } - if changes, err := s.ExpireWakes(context.Background()); err != nil || len(changes) != 0 { - t.Fatal(changes, err) - } - ws.Close() - after, _ := os.ReadFile(path) - if string(before) != string(after) { - t.Fatal("wake read migrated schema 3") - } -} - -func TestWakeB_HomeCapAndLocalDayReset(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - event := WakeEvent{Kind: "morning", At: *now} - var last Workstream - for i := 0; i < 9; i++ { - last = createStream(t, s, fmt.Sprintf("Stream %d", i)) - s.Update(ctx, last.ID, Patch{Wake: &Wake{Morning: true}}) - n := WakeWorkstreamDayMax - if i == 8 { - n = 8 - } - for j := 0; j < n; j++ { - if _, woke, err := s.BeginWake(ctx, last.ID, fmt.Sprintf("%d/%d", i, j), event); err != nil || !woke { - t.Fatal(i, j, err) - } - } - } - if _, woke, err := s.BeginWake(ctx, last.ID, "home-capped", event); !errors.Is(err, ErrWakeCap) || woke { - t.Fatal("home cap ignored", err) - } - *now = now.In(time.Local).AddDate(0, 0, 1) - event.At = *now - if _, woke, err := s.BeginWake(ctx, last.ID, "next-day", event); err != nil || !woke { - t.Fatal("cap did not reset", err) - } -} - -func TestWakeB_DuplicateAcrossStoreHandles(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "claims.db") - a, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer a.Close() - first := New(a, Options{}) - w := createStream(t, first, "Shared", workstreamWakeMember()) - b, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer b.Close() - second := New(b, Options{}) - event := WakeEvent{Kind: "failed", Factory: "f", Job: 7, At: time.Now()} - if _, woke, err := first.BeginWake(ctx, w.ID, "same", event); err != nil || !woke { - t.Fatal(err) - } - if _, woke, err := second.BeginWake(ctx, w.ID, "same", event); err != nil || woke { - t.Fatal("second handle won", err) - } - runs, err := second.Wakes(ctx, w.ID, 20) - if err != nil || len(runs) != 1 { - t.Fatal(runs, err) - } -} - -func TestWakeD_ExplicitApplyKeepsRecordsAndAgentSources(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Explicit", workstreamWakeMember()) - run := workstreamWakeForTest(t, s, w, "explicit", WakeProposal{Kind: "draft", Title: "Costly follow-up", BudgetUSD: workstreamWakeBudget(7)}, WakeProposal{Kind: "page", Title: "Spec", Markdown: "Keep these words"}, WakeProposal{Kind: "decision", Text: "The rollout is ready"}) - off := "off" - s.Update(ctx, w.ID, Patch{Autonomy: &off}) - applied, _, err := s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil { - t.Fatal(err) - } - for _, item := range applied.Items { - if item.State != "applied" { - t.Fatal(item) - } - } - plan, _ := s.Plan(ctx, w.ID) - if len(plan.Waves) != 1 || plan.Waves[0].AutoStart || plan.Waves[0].Items[0].Source.Kind != "factory_job" { - t.Fatal(plan) - } - page, _ := s.Page(ctx, applied.Items[1].PageID, false) - if page.Owner != "agent" || page.UpdatedBy != "agent" || page.UpdatedFrom.Factory != "f" || !page.ReadByJobs { - t.Fatal(page) - } - r, err := s.Records(ctx, w.ID, "decision", "kept") - if err != nil || len(r) != 1 || r[0].By != "agent" || r[0].Source.Kind != "factory_job" { - t.Fatal(r, err) - } - if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { - t.Fatal(err) - } - restored, _ := s.Plan(ctx, w.ID) - if len(restored.Waves) != 0 { - t.Fatal("created wave remained", restored) - } - deleted, err := s.Page(ctx, page.ID, false) - if err != nil || deleted.DeletedAt.IsZero() { - t.Fatal("new page was not restorably deleted", deleted, err) - } -} - -func TestWakeG_NamedApplyConflictRollsBackAndAllKeepsNote(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Gone", workstreamWakeMember()) - run := workstreamWakeForTest(t, s, w, "gone", WakeProposal{Kind: "move", DraftID: "missing", ToWave: 7}) - if _, _, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply"); !errors.Is(err, ErrConflict) { - t.Fatal(err) - } - runs, _ := s.Wakes(ctx, w.ID, 20) - if !reflect.DeepEqual(runs[0], run) { - t.Fatal("conflict wrote") - } - noted, _, err := s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil || noted.Items[0].State != "proposed" || noted.Items[0].Note != "Its target is gone; not applied." { - t.Fatal(noted, err) - } -} - -func TestWakeE_DraftUndoCannotTargetAMemberJob(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Identities", workstreamWakeMember()) - if _, _, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}, Attribution{}); err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "identities", WakeProposal{Kind: "draft", Title: "Follow up", Factory: "f", Job: 7, DraftID: "untrusted", BudgetUSD: workstreamWakeBudget(3)}) - if _, _, err := s.ActOnWake(ctx, run.ID, "", "apply"); err != nil { - t.Fatal(err) - } - if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { - t.Fatal(err) - } - plan, _ := s.Plan(ctx, w.ID) - if len(plan.Waves[0].Items) != 1 || plan.Waves[0].Items[0].Kind != "job" || plan.Waves[0].Items[0].Job != 7 { - t.Fatal("undo touched member job", plan) - } -} diff --git a/internal/workstream/workstream.go b/internal/workstream/workstream.go deleted file mode 100644 index f9dc634ab8..0000000000 --- a/internal/workstream/workstream.go +++ /dev/null @@ -1,264 +0,0 @@ -// Package workstream owns workstream rules and durable state. Adapters supply -// job observations and recorded spend; execution owners stay outside this package. -package workstream - -import ( - "encoding/json" - "errors" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const ( - DocMax = 1 << 20 - PlanDraftTextMax = 80_000 - PlanMilestonesMax = 500 - PlanWavesMax = 500 - PlanItemsMax = 5_000 - PlanEncodedMax = 4 << 20 - PagesMax = 5_000 - RecordsMax = 5_000 - emptyDoc = `{"type":"doc","content":[]}` -) - -var ( - ErrInvalid = workspace.ErrInvalid - ErrNotFound = errors.New("workstream target not found") - ErrConflict = errors.New("workstream conflict") - ErrCycle = errors.New("page move would form a cycle") - ErrTooLarge = errors.New("encoded document exceeds 1 MiB") - ErrLimit = errors.New("workstream limit exceeded") -) - -type ChangedPart string - -const ( - PartWorkstream ChangedPart = "workstream" - PartPlan ChangedPart = "plan" - PartPages ChangedPart = "pages" - PartRecords ChangedPart = "records" - PartGoAheads ChangedPart = "goaheads" - PartJobs ChangedPart = "jobs" - PartSources ChangedPart = "sources" -) - -// Change is empty for an unchanged write. Adapters emit it only after success. -type Change struct { - ID string - Parts []ChangedPart -} - -type VersionConflict struct { - ID string - Version int - UpdatedAt time.Time - UpdatedBy string -} - -func (e *VersionConflict) Error() string { return "the version changed since it was read" } - -type Wake struct{ Morning, RelatedIssues bool } -type Workstream struct { - ID string - Name string - Why string - OriginChatID string - AgentID string - Autonomy string - HowItWent string - ClientID string - Wake Wake - BudgetUSD *float64 - CreatedAt time.Time - UpdatedAt time.Time - StartedAt time.Time - DoneAt time.Time - Stage string - Members []workspace.Ref - Proposed int - Total int -} -type Create struct { - Name string - Why string - OriginChatID string - AgentID string - ClientID string - Refs []workspace.Ref - Sources []SourceAdd -} -type Patch struct { - Name, Why, Autonomy *string - Wake *Wake - BudgetUSD *float64 -} - -type Job struct { - Factory string - Number int -} - -// JobFacts is absent from the facts map until a job was successfully read. -// Individual facts may remain unknown even when its state is known. -type JobFacts struct { - State string - Merged *bool - CostUSD *float64 -} -type Numbers struct { - Total int - Merged, NeedsYou *int - SpendUSD *float64 -} - -type Source struct { - Kind string `json:"kind"` - ID string `json:"id"` - ChatID string `json:"chat_id,omitempty"` - Factory string `json:"factory,omitempty"` - Label string `json:"label,omitempty"` -} - -// Attribution permits later agent tools to use the same domain operations. -// A zero attribution means the person; proposal authors default to agent. -type Attribution struct { - By string - From *Source -} - -type Milestone struct { - ID string `json:"id"` - Title string `json:"title"` -} -type PlanItem struct { - Kind string `json:"kind"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - DraftID string `json:"draft_id,omitempty"` - Title string `json:"title,omitempty"` - Repo string `json:"repo,omitempty"` - Text string `json:"text,omitempty"` - Size string `json:"size,omitempty"` - BudgetUSD float64 `json:"budget_usd,omitempty"` - Source *Source `json:"source,omitempty"` -} -type Wave struct { - N int `json:"n"` - Title string `json:"title,omitempty"` - MilestoneID string `json:"milestone_id,omitempty"` - AutoStart bool `json:"auto_start"` - Items []PlanItem `json:"items"` -} -type Plan struct { - WorkstreamID string - Version int - UpdatedAt time.Time - UpdatedBy string - Milestones []Milestone - Waves []Wave -} - -type PageNode struct { - ID string - WorkstreamID string - ParentID string - Title string - Owner string - ReadByJobs bool - Position, Version int - UpdatedAt time.Time - UpdatedBy string - UpdatedFrom *Source - DeletedAt time.Time -} -type Page struct { - PageNode - Doc json.RawMessage - Markdown *string -} -type PageVersion struct { - Version int - SavedAt time.Time - SavedBy string - From *Source - Title string - Bytes int -} -type PageCreate struct { - WorkstreamID string - ParentID string - AfterID string - Title string - Owner string - ReadByJobs bool - Doc json.RawMessage - Attribution -} -type PagePatch struct { - Title, Owner *string - ReadByJobs *bool - Attribution -} - -type Record struct { - ID string - WorkstreamID string - Kind string - Text string - State string - By string - Source *Source - CreatedAt, DecidedAt time.Time - Supersedes, SupersededBy string - GoAhead *GoAheadProposal - GoAheadID string -} -type RecordPropose struct { - WorkstreamID, Kind, Text string - Source *Source - Kept bool - By string - GoAhead *GoAheadProposal -} -type RecordReplacement struct { - Kind, Text string - Source *Source - By string -} - -type GoAhead struct { - ID string - WorkstreamID string - Scope string - Factory string - MilestoneID string - Job, Wave int - CapUSD float64 - EndsAt time.Time - State, EndReason string - GrantedAt, EndedAt time.Time -} -type GoAheadGrant struct { - WorkstreamID string - Scope string - Factory string - MilestoneID string - Job, Wave int - CapUSD float64 - EndsAt time.Time -} - -type GoAheadProposal 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"` - EndsAt time.Time `json:"ends_at"` -} - -func (p GoAheadProposal) grant(id string) GoAheadGrant { - return GoAheadGrant{WorkstreamID: id, Scope: p.Scope, Factory: p.Factory, Job: p.Job, Wave: p.Wave, MilestoneID: p.MilestoneID, CapUSD: p.CapUSD, EndsAt: p.EndsAt} -} diff --git a/internal/workstream/workstream_agent.go b/internal/workstream/workstream_agent.go deleted file mode 100644 index 395c02d8c0..0000000000 --- a/internal/workstream/workstream_agent.go +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index c7e3f44300..0000000000 --- a/internal/workstream/workstream_agent_done_test.go +++ /dev/null @@ -1,155 +0,0 @@ -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 deleted file mode 100644 index a657498f83..0000000000 --- a/internal/workstream/workstream_agent_test.go +++ /dev/null @@ -1,106 +0,0 @@ -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) - } -} diff --git a/internal/workstream/workstream_fields_v02_test.go b/internal/workstream/workstream_fields_v02_test.go deleted file mode 100644 index e4256d3a71..0000000000 --- a/internal/workstream/workstream_fields_v02_test.go +++ /dev/null @@ -1,398 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "math" - "reflect" - "slices" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -// WP-F2 (a): draft metadata survives storage, while old plan JSON stays optional. -func TestWorkstreamDraftFieldsRoundTripAndOldJSON(t *testing.T) { - s, db, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Drafts") - item := PlanItem{Kind: "draft", DraftID: "draft", Title: "Draft", Size: "M", BudgetUSD: 10000, Source: &Source{Kind: "message", ID: "message", ChatID: "chat", Label: "Scope"}} - waves := []Wave{{N: 1, Items: []PlanItem{item}}} - p, _, err := s.SetPlan(ctx, w.ID, 0, nil, waves, Attribution{}) - if err != nil || !reflect.DeepEqual(p.Waves, waves) { - t.Fatalf("set: %+v %v", p, err) - } - p, err = s.Plan(ctx, w.ID) - if err != nil || !reflect.DeepEqual(p.Waves, waves) { - t.Fatalf("get: %+v %v", p, err) - } - err = db.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `UPDATE workstream_plans SET waves=? WHERE workstream_id=?`, `[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"old","title":"Old"}]}]`, w.ID) - return err - }) - if err != nil { - t.Fatal(err) - } - p, err = s.Plan(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(p.Waves[0].Items[0]) - if err != nil || strings.Contains(string(raw), "size") || strings.Contains(string(raw), "budget_usd") || strings.Contains(string(raw), "source") { - t.Fatalf("old fields must stay absent: %s %v", raw, err) - } -} - -// WP-F2 (a): each invalid draft field refuses the entire plan without a write. -func TestWorkstreamDraftFieldRefusalsAreAtomic(t *testing.T) { - for name, item := range map[string]PlanItem{ - "job size": {Kind: "job", Factory: "f", Job: 7, Size: "S"}, - "job budget": {Kind: "job", Factory: "f", Job: 7, BudgetUSD: 1}, - "job source": {Kind: "job", Factory: "f", Job: 7, Source: &Source{Kind: "chat", ID: "chat"}}, - "size XL": {Kind: "draft", DraftID: "d", Title: "Draft", Size: "XL"}, - "negative": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: -1}, - "over cap": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: 10000.01}, - "NaN": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: math.NaN()}, - "infinite": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: math.Inf(1)}, - "message without chat": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "message", ID: "msg"}}, - "chat with factory": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: "chat", Factory: "f"}}, - "page with chat": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "page", ID: "p", ChatID: "chat"}}, - "bad source kind": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "other", ID: "p"}}, - "empty source id": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat"}}, - "long source id": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: strings.Repeat("x", 4097)}}, - "multiline label": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: "chat", Label: "a\nb"}}, - } { - t.Run(name, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Refusals", workspace.Ref{Kind: workspace.FactoryJobKind, Factory: "f", ID: "7"}) - before, _ := s.Plan(ctx, w.ID) - _, c, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{item}}}, Attribution{}) - if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("refusal: %+v %v", c, err) - } - after, err := s.Plan(ctx, w.ID) - row, rowErr := s.Get(ctx, w.ID) - if err != nil || rowErr != nil || !reflect.DeepEqual(before, after) || !row.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("refusal changed plan: %+v %v %v", after, err, rowErr) - } - }) - } -} - -func workstreamProposalFixture(t *testing.T, s *Store, now time.Time) (Workstream, GoAheadProposal) { - t.Helper() - w := createStream(t, s, "Proposal", workspace.Ref{Kind: workspace.FactoryJobKind, Factory: "f", ID: "7"}) - if _, _, err := s.SetPlan(context.Background(), w.ID, 0, []Milestone{{ID: "ship", Title: "Ship"}}, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { - t.Fatal(err) - } - return w, GoAheadProposal{Scope: "wave", Wave: 1, CapUSD: 12.5, EndsAt: now.Add(time.Hour)} -} - -// WP-F2 (d): keeping grants once, dropping revokes, re-keeping replaces the id. -func TestWorkstreamGoAheadRecordLifecycle(t *testing.T) { - for _, target := range []GoAheadProposal{{Scope: "job", Factory: "f", Job: 7}, {Scope: "wave", Wave: 1}, {Scope: "milestone", MilestoneID: "ship"}} { - t.Run(target.Scope, func(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - target.CapUSD, target.EndsAt = p.CapUSD, p.EndsAt - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &target}) - if err != nil || r.State != "proposed" || r.GoAheadID != "" || !reflect.DeepEqual(r.GoAhead, &target) { - t.Fatalf("propose: %+v %v", r, err) - } - if grants, err := s.GoAheads(ctx, w.ID, true); err != nil || len(grants) != 0 { - t.Fatalf("proposal granted: %+v %v", grants, err) - } - r, c, err := s.KeepRecord(ctx, r.ID) - if err != nil || r.State != "kept" || r.DecidedAt.IsZero() || !slices.Contains(c.Parts, PartRecords) || !slices.Contains(c.Parts, PartGoAheads) { - t.Fatalf("keep: %+v %+v %v", r, c, err) - } - grants, err := s.GoAheads(ctx, w.ID, false) - if err != nil || len(grants) != 1 { - t.Fatalf("grants: %+v %v", grants, err) - } - g := grants[0] - if g.ID != r.GoAheadID || g.Scope != target.Scope || g.Factory != target.Factory || g.Job != target.Job || g.Wave != target.Wave || g.MilestoneID != target.MilestoneID || g.CapUSD != target.CapUSD || !g.EndsAt.Equal(target.EndsAt) { - t.Fatalf("grant differs: %+v %+v", g, target) - } - again, c, err := s.KeepRecord(ctx, r.ID) - grants, listErr := s.GoAheads(ctx, w.ID, true) - if err != nil || listErr != nil || !reflect.DeepEqual(again, r) || len(c.Parts) != 0 || len(grants) != 1 { - t.Fatalf("repeat keep: %+v %+v %v %v", again, c, err, listErr) - } - dropped, c, err := s.DropRecord(ctx, r.ID) - grants, listErr = s.GoAheads(ctx, w.ID, true) - if err != nil || listErr != nil || dropped.State != "dropped" || dropped.GoAheadID != r.GoAheadID || len(grants) != 1 || grants[0].State != "ended" || grants[0].EndReason != "revoked" || !slices.Contains(c.Parts, PartGoAheads) { - t.Fatalf("drop: %+v %+v %+v %v %v", dropped, c, grants, err, listErr) - } - kept, _, err := s.KeepRecord(ctx, r.ID) - if err != nil || kept.GoAheadID == r.GoAheadID || kept.GoAheadID == "" { - t.Fatalf("re-keep: %+v %v", kept, err) - } - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - grants, err = s.GoAheads(ctx, w.ID, true) - if err != nil || len(grants) != 2 || grants[0].ID != kept.GoAheadID || grants[0].EndReason != "workstream_done" { - t.Fatalf("done: %+v %v", grants, err) - } - records, err := s.Records(ctx, w.ID, "goahead", "") - if err != nil || len(records) != 1 || !reflect.DeepEqual(records[0], kept) { - t.Fatalf("done changed kept record: %+v %v", records, err) - } - again, c, err = s.KeepRecord(ctx, r.ID) - if err != nil || len(c.Parts) != 0 || !reflect.DeepEqual(again, kept) { - t.Fatalf("already kept on done: %+v %+v %v", again, c, err) - } - }) - } -} - -// WP-F2 (d): keep checks current state in its transaction and rolls back refusal. -func TestWorkstreamGoAheadKeepRefusalsAreAtomic(t *testing.T) { - for _, reason := range []string{"done", "wave removed", "expired"} { - t.Run(reason, func(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if err != nil { - t.Fatal(err) - } - want := ErrInvalid - switch reason { - case "done": - _, _, err = s.MarkDone(ctx, w.ID, "") - want = ErrConflict - case "wave removed": - _, _, err = s.SetPlan(ctx, w.ID, 1, nil, nil, Attribution{}) - case "expired": - *now = p.EndsAt - } - if err != nil { - t.Fatal(err) - } - before, _ := s.Get(ctx, w.ID) - _, c, err := s.KeepRecord(ctx, r.ID) - if !errors.Is(err, want) || len(c.Parts) != 0 { - t.Fatalf("keep: %+v %v", c, err) - } - records, err := s.Records(ctx, w.ID, "goahead", "") - grants, grantErr := s.GoAheads(ctx, w.ID, true) - after, rowErr := s.Get(ctx, w.ID) - if err != nil || grantErr != nil || rowErr != nil || len(records) != 1 || !reflect.DeepEqual(records[0], r) || len(grants) != 0 || !after.UpdatedAt.Equal(before.UpdatedAt) { - t.Fatalf("refusal wrote: %+v %+v %v %v %v", records, grants, err, grantErr, rowErr) - } - }) - } -} - -// WP-F2 (d), (f): invalid proposals/supersedes leave records and grants unchanged. -func TestWorkstreamGoAheadProposeRefusalsAndListFilters(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - for _, kind := range []string{"decision", "question", "goahead"} { - ask := RecordPropose{WorkstreamID: w.ID, Kind: kind, Text: "Why"} - if kind == "goahead" { - ask.GoAhead = &p - } - if _, _, err := s.ProposeRecord(ctx, ask); err != nil { - t.Fatal(err) - } - } - for _, tc := range []struct { - kind string - include bool - count int - }{{"", false, 2}, {"", true, 3}, {"goahead", false, 1}, {"question", true, 1}} { - records, err := s.Records(ctx, w.ID, tc.kind, "", tc.include) - if err != nil || len(records) != tc.count { - t.Fatalf("filter %+v: %+v %v", tc, records, err) - } - } - before, _ := s.Records(ctx, w.ID, "", "", true) - defaults, err := s.Records(ctx, w.ID, "", "") - if err != nil || len(defaults) != 2 { - t.Fatalf("default list included goaheads: %+v %v", defaults, err) - } - missing := p - missing.Wave = 2 - for _, ask := range []RecordPropose{ - {Kind: "goahead"}, {Kind: "goahead", GoAhead: &p, Kept: true}, - {Kind: "decision", GoAhead: &p}, {Kind: "question", GoAhead: &p}, - {Kind: "goahead", GoAhead: &missing}, - } { - ask.WorkstreamID, ask.Text = w.ID, "Why" - if _, c, err := s.ProposeRecord(ctx, ask); !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("propose %+v: %+v %v", ask, c, err) - } - } - for _, tc := range []struct{ id, kind string }{{before[0].ID, ""}, {before[0].ID, "decision"}, {before[1].ID, "goahead"}} { - if _, _, c, err := s.SupersedeRecord(ctx, tc.id, RecordReplacement{Kind: tc.kind, Text: "Replacement"}); !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("supersede %+v: %+v %v", tc, c, err) - } - } - after, err := s.Records(ctx, w.ID, "", "", true) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("refusals wrote: %+v %v", after, err) - } - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - for _, by := range []string{"", "you", "agent"} { - if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p, By: by}); !errors.Is(err, ErrConflict) { - t.Fatalf("done attribution %q: %v", by, err) - } - } -} - -// WP-F2 (d): proposal validation uses the same cap, end and scope rules as grants. -func TestWorkstreamGoAheadProposalsUseGrantBounds(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, good := workstreamProposalFixture(t, s, *now) - for name, mutate := range map[string]func(*GoAheadProposal){ - "cap zero": func(p *GoAheadProposal) { p.CapUSD = 0 }, - "cap negative": func(p *GoAheadProposal) { p.CapUSD = -1 }, - "cap too large": func(p *GoAheadProposal) { p.CapUSD = 10000.01 }, - "cap NaN": func(p *GoAheadProposal) { p.CapUSD = math.NaN() }, - "cap infinite": func(p *GoAheadProposal) { p.CapUSD = math.Inf(1) }, - "end zero": func(p *GoAheadProposal) { p.EndsAt = time.Time{} }, - "end now": func(p *GoAheadProposal) { p.EndsAt = *now }, - "end too far": func(p *GoAheadProposal) { p.EndsAt = now.Add(90*24*time.Hour + time.Millisecond) }, - "mixed scope": func(p *GoAheadProposal) { p.Factory = "f" }, - "missing job": func(p *GoAheadProposal) { p.Scope = "job"; p.Factory = "f"; p.Job = 8; p.Wave = 0 }, - "missing milestone": func(p *GoAheadProposal) { p.Scope = "milestone"; p.MilestoneID = "gone"; p.Wave = 0 }, - } { - t.Run(name, func(t *testing.T) { - p := good - mutate(&p) - _, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("proposal: %+v %v", c, err) - } - }) - } - all, err := s.Records(ctx, w.ID, "", "", true) - grants, grantErr := s.GoAheads(ctx, w.ID, true) - if err != nil || grantErr != nil || len(all) != 0 || len(grants) != 0 { - t.Fatalf("refusals wrote: %+v %+v %v %v", all, grants, err, grantErr) - } - good.CapUSD, good.EndsAt = 10000, now.Add(90*24*time.Hour) - if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &good}); err != nil { - t.Fatalf("inclusive bounds: %v", err) - } -} - -// WP-F2 (d): a failed record update rolls back the preceding grant or revocation. -func TestWorkstreamGoAheadRecordTransactionRollsBackGrantChanges(t *testing.T) { - s, db, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if err != nil { - t.Fatal(err) - } - for _, state := range []string{"kept", "dropped"} { - if state == "dropped" { - r, _, err = s.KeepRecord(ctx, r.ID) - if err != nil { - t.Fatal(err) - } - } - before, err := s.GoAheads(ctx, w.ID, true) - if err != nil { - t.Fatal(err) - } - err = db.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `CREATE TRIGGER workstream_record_fail BEFORE UPDATE ON workstream_records BEGIN SELECT RAISE(ABORT,'fixture refuses record write'); END`) - return err - }) - if err != nil { - t.Fatal(err) - } - var c Change - if state == "kept" { - _, c, err = s.KeepRecord(ctx, r.ID) - } else { - _, c, err = s.DropRecord(ctx, r.ID) - } - if err == nil || len(c.Parts) != 0 { - t.Fatalf("failed record write: %+v %v", c, err) - } - after, err := s.GoAheads(ctx, w.ID, true) - records, recordErr := s.Records(ctx, w.ID, "goahead", "") - if err != nil || recordErr != nil || !reflect.DeepEqual(before, after) || len(records) != 1 || !reflect.DeepEqual(records[0], r) { - t.Fatalf("partial commit: %+v %+v %v %v", after, records, err, recordErr) - } - err = db.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, "DROP TRIGGER workstream_record_fail") - return err - }) - if err != nil { - t.Fatal(err) - } - } -} - -// WP-F2 (d): expiry and explicit revocation leave a kept record unchanged. -func TestWorkstreamGoAheadDropAfterExpiryOrRevocation(t *testing.T) { - for _, reason := range []string{"expired", "revoked"} { - t.Run(reason, func(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if err != nil { - t.Fatal(err) - } - r, _, err = s.KeepRecord(ctx, r.ID) - if err != nil { - t.Fatal(err) - } - if reason == "expired" { - *now = p.EndsAt - } else { - _, _, err = s.RevokeGoAhead(ctx, r.GoAheadID) - } - if err != nil { - t.Fatal(err) - } - records, err := s.Records(ctx, w.ID, "goahead", "") - if err != nil || len(records) != 1 || !reflect.DeepEqual(records[0], r) { - t.Fatalf("ended changed record: %+v %v", records, err) - } - dropped, c, err := s.DropRecord(ctx, r.ID) - grants, grantErr := s.GoAheads(ctx, w.ID, true) - if err != nil || grantErr != nil || dropped.GoAheadID != r.GoAheadID || slices.Contains(c.Parts, PartGoAheads) || len(grants) != 1 || grants[0].EndReason != reason { - t.Fatalf("drop: %+v %+v %+v %v %v", dropped, c, grants, err, grantErr) - } - }) - } -} - -// WP-F2 (d): a proposal's end is stored at the grant's precision, so keep grants the same end. -func TestWorkstreamGoAheadKeepGrantsTheProposedEndExactly(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - p.EndsAt = p.EndsAt.Add(123456 * time.Microsecond) - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if err != nil { - t.Fatal(err) - } - r, _, err = s.KeepRecord(ctx, r.ID) - grants, listErr := s.GoAheads(ctx, w.ID, false) - if err != nil || listErr != nil || len(grants) != 1 || r.GoAhead == nil { - t.Fatalf("keep: %+v %+v %v %v", r, grants, err, listErr) - } - if !grants[0].EndsAt.Equal(r.GoAhead.EndsAt) || r.GoAhead.EndsAt.Nanosecond() != 123000000 { - t.Fatalf("proposal end %v, grant end %v", r.GoAhead.EndsAt, grants[0].EndsAt) - } -} diff --git a/internal/workstream/workstream_wake_fix1_test.go b/internal/workstream/workstream_wake_fix1_test.go deleted file mode 100644 index 32b95961ff..0000000000 --- a/internal/workstream/workstream_wake_fix1_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package workstream - -import ( - "context" - "errors" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -func workstreamWakeUndoConflictForTest(t *testing.T, s *Store, run WakeRun) { - t.Helper() - ctx := context.Background() - if len(run.Items) != 1 || run.Items[0].State != "applied" { - t.Fatalf("expected one applied item: %+v", run) - } - if _, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "undo"); !errors.Is(err, ErrConflict) || c.ID != "" { - t.Fatalf("named undo: %+v %v", c, err) - } - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || !reflect.DeepEqual(stored, run) { - t.Fatalf("named conflict changed the wake: %+v %v", stored, err) - } - noted, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != run.WorkstreamID || len(noted.Items) != 1 || noted.Items[0].State != "applied" || noted.Items[0].Note != "Changed since; not undone." { - t.Fatalf("undo all: %+v %+v %v", noted, c, err) - } - again, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != "" || !reflect.DeepEqual(again, noted) { - t.Fatalf("repeated undo all changed the wake: %+v %+v %v", again, c, err) - } -} - -func TestWakeD_M1_ChangedDraftRefusesUndo(t *testing.T) { - for _, change := range []string{"title", "repo", "text", "size", "budget", "source", "wave"} { - t.Run(change, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Draft edits", workstreamWakeMember()) - small := "small" - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "draft-edits", WakeProposal{Kind: "draft", Title: "Follow up", Repo: "api", Text: "Original", Size: "S", BudgetUSD: workstreamWakeBudget(3)}) - plan, err := s.Plan(ctx, w.ID) - if err != nil || len(plan.Waves) != 1 || len(plan.Waves[0].Items) != 1 { - t.Fatalf("wake plan: %+v %v", plan, err) - } - draft := &plan.Waves[0].Items[0] - switch change { - case "title": - draft.Title = "Edited follow-up" - case "repo": - draft.Repo = "web" - case "text": - draft.Text = "Edited contents" - case "size": - draft.Size = "M" - case "budget": - draft.BudgetUSD = 4 - case "source": - draft.Source = &Source{Kind: "chat", ID: w.AgentID, Label: "Person's edit"} - case "wave": - plan.Waves = append(plan.Waves, Wave{N: 2, Title: "Person's wave", Items: plan.Waves[0].Items}) - plan.Waves[0].Items = []PlanItem{} - } - before, _, err := s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}) - if err != nil { - t.Fatal(err) - } - workstreamWakeUndoConflictForTest(t, s, run) - after, err := s.Plan(ctx, w.ID) - if err != nil || !reflect.DeepEqual(after, before) { - t.Fatalf("undo changed the edited draft or its waves: %+v %v", after, err) - } - }) - } -} - -func TestWakeD_M1_OnlyUnchangedWakeCreatedWaveIsRemoved(t *testing.T) { - for _, change := range []string{"unchanged", "renamed", "auto_start", "milestone", "another_draft", "person_created"} { - t.Run(change, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Wave cleanup", workstreamWakeMember()) - small := "small" - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - if change == "person_created" { - if _, _, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { - t.Fatal(err) - } - } - run := workstreamWakeForTest(t, s, w, "wave-cleanup", WakeProposal{Kind: "draft", Title: "Follow up", BudgetUSD: workstreamWakeBudget(3)}) - plan, err := s.Plan(ctx, w.ID) - if err != nil || len(run.Items) != 1 || run.Items[0].State != "applied" || len(plan.Waves) != 1 || len(plan.Waves[0].Items) != 1 { - t.Fatalf("wake plan: %+v %+v %v", run, plan, err) - } - switch change { - case "renamed": - plan.Waves[0].Title = "Person's title" - case "auto_start": - plan.Waves[0].AutoStart = true - case "milestone": - plan.Milestones = []Milestone{{ID: "m", Title: "Release"}} - plan.Waves[0].MilestoneID = "m" - case "another_draft": - plan.Waves[0].Items = append(plan.Waves[0].Items, PlanItem{Kind: "draft", DraftID: "person-draft", Title: "Person's draft"}) - } - if change != "unchanged" && change != "person_created" { - if _, _, err := s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}); err != nil { - t.Fatal(err) - } - } - want := []Wave{} - if change != "unchanged" { - want = plan.Waves - want[0].Items = want[0].Items[1:] - } - undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID || len(undone.Items) != 1 || undone.Items[0].State != "undone" { - t.Fatalf("undo: %+v %+v %v", undone, c, err) - } - after, err := s.Plan(ctx, w.ID) - if err != nil || !reflect.DeepEqual(after.Waves, want) || !reflect.DeepEqual(after.Milestones, plan.Milestones) { - t.Fatalf("wave cleanup: %+v, want %+v (%v)", after, want, err) - } - }) - } -} - -func TestWakeDE_M2_PageMetadataChangesRefuseUndo(t *testing.T) { - for _, operation := range []string{"wake_update", "wake_creation"} { - t.Run(operation, func(t *testing.T) { - for _, change := range []string{"page.update_title", "page.update_owner", "page.update_read_by_jobs", "page.move_parent", "page.move_position", "page.delete"} { - t.Run(change, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Page metadata", workstreamWakeMember()) - small := "small" - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - parent, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person's folder", Owner: "you"}) - if err != nil { - t.Fatal(err) - } - proposal := WakeProposal{Kind: "page", Title: "Agent notes", Markdown: "Wake contents"} - if operation == "wake_update" { - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: proposal.Title, Owner: "agent", Doc: pagedoc.FromMarkdown("Original")}) - if err != nil { - t.Fatal(err) - } - proposal.PageID = page.ID - } - run := workstreamWakeForTest(t, s, w, "page-metadata", proposal) - if len(run.Items) != 1 || run.Items[0].State != "applied" { - t.Fatal(run) - } - id := run.Items[0].PageID - switch change { - case "page.update_title": - title := "Person's title" - _, _, err = s.UpdatePage(ctx, id, PagePatch{Title: &title}) - case "page.update_owner": - owner := "you" - _, _, err = s.UpdatePage(ctx, id, PagePatch{Owner: &owner}) - case "page.update_read_by_jobs": - read := true - _, _, err = s.UpdatePage(ctx, id, PagePatch{ReadByJobs: &read}) - case "page.move_parent": - _, _, err = s.MovePage(ctx, id, parent.ID, "") - case "page.move_position": - _, _, err = s.MovePage(ctx, id, "", "") - case "page.delete": - _, _, err = s.DeletePage(ctx, id) - } - if err != nil { - t.Fatal(err) - } - before, err := s.Page(ctx, id, false) - if err != nil || before.Version != run.Items[0].Undo.Version { - t.Fatalf("metadata change advanced the content version: %+v %v", before, err) - } - history, historyErr := s.PageHistory(ctx, id, 100) - if change == "page.delete" { - if !errors.Is(historyErr, ErrNotFound) { - t.Fatal(historyErr) - } - } else if historyErr != nil { - t.Fatal(historyErr) - } - personBefore, err := s.Page(ctx, parent.ID, false) - if err != nil { - t.Fatal(err) - } - workstreamWakeUndoConflictForTest(t, s, run) - after, err := s.Page(ctx, id, false) - if err != nil || !reflect.DeepEqual(after, before) { - t.Fatalf("undo changed the later page edit: %+v %v", after, err) - } - afterHistory, err := s.PageHistory(ctx, id, 100) - if !errors.Is(err, historyErr) || !reflect.DeepEqual(afterHistory, history) { - t.Fatalf("undo wrote page history: %+v %v", afterHistory, err) - } - person, err := s.Page(ctx, parent.ID, false) - if err != nil || !reflect.DeepEqual(person, personBefore) { - t.Fatalf("undo changed the person's page: %+v %v", person, err) - } - }) - } - }) - } -} - -func TestWakeD_m1_DuplicatePageItemsKeepFirstAndUndoOriginal(t *testing.T) { - for _, autonomy := range []string{"small", "propose"} { - t.Run(autonomy, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Duplicate pages", workstreamWakeMember()) - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}); err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent", Doc: pagedoc.FromMarkdown("Original")}) - if err != nil { - t.Fatal(err) - } - answer, err := ParseWakeAnswer(`{"summary":"Next","items":[` + - `{"kind":"page","page_id":"` + page.ID + `","markdown":"First update"},` + - `{"kind":"page","page_id":"` + page.ID + `","markdown":"Later update"}]}`) - if err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "duplicate-pages", answer.Items...) - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || len(stored.Items) != 1 || stored.Items[0].PageID != page.ID || stored.Items[0].Proposal.Markdown != "First update" { - t.Fatalf("page items were not deduplicated: %+v %v", stored, err) - } - if autonomy == "propose" { - for _, item := range stored.Items { - if item.State != "proposed" { - t.Fatal(item) - } - } - stored, _, err = s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil { - t.Fatal(err) - } - } - for _, item := range stored.Items { - if item.State != "applied" { - t.Fatal(item) - } - } - updated, err := s.Page(ctx, page.ID, false) - if err != nil || updated.Version != page.Version+1 || string(updated.Doc) != string(pagedoc.FromMarkdown("First update")) { - t.Fatalf("first update was not the only write: %+v %v", updated, err) - } - undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID { - t.Fatalf("undo: %+v %v", c, err) - } - for _, item := range undone.Items { - if item.State != "undone" || item.Note != "" { - t.Fatal(item) - } - } - restored, err := s.Page(ctx, page.ID, false) - if err != nil || restored.Version != page.Version+2 || string(restored.Doc) != string(page.Doc) || restored.Title != page.Title { - t.Fatalf("undo did not restore the original document: %+v %v", restored, err) - } - }) - } -} - -func TestWakeD_m1_DuplicateNewPageTitlesKeepFirst(t *testing.T) { - for _, autonomy := range []string{"small", "propose"} { - t.Run(autonomy, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Duplicate new pages", workstreamWakeMember()) - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}); err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "duplicate-new-pages", WakeProposal{Kind: "page", Title: "New notes", Markdown: "First creation"}, WakeProposal{Kind: "page", Title: "New notes", Markdown: "Later creation"}) - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || len(stored.Items) != 1 || stored.Items[0].Proposal.Markdown != "First creation" { - t.Fatalf("new-page items were not deduplicated: %+v %v", stored, err) - } - if autonomy == "propose" { - if stored.Items[0].State != "proposed" { - t.Fatal(stored) - } - stored, _, err = s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil { - t.Fatal(err) - } - } - pages, err := s.Pages(ctx, w.ID) - if err != nil || len(pages) != 2 || stored.Items[0].State != "applied" { - t.Fatalf("expected one new agent page: %+v %+v %v", pages, stored, err) - } - page, err := s.Page(ctx, stored.Items[0].PageID, false) - if err != nil || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown("First creation")) { - t.Fatalf("first new-page suggestion was not kept: %+v %v", page, err) - } - undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID || undone.Items[0].State != "undone" { - t.Fatalf("undo: %+v %+v %v", undone, c, err) - } - deleted, err := s.Page(ctx, page.ID, false) - if err != nil || deleted.DeletedAt.IsZero() { - t.Fatalf("new page was not restorably deleted: %+v %v", deleted, err) - } - }) - } -} diff --git a/internal/workstream/workstream_wake_fix2_test.go b/internal/workstream/workstream_wake_fix2_test.go deleted file mode 100644 index f29c87c71b..0000000000 --- a/internal/workstream/workstream_wake_fix2_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package workstream - -import ( - "context" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -func TestWakeD_R2_M2_UndoKeepsRenameBetweenClaimAndSettlement(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Rename during wake", workstreamWakeMember()) - small := "small" - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Old title", Owner: "agent", Doc: pagedoc.FromMarkdown("Original document")}) - if err != nil { - t.Fatal(err) - } - run, woke, err := s.BeginWake(ctx, w.ID, "rename-during-wake", WakeEvent{Kind: "merged", Factory: "f", Job: 7, At: s.now()}) - if err != nil || !woke { - t.Fatalf("claim: %+v %v %v", run, woke, err) - } - title := "Person's title" - renamed, c, err := s.UpdatePage(ctx, page.ID, PagePatch{Title: &title}) - if err != nil || c.ID != w.ID || renamed.Title != title || renamed.Version != page.Version { - t.Fatalf("rename between claim and settlement: %+v %+v %v", renamed, c, err) - } - history, err := s.PageHistory(ctx, page.ID, 100) - if err != nil || len(history) != 1 || history[0].Title != page.Title { - t.Fatalf("rename changed the original content history: %+v %v", history, err) - } - run, c, err = s.SettleWake(ctx, run.ID, WakeAnswer{Items: []WakeProposal{{Kind: "page", PageID: page.ID, Markdown: "Wake document"}}}, nil, "") - if err != nil || c.ID != w.ID || len(run.Items) != 1 || run.Items[0].State != "applied" { - t.Fatalf("settle: %+v %+v %v", run, c, err) - } - applied, err := s.Page(ctx, page.ID, false) - if err != nil || applied.Title != title || applied.Version != page.Version+1 || string(applied.Doc) != string(pagedoc.FromMarkdown("Wake document")) { - t.Fatalf("wake did not preserve the rename: %+v %v", applied, err) - } - undone, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "undo") - if err != nil || c.ID != w.ID || len(undone.Items) != 1 || undone.Items[0].State != "undone" || undone.Items[0].Note != "" { - t.Fatalf("undo: %+v %+v %v", undone, c, err) - } - restored, err := s.Page(ctx, page.ID, false) - if err != nil || restored.Title != title || restored.Version != page.Version+2 || restored.UpdatedBy != "agent" || string(restored.Doc) != string(page.Doc) { - t.Fatalf("undo did not restore the original document and keep the rename: %+v %v", restored, err) - } - history, err = s.PageHistory(ctx, page.ID, 100) - if err != nil || len(history) != 3 || history[0].Version != restored.Version || history[0].Title != title || history[0].SavedBy != "agent" || history[2].Title != page.Title { - t.Fatalf("undo history lost the rename or rewrote the original version: %+v %v", history, err) - } - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || !reflect.DeepEqual(stored, undone) { - t.Fatalf("undo was not persisted: %+v %v", stored, err) - } -} - -func TestWakeD_R2_m1_CreatedSiblingPagesUndoInAnyOrder(t *testing.T) { - for _, tc := range []struct { - name string - autonomy string - reverseApply bool - reorderSibling bool - }{ - {name: "named_undo_after_sibling_compaction", autonomy: "small"}, - {name: "undo_all_after_reverse_individual_apply", autonomy: "propose", reverseApply: true}, - {name: "named_undo_after_person_reorders_siblings", autonomy: "small", reorderSibling: true}, - } { - t.Run(tc.name, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Sibling pages", workstreamWakeMember()) - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &tc.autonomy}); err != nil { - t.Fatal(err) - } - before, err := s.Pages(ctx, w.ID) - if err != nil || len(before) != 1 { - t.Fatalf("initial pages: %+v %v", before, err) - } - run := workstreamWakeForTest(t, s, w, "sibling-pages", - WakeProposal{Kind: "page", Title: "A", Markdown: "Document A"}, - WakeProposal{Kind: "page", Title: "B", Markdown: "Document B"}) - if len(run.Items) != 2 || run.Items[0].Proposal.Title != "A" || run.Items[1].Proposal.Title != "B" { - t.Fatalf("expected two pages in card order A, B: %+v", run) - } - if tc.reverseApply { - for _, item := range run.Items { - if item.State != "proposed" { - t.Fatalf("page was applied before individual Apply: %+v", item) - } - } - for i := len(run.Items) - 1; i >= 0; i-- { - var c Change - run, c, err = s.ActOnWake(ctx, run.ID, run.Items[i].ID, "apply") - if err != nil || c.ID != w.ID || run.Items[i].State != "applied" { - t.Fatalf("individual Apply in reverse card order: %+v %+v %v", run, c, err) - } - } - } - for i, item := range run.Items { - position := i + 1 - if tc.reverseApply { - position = len(run.Items) - i - } - page, err := s.Page(ctx, item.PageID, false) - if err != nil || item.State != "applied" || page.ParentID != "" || page.Position != position || page.Owner != "agent" || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown(item.Proposal.Markdown)) { - t.Fatalf("applied root page: %+v %+v %v", item, page, err) - } - } - var sibling PageNode - if tc.reorderSibling { - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person's page", Owner: "you"}) - if err != nil { - t.Fatal(err) - } - var c Change - sibling, c, err = s.MovePage(ctx, page.ID, "", before[0].ID) - if err != nil || c.ID != w.ID || sibling.Position != 1 { - t.Fatalf("reorder sibling: %+v %+v %v", sibling, c, err) - } - for i, item := range run.Items { - page, err := s.Page(ctx, item.PageID, false) - if err != nil || page.Position != i+2 || item.Undo.Page == nil { - t.Fatalf("sibling reorder did not shift the wake page: %+v %v", page, err) - } - postImage := *item.Undo.Page - postImage.Position = page.Position - if !reflect.DeepEqual(page.PageNode, postImage) { - t.Fatalf("sibling reorder changed more than position: %+v", page) - } - } - } - if tc.reverseApply { - var c Change - run, c, err = s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID { - t.Fatalf("Undo all: %+v %+v %v", run, c, err) - } - } else { - b, err := s.Page(ctx, run.Items[1].PageID, false) - if err != nil { - t.Fatal(err) - } - for i := range run.Items { - var c Change - run, c, err = s.ActOnWake(ctx, run.ID, run.Items[i].ID, "undo") - if err != nil || c.ID != w.ID || run.Items[i].State != "undone" || run.Items[i].Note != "" { - t.Fatalf("named Undo %d: %+v %+v %v", i, run, c, err) - } - if i == 0 { - compacted, err := s.Page(ctx, b.ID, false) - if err != nil || compacted.Position != b.Position-1 || !compacted.DeletedAt.IsZero() || run.Items[1].State != "applied" { - t.Fatalf("Undo A did not compact the still-applied B: %+v %+v %v", compacted, run, err) - } - } - } - } - for _, item := range run.Items { - page, err := s.Page(ctx, item.PageID, false) - if err != nil || item.State != "undone" || item.Note != "" || page.DeletedAt.IsZero() || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown(item.Proposal.Markdown)) { - t.Fatalf("wake page was not restorably deleted: %+v %+v %v", item, page, err) - } - } - want := append([]PageNode(nil), before...) - if tc.reorderSibling { - want = append(want, sibling) - } - after, err := s.Pages(ctx, w.ID) - if err != nil || !reflect.DeepEqual(after, want) { - t.Fatalf("Undo changed the person's pages: %+v %v", after, err) - } - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || !reflect.DeepEqual(stored, run) { - t.Fatalf("Undo was not persisted: %+v %v", stored, err) - } - if again, c, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil || c.ID != "" || !reflect.DeepEqual(again, run) { - t.Fatalf("repeated Undo changed the wake: %+v %+v %v", again, c, err) - } - }) - } -} From 4b1bb03f07e4f91d89f4343adbd4f90dfe6bd229 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 21:30:55 -0500 Subject: [PATCH 19/20] A blank conversation a collection holds is kept, never resumed, and shown as live (#2337) * A blank conversation a collection holds is kept but never resumed Launch resumed a never-spoken conversation that a collection references, ahead of the last spoken one, because "kept" and "spoken" shared one predicate. The emptiness check now reads only content; references keep a blank out of both the resume and the reuse piles, and every removal path still refuses it. Co-Authored-By: Claude Opus 5.5 * Collections show a held blank conversation as live after a restart The resolver built its view from agents.list, which leaves named blanks out, so collections.open and collections.expand called them deleted. A missing id is now looked up once on disk before it is called deleted. Co-Authored-By: Claude Opus 5.5 * Tests: held blanks are neither resumed nor called deleted Co-Authored-By: Claude Opus 5.5 * Change entry for #2337 Co-Authored-By: Claude Opus 5.5 * A permanently deleted conversation stays deleted in its collection The on-disk fallback found the metadata a permanent deletion keeps, so the resolver would have shown a deleted conversation as live and allowed a relink to it. A row pending deletion, or a folder with the deletion marker, now stays deleted. Co-Authored-By: Claude Opus 5.5 * Only a conversation member uses the saved-folder fallback A blank found on disk shared the listing cache that task and process members use to find their owner, so a collection read could open a crew member's conversation to look for a child that does not exist. The fallback now serves conversation members only, from its own cache. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/appblank.go | 2 +- cmd/codeaf/appcollectionkeep_test.go | 477 +++++++++++++++++- cmd/codeaf/appcollections.go | 59 ++- cmd/codeaf/chatv3_layout.go | 14 +- ...-held-blank-conversations-never-resumed.md | 12 + 5 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md diff --git a/cmd/codeaf/appblank.go b/cmd/codeaf/appblank.go index 7dd6e31fb9..90ab9cbff9 100644 --- a/cmd/codeaf/appblank.go +++ b/cmd/codeaf/appblank.go @@ -139,7 +139,7 @@ func (b *appBackend) reapBlank(a *appAgent) bool { } gone := false _ = teamstore.InspectConversation(b.crewProfile(), transcript, func(referenced bool) error { - if referenced || session.InUse(transcript) || !v3EmptyUnassignedSession(dir) { + if referenced || session.InUse(transcript) || !v3EmptyUnassignedSession(dir) || collectionKeepsConversation(dir) { return nil } gone = os.RemoveAll(dir) == nil diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go index 019c60feef..41e282c152 100644 --- a/cmd/codeaf/appcollectionkeep_test.go +++ b/cmd/codeaf/appcollectionkeep_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "sync/atomic" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/workspace" ) @@ -29,6 +31,170 @@ func collectionKeepEnvironment(t *testing.T) (string, string) { return root, bucket } +// STAB2 E-B1 +func TestACollectionHeldBlankIsNeverTheConversationALaunchResumes(t *testing.T) { + for _, test := range []struct { + name string + spoken bool + held bool + unreadable bool + team bool + }{ + {name: "a held blank beside an older spoken conversation", spoken: true, held: true}, + {name: "only a held blank", held: true}, + {name: "an unreadable store beside an older spoken conversation", spoken: true, unreadable: true}, + {name: "only a blank with an unreadable store", unreadable: true}, + {name: "a blank held by both a team and a collection", held: true, team: true}, + } { + t.Run(test.name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + blank := writeV3Session(t, bucket, "blank-agent", "", time.Time{}) + older := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC) + newer := older.Add(24 * time.Hour) + if err := os.Chtimes(blank, newer, newer); err != nil { + t.Fatal(err) + } + var conversation string + if test.spoken { + conversation = writeV3Session(t, bucket, "spoken-agent", "keep these notes", older) + } + path := home.Join("v3", "collections.db") + if test.held { + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(blank)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + } + if test.unreadable { + if err := os.WriteFile(path, []byte("unreadable database"), 0o600); err != nil { + t.Fatal(err) + } + } + if test.team { + transcript := session.Place{Dir: blank}.Transcript() + if err := teams.Save(config.ProfileDir(), []teams.Team{{ID: "aaaaaaaaaaaa", Name: "Notes", Manager: transcript, + Members: []teams.Member{{Key: transcript, File: transcript}}, + }}); err != nil { + t.Fatal(err) + } + } + + spoken, empty := v3ScanBucket(bucket) + if test.spoken { + if len(spoken) != 1 || spoken[0].dir != conversation { + t.Fatalf("resume candidates: %+v, want only %s", spoken, conversation) + } + } else if len(spoken) != 0 { + t.Fatalf("a blank became a resume candidate: %+v", spoken) + } + if len(empty) != 0 { + t.Fatalf("a kept blank was offered for reuse: %+v", empty) + } + folder, resumed, found := v3PickFolder(spoken, empty, true) + if test.spoken { + if !found || !resumed || folder.dir != conversation { + t.Fatalf("launch picked %+v, resumed=%t found=%t", folder, resumed, found) + } + } else if found || resumed { + t.Fatalf("launch did not ask for a fresh identity: %+v, resumed=%t found=%t", folder, resumed, found) + } + want := "" + if test.spoken { + want = session.Place{Dir: conversation}.Transcript() + } + if got := v3LatestTranscript(root); got != want { + t.Fatalf("host discovery found %q, want %q", got, want) + } + // Recheck a stale cleanup candidate as well as the scan's own pile. + v3ReapEmpty([]v3Folder{{dir: blank}}, "") + if _, err := os.Stat(blank); err != nil { + t.Fatalf("cleanup removed a kept blank: %v", err) + } + }) + } +} + +func TestABlankConversationCanBeReapedAfterItsLastCollectionReferenceIsRemoved(t *testing.T) { + for _, withSpoken := range []bool{false, true} { + name := "only a detached blank" + if withSpoken { + name = "a detached blank beside an older spoken conversation" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + dir := writeV3Session(t, bucket, "reserved-agent", "", time.Time{}) + older := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC) + newer := older.Add(24 * time.Hour) + if err := os.Chtimes(dir, newer, newer); err != nil { + t.Fatal(err) + } + var conversation string + wantSpoken := 0 + if withSpoken { + conversation = writeV3Session(t, bucket, "spoken-agent", "keep these notes", older) + wantSpoken = 1 + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(dir)} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + spoken, empty := v3ScanBucket(bucket) + if len(spoken) != wantSpoken || withSpoken && spoken[0].dir != conversation || len(empty) != 0 || v3EmptySession(dir) { + t.Fatalf("a held blank was offered for resume or cleanup: spoken=%+v empty=%+v", spoken, empty) + } + if err := store.Remove(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + spoken, empty = v3ScanBucket(bucket) + if len(spoken) != wantSpoken || withSpoken && spoken[0].dir != conversation || len(empty) != 1 || empty[0].dir != dir || !v3EmptySession(dir) { + t.Fatalf("a detached blank was still kept: spoken=%+v empty=%+v", spoken, empty) + } + want := dir + if withSpoken { + want = conversation + } + folder, resumed, found := v3PickFolder(spoken, empty, false) + if !found || resumed != withSpoken || folder.dir != want { + t.Fatalf("host discovery picked %+v, resumed=%t found=%t", folder, resumed, found) + } + if got := v3LatestTranscript(root); got != (session.Place{Dir: want}).Transcript() { + t.Fatalf("host discovery found %q, want the transcript in %s", got, want) + } + folder, resumed, found = v3PickFolder(spoken, empty, true) + if withSpoken { + if !found || !resumed || folder.dir != conversation { + t.Fatalf("launch picked %+v, resumed=%t found=%t", folder, resumed, found) + } + } else if found || resumed { + t.Fatalf("launch did not ask for a fresh identity: %+v, resumed=%t found=%t", folder, resumed, found) + } + v3ReapEmpty(empty, "") + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("cleanup kept a detached blank: %v", err) + } + }) + } +} + // Contract (g): collection-held blanks survive launch and the app's cleanup. func TestCollectionHeldConversationSurvivesEveryBlankCleanup(t *testing.T) { _, bucket := collectionKeepEnvironment(t) @@ -57,7 +223,10 @@ func TestCollectionHeldConversationSurvivesEveryBlankCleanup(t *testing.T) { if err != nil { t.Fatal(err) } - if v3EmptyUnassignedSession(held) || v3EmptySession(held) { + if !v3EmptyUnassignedSession(held) { + t.Fatal("a collection reference changed whether the conversation is blank") + } + if v3EmptySession(held) { t.Fatal("collection-held conversation is litter") } if !v3EmptyUnassignedSession(unheld) { @@ -198,7 +367,7 @@ func TestCollectionKeepMissingUnreadableAndNoWrites(t *testing.T) { if err := os.WriteFile(path, bad, 0o600); err != nil { t.Fatal(err) } - if !collectionKeepsConversation(dir) || v3EmptyUnassignedSession(dir) { + if !collectionKeepsConversation(dir) || v3EmptySession(dir) { t.Fatal("uncertainty discarded a conversation") } got, err := os.ReadFile(path) @@ -216,6 +385,310 @@ func TestCollectionKeepMissingUnreadableAndNoWrites(t *testing.T) { } } +// STAB2 E-M1 +func TestACollectionHeldBlankAgentIsLiveAfterRestart(t *testing.T) { + for _, title := range []string{"Reserved notes", ""} { + name := "a saved title" + if title == "" { + name = "no saved title" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + if err := session.SaveMeta(dir, session.Meta{ID: id, Workspace: root, Title: title, + Created: time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: id} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + opened := 0 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened++ + return refuseAppOpen(ask) + }) + t.Cleanup(func() { _ = b.Close() }) + b.profileDir = config.ProfileDir() + checkUnlisted := func() { + t.Helper() + list, err := b.ListAgents("") + if err != nil { + t.Fatal(err) + } + if _, found := listsAgent(list, id); found { + t.Fatal("agents.list included a blank conversation") + } + } + checkUnlisted() + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 { + t.Fatalf("collections.open: %+v, %v", open, err) + } + member := open.Members[0] + if member.ID != id || member.Target.State != appwire.TargetLive || member.Target.Title != title { + t.Fatalf("collections.open member: %+v", member) + } + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 0 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + live := expanded.Refs[0] + if live.ID != id || live.Target.State != appwire.TargetLive || live.Target.Title != title { + t.Fatalf("collections.expand member: %+v", live) + } + checkUnlisted() + if opened != 0 { + t.Fatalf("resolving collection members opened %d conversations", opened) + } + + missing := appwire.Ref{Kind: appwire.RefConversation, ID: "missing-agent"} + if _, err := b.AddToCollection(collection.ID, []appwire.Member{{Ref: missing}}); err != nil { + t.Fatal(err) + } + open, err = b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 2 { + t.Fatalf("collections.open with a missing agent: %+v, %v", open, err) + } + foundMissing := false + for _, member := range open.Members { + if member.ID == missing.ID { + foundMissing = true + if member.Target.State != appwire.TargetDeleted { + t.Fatalf("missing agent: %+v", member) + } + } + } + if !foundMissing { + t.Fatal("collections.open omitted the deleted member") + } + expanded, err = b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 1 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 || expanded.Refs[0].ID != id { + t.Fatalf("collections.expand with a missing agent: %+v, %v", expanded, err) + } + if opened != 0 { + t.Fatalf("resolving a missing member opened %d conversations", opened) + } + }) + } +} + +// STAB2-fix R4 +func TestACollectionReadNeverOpensAHeldBlankToResolveItsChildren(t *testing.T) { + for _, method := range []string{"collections.open", "collections.expand"} { + t.Run(method, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + if err := session.SaveMeta(dir, session.Meta{ID: id, Workspace: root, Title: "Reserved notes", + Created: time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatal(err) + } + transcript := session.Place{Dir: dir}.Transcript() + if err := teams.Update(config.ProfileDir(), func(f *teams.File) error { + crew := teams.NewID() + f.Teams = append(f.Teams, teams.Team{ID: crew, Name: "Notes", Made: time.Now()}) + if err := f.AddMember(crew, teams.Member{Key: transcript, File: transcript, Where: root, Word: "notes", Handle: "notes"}); err != nil { + return err + } + return f.SetManager(crew, transcript) + }); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + refs := []workspace.Ref{ + {Kind: workspace.ConversationKind, ID: id}, + {Kind: workspace.TaskKind, ID: "1", SessionID: id}, + {Kind: workspace.ProcessKind, ID: id + ":p1", SessionID: id}, + } + // Reads visit newest members first, so resolve the conversation's + // fallback before either child asks for its owner. + if _, _, err := store.AddRefs(context.Background(), collection.ID, []workspace.Addition{ + {Ref: refs[0], AddedAt: 3}, + {Ref: refs[1], AddedAt: 2}, + {Ref: refs[2], AddedAt: 1}, + }); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + var opened atomic.Int32 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened.Add(1) + return refuseAppOpen(ask) + }) + t.Cleanup(func() { + _ = b.Close() + if got := opened.Load(); got != 0 { + t.Errorf("resolving collection members opened %d conversations", got) + } + }) + b.profileDir = config.ProfileDir() + list, err := b.ListAgents("") + if err != nil { + t.Fatal(err) + } + if _, found := listsAgent(list, id); found { + t.Fatal("agents.list included a blank conversation") + } + if method == "collections.open" { + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != len(refs) { + t.Fatalf("collections.open: %+v, %v", open, err) + } + for i, member := range open.Members { + if member.Kind != string(refs[i].Kind) || member.ID != refs[i].ID || member.SessionID != refs[i].SessionID { + t.Fatalf("collections.open member %d: %+v", i, member) + } + state := appwire.TargetDeleted + if i == 0 { + state = appwire.TargetLive + if member.Target.Title != "Reserved notes" || !reflect.DeepEqual(member.Target.In, []string{"Notes"}) { + t.Fatalf("held blank conversation: %+v", member) + } + } + if member.Target.State != state { + t.Fatalf("collections.open member: %+v, want %s", member, state) + } + } + } else { + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 2 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + live := expanded.Refs[0] + if live.Kind != appwire.RefConversation || live.ID != id || live.Target.State != appwire.TargetLive || live.Target.Title != "Reserved notes" || !reflect.DeepEqual(live.Target.In, []string{"Notes"}) { + t.Fatalf("collections.expand member: %+v", live) + } + } + }) + } +} + +// STAB2-fix R1 +func TestAPermanentlyDeletedConversationStaysDeletedInItsCollection(t *testing.T) { + for _, pending := range []bool{false, true} { + name := "completed deletion" + if pending { + name = "pending deletion" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "deleted-agent" + dir := writeV3Session(t, bucket, id, "saved notes", time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC)) + meta, err := session.LoadMeta(dir) + if err != nil { + t.Fatal(err) + } + meta.Workspace, meta.Title = root, "Deleted notes" + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Saved conversations") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: id} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + transcript := session.Place{Dir: dir}.Transcript() + if pending { + // A staged deletion keeps its cleanup receipt and pending journal. + if err := os.WriteFile(filepath.Join(dir, ".delete-task-cleanup.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, teams.ConversationDeletedFile), []byte("deleted\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(transcript, transcript+".delete-pending"); err != nil { + t.Fatal(err) + } + if row, ok := session.ReadRows([]string{transcript})[filepath.Clean(transcript)]; !ok || !row.DeletionPending { + t.Fatalf("pending deletion fixture: %+v, found=%t", row, ok) + } + } else { + if err := session.DeleteConversationUnder(session.PlacesRoot(), config.ProfileDir(), transcript, nil, nil); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(transcript); !os.IsNotExist(err) { + t.Fatalf("permanent deletion kept the transcript: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, teams.ConversationDeletedFile)); err != nil { + t.Fatalf("permanent deletion lost its lifetime marker: %v", err) + } + if saved, err := session.LoadMeta(dir); err != nil || saved.ID != id || saved.Title != meta.Title { + t.Fatalf("permanent deletion lost its saved metadata: %+v, %v", saved, err) + } + + b := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { _ = b.Close() }) + b.profileDir = config.ProfileDir() + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 { + t.Fatalf("collections.open: %+v, %v", open, err) + } + if member := open.Members[0]; member.ID != id || member.Target.State != appwire.TargetDeleted { + t.Fatalf("collections.open revived a permanently deleted conversation: %+v", member) + } + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 1 || expanded.Unavailable != 0 || len(expanded.Refs) != 0 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + + other, err := b.CreateCollection("Other conversations") + if err != nil { + t.Fatal(err) + } + from := appwire.Ref{Kind: appwire.RefConversation, ID: "another-agent"} + if _, err := b.AddToCollection(other.ID, []appwire.Member{{Ref: from}}); err != nil { + t.Fatal(err) + } + to := appwire.Ref{Kind: appwire.RefConversation, ID: id} + if _, err := b.RelinkCollection(other.ID, from, to); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("relink to a permanently deleted conversation: %v", err) + } + open, err = b.OpenCollection(other.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 || open.Members[0].ID != from.ID { + t.Fatalf("refused relink changed the collection: %+v, %v", open, err) + } + }) + } +} + // Contract (g): a restart recovers every identity field from a never-spoken meta. func TestCollectionHeldConversationIsFoundAndOpenedAfterRestart(t *testing.T) { root, bucket := collectionKeepEnvironment(t) diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 8355d5d926..1d25860c2c 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -10,12 +10,12 @@ package main // // RESOLUTION ASKS EACH OWNER ONCE PER CALL, NEVER ONCE PER MEMBER. A call // takes one agents listing (every state, closed and archived included), one -// crews listing, one task list and one process list per distinct agent, and -// one look for a git work tree per directory; a collection of a thousand -// files in one repository costs one walk up to its .git. No transcript is -// read to resolve anything, and a member whose owner cannot be asked is -// "unknown", which says nothing about the member itself, with a reason that -// names the owner and why it could not be asked. +// folder lookup per unlisted conversation id, one crews listing, one task list +// and one process list per distinct listed agent, and one look for a git work +// tree per directory; a collection of a thousand files in one repository costs +// one walk up to its .git. No transcript is read to resolve anything. A member +// whose owner cannot be asked is "unknown", which says nothing about the member +// itself, with a reason naming the owner and why it could not be asked. import ( "bufio" @@ -34,6 +34,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/workspace" ) @@ -492,9 +493,10 @@ func appUnaskedReason(owner string, err error) string { type collectionResolver struct { b *appBackend - agentsRead bool - agents map[string]appwire.Agent - agentsErr error + agentsRead bool + agents map[string]appwire.Agent + agentsErr error + savedAgents map[string]appwire.Agent crewsRead bool crews []appwire.Crew @@ -526,7 +528,7 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { } return r.factoryTargets[ref] case workspace.ConversationKind: - a, not := r.agent(ref.ID) + a, not := r.conversation(ref.ID) if not != nil { return *not } @@ -581,8 +583,8 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { return appwire.Target{State: appwire.TargetDeleted} } -// agent is one agent off the one listing, or the target a member of it is -// when there is no such agent (deleted) or no listing (unknown, and why). +// agent is one agent off the listing, or the target a member +// of it is when there is no such agent (deleted) or no listing (unknown, and why). func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { if !r.agentsRead { r.agentsRead = true @@ -597,13 +599,42 @@ func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { t := appUnknown("the conversation list", r.agentsErr) return appwire.Agent{}, &t } - a, ok := r.agents[id] - if !ok { + a := r.agents[id] + if a.ID == "" { return appwire.Agent{}, &appwire.Target{State: appwire.TargetDeleted} } return a, nil } +// conversation also checks saved folders for explicitly referenced agents. +// Its cache stays separate so child references still require a listed owner. +func (r *collectionResolver) conversation(id string) (appwire.Agent, *appwire.Target) { + a, not := r.agent(id) + if not == nil || not.State != appwire.TargetDeleted { + return a, not + } + if r.savedAgents == nil { + r.savedAgents = map[string]appwire.Agent{} + } + a, ok := r.savedAgents[id] + if !ok { + // Named blanks stay out of agents.list but can still be held by a + // collection. Cache misses too, since several members can name one agent. + if row, found := appRowOnDisk(id); found && !row.DeletionPending { + // Permanent deletion retains metadata, but its lifetime marker + // prevents the saved folder from becoming a live target again. + if _, err := os.Stat(filepath.Join(row.Dir, teams.ConversationDeletedFile)); errors.Is(err, fs.ErrNotExist) { + a = appRowView(row) + } + } + r.savedAgents[id] = a + } + if a.ID == "" { + return appwire.Agent{}, not + } + return a, nil +} + // appFindOwned is one task or process of an agent, read with list once per // agent per call, or the target it is when it is not there; owner names the // list in an unknown target's reason. diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 2197ff6065..81ef673d68 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -677,8 +677,9 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { } } if v3EmptyUnassignedSession(dir) { - // A team's reference keeps it, and so does a store that cannot say. - if referenced, err := teams.ConversationReferenced(config.ProfileDir(), place.Transcript()); err == nil && !referenced { + // A team or collection reference keeps a blank out of both piles. + // So does a store that cannot say whether it is referenced. + if referenced, err := teams.ConversationReferenced(config.ProfileDir(), place.Transcript()); err == nil && !referenced && !collectionKeepsConversation(dir) { empty = append(empty, folder) } continue @@ -710,14 +711,17 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { // A team reference makes an empty conversation intentional, not reusable // litter. Only the teams that name this conversation are read (the store's // index), and a store that cannot say keeps the folder. +// A collection reference keeps it too, including when its store cannot be read. func v3EmptySession(dir string) bool { referenced, err := teams.ConversationReferenced(config.ProfileDir(), session.Place{Dir: dir}.Transcript()) if err != nil || referenced { return false } - return v3EmptyUnassignedSession(dir) + return v3EmptyUnassignedSession(dir) && !collectionKeepsConversation(dir) } +// v3EmptyUnassignedSession reads only content. References decide whether a blank +// is kept, never whether it is a conversation somebody has spoken in. func v3EmptyUnassignedSession(dir string) bool { place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) @@ -731,7 +735,7 @@ func v3EmptyUnassignedSession(dir string) bool { return false } } - return !collectionKeepsConversation(dir) + return true } // v3ReapEmpty removes the empty session folders a launch left behind, except @@ -751,7 +755,7 @@ func v3ReapEmpty(empty []v3Folder, keep string) { } transcript := session.Place{Dir: folder.dir}.Transcript() _ = teams.InspectConversation(config.ProfileDir(), transcript, func(referenced bool) error { - if !referenced && !session.InUse(transcript) && v3EmptyUnassignedSession(folder.dir) { + if !referenced && !session.InUse(transcript) && v3EmptyUnassignedSession(folder.dir) && !collectionKeepsConversation(folder.dir) { _ = os.RemoveAll(folder.dir) } return nil diff --git a/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md b/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md new file mode 100644 index 0000000000..3d745df9fc --- /dev/null +++ b/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: A blank conversation a collection holds is never resumed and reads as live in its collection +pr: 2337 +surface: [app, chat] +invalidates: [] +--- + +A never-spoken conversation that a collection references is kept, but a launch +without an explicit session no longer resumes it ahead of the last spoken +conversation, and `collections.open` no longer reports it as deleted after a +restart. From b1a02be03cf94db256126e31abb0e9fd970e1bbc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 4 Nov 2026 09:57:56 -0500 Subject: [PATCH 20/20] Client tools: a tool call names the message its turn answers (#2346) * Client tools: a tool call names the message its turn answers (source_message_id) agents.tool_call gains an optional source_message_id: the durable id of the message the running turn answers, the same string as Entry.message_id on that message's chat entry. It is captured when the turn starts and carried on the turn's context, never sampled from the app's current turn, so it survives an engine restart and a replayed call. A stopped turn asked again carries its recorded message; a carry-on after a quit or close carries the message that opened the interrupted run only when the record names it exactly. Turns the engine wakes on its own notes carry none. Crew provenance is unchanged. The hello feature agents.client_tools.source comes with agents.client_tools; turn stays the process-local counter. Co-Authored-By: Claude Opus 5.5 * Client tools: tests for the source message on tool calls A call names its own message (and a later message its own), the same id after reopening the journal, on a resumed stopped turn, on an exact carry-on and on a fresh-registry redispatch; note-woken turns carry none; a steer never moves it; the field is omitted when empty and the feature rides with client tools. Every wait in the new fixtures is bounded. Co-Authored-By: Claude Opus 5.5 * Change entry for the client tool source message Co-Authored-By: Claude Opus 5.5 * Client tools: a carry-on after a quit or close names no message Naming the interrupted run's opening message needed a run id on the person's line, which changes what a person's line records. A carry-on turn is one the engine starts itself, so it now carries no source_message_id, like every other turn the engine starts. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmd/codeaf/appclienttools.go | 2 +- cmd/codeaf/appclienttools_test.go | 181 +++++++++- .../2346-client-tool-source-message.md | 16 + docs/design/app-v3/PROTOCOL.md | 21 +- internal/appwire/client_tools.go | 20 +- internal/appwire/client_tools_test.go | 38 ++- internal/appwire/contract_test.go | 10 + internal/session/agent.go | 5 +- internal/session/client_tools.go | 2 + internal/session/client_tools_test.go | 309 +++++++++++++++++- internal/session/resume.go | 3 + internal/session/turn_origin.go | 21 +- 12 files changed, 595 insertions(+), 33 deletions(-) create mode 100644 docs/changes/unreleased/2346-client-tool-source-message.md diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go index 9c52d5a2ac..fe6b55a0bd 100644 --- a/cmd/codeaf/appclienttools.go +++ b/cmd/codeaf/appclienttools.go @@ -293,7 +293,7 @@ func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.Cli } }) r.queueEmission(d.backend.emit, pending, appwire.AgentsToolCall{Agent: d.agent, Turn: turn, - Call: id, TurnCall: call.TurnCall, Set: call.Set, Name: call.Name, Args: bytes.Clone(call.Args), DeadlineMS: pending.call.DeadlineMS}) + Call: id, TurnCall: call.TurnCall, SourceMessageID: call.SourceMessage, Set: call.Set, Name: call.Name, Args: bytes.Clone(call.Args), DeadlineMS: pending.call.DeadlineMS}) } return r.wait(ctx, id, pending), nil } diff --git a/cmd/codeaf/appclienttools_test.go b/cmd/codeaf/appclienttools_test.go index cb9326c985..d2a101c37a 100644 --- a/cmd/codeaf/appclienttools_test.go +++ b/cmd/codeaf/appclienttools_test.go @@ -21,12 +21,159 @@ import ( ) type clientToolEmitter struct { + t *testing.T mu sync.Mutex calls []appwire.AgentsToolCall seen chan appwire.AgentsToolCall answer func(appwire.AgentsToolCall) } +// Source contract (a), (b): real provider calls name the entry returned by the +// app read projection, across a second send, a reopened journal and takeover resume. +func TestClientToolsSourceMessageWireAndRestart(t *testing.T) { + for _, stopped := range []bool{false, true} { + t.Run(fmt.Sprintf("stopped=%t", stopped), func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + clientToolAttach(t, b, door) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + entered := make(chan struct{}) + var mu sync.Mutex + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + Tools []json.RawMessage `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + tool := len(request.Tools) > 0 && len(request.Messages) > 0 && request.Messages[len(request.Messages)-1].Role == "user" + mu.Lock() + if tool { + calls++ + } + call := calls + mu.Unlock() + if tool && stopped && call == 1 { + close(entered) + select { + case <-r.Context().Done(): + case <-ctx.Done(): + } + return + } + w.Header().Set("Content-Type", "text/event-stream") + if tool { + fmt.Fprintf(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-source-%d","type":"function","function":{"name":"app_note","arguments":"{}"}}]},"finish_reason":null}]}`+"\n\n", call) + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n") + } else { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + t.Cleanup(server.Close) + answer := func(backend *appBackend, emit *clientToolEmitter) { + emit.answer = func(event appwire.AgentsToolCall) { + ack, err := backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || !ack.Accepted { + t.Errorf("tool result: %+v %v", ack, err) + } + } + } + answer(b, emitter) + path := filepath.Join(t.TempDir(), "session.jsonl") + cfg := session.Config{Workspace: t.TempDir(), SessionFile: path, Model: "test/model", APIKey: "fixture", BaseURL: server.URL, + OneModel: true, DeterministicNames: true, PromptProfile: "full", ClientTools: door} + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = agent }) + drain := func(events <-chan session.Event) { + t.Helper() + for { + select { + case _, open := <-events: + if !open { + return + } + case <-ctx.Done(): + t.Fatal("turn did not finish") + } + } + } + events, err := agent.Submit(ctx, "first question") + if err != nil { + t.Fatal(err) + } + if stopped { + clientToolAwait(t, entered) + agent.InterruptFor(session.StopByTakeover) + } + drain(events) + messageID := func(conv *session.Agent, text string) string { + t.Helper() + for _, entry := range appEntriesOf(door.agent, conv.Transcript(), time.Time{}, nil) { + if entry.Kind == "user" && entry.Text == text && entry.MessageID != "" { + return entry.MessageID + } + } + t.Fatalf("read omitted message id for %q", text) + return "" + } + firstID := messageID(agent, "first question") + if !stopped { + first := clientToolAwait(t, emitter.seen) + if first.SourceMessageID != firstID { + t.Fatalf("first call=%+v read message=%q", first, firstID) + } + next, err := agent.SubmitMessage(ctx, session.Submission{Text: "second question", ClientID: "second-window"}) + if err != nil { + t.Fatal(err) + } + drain(next) + second := clientToolAwait(t, emitter.seen) + secondID := messageID(agent, "second question") + if second.SourceMessageID != secondID || secondID == firstID { + t.Fatalf("second call=%+v read ids=%q/%q", second, firstID, secondID) + } + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + fresh, freshDoor, freshEmitter := clientToolBackend(t) + clientToolAttach(t, fresh, freshDoor) + answer(fresh, freshEmitter) + cfg.ClientTools = freshDoor + reopened, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reopened.Close() }) + fresh.locked(func() { fresh.agents[freshDoor.agent].live.conn.Conv = reopened }) + if got := messageID(reopened, "first question"); got != firstID { + t.Fatalf("reopened read changed message id: %q want %q", got, firstID) + } + if stopped { + resumed, ok := reopened.ResumeStoppedTurn(ctx) + if !ok { + t.Fatal("reopened stopped turn did not resume") + } + drain(resumed) + call := clientToolAwait(t, freshEmitter.seen) + if call.SourceMessageID != firstID { + t.Fatalf("resumed call=%+v read message=%q", call, firstID) + } + } + }) + } +} + // Contract (a), (b), (e): a real session provider request crosses the app door, // then a turn started after disconnect loses the tools and their instructions. func TestClientToolsModelWireRoundTripAndDisconnectBoundary(t *testing.T) { @@ -192,7 +339,12 @@ func (e *clientToolEmitter) Emit(event string, data any) { e.mu.Lock() e.calls = append(e.calls, call) e.mu.Unlock() - e.seen <- call + select { + case e.seen <- call: + case <-time.After(2 * time.Second): + e.t.Error("client tool event could not be delivered within two seconds") + return + } if e.answer != nil { e.answer(call) } @@ -201,7 +353,7 @@ func (e *clientToolEmitter) Emit(event string, data any) { func clientToolBackend(t *testing.T) (*appBackend, *appClientToolDoor, *clientToolEmitter) { t.Helper() t.Setenv(home.EnvVar, t.TempDir()) - e := &clientToolEmitter{seen: make(chan appwire.AgentsToolCall, 20)} + e := &clientToolEmitter{t: t, seen: make(chan appwire.AgentsToolCall, 20)} b := newFakeBackend(e, nil) id := "client-agent" transcript := filepath.Join(t.TempDir(), id, "transcript.jsonl") @@ -318,14 +470,15 @@ func clientToolAwait[T any](t *testing.T, done <-chan T) T { return zero } -// Contract (b), (d): fast answers, duplicate dispatches and replay share one event. +// Contract (b), (d), source contract (b): redispatch preserves the source and emits once. func TestClientToolsEventResultAndReplay(t *testing.T) { b, door, emitter := clientToolBackend(t) call := clientToolAttach(t, b, door) + call.SourceMessage = "msg-recorded" first := clientToolStart(context.Background(), door, call) event := clientToolAwait(t, emitter.seen) if event.Agent != door.agent || event.Turn != 7 || event.TurnCall != call.TurnCall || event.Set != call.Set || event.Name != call.Name || - string(event.Args) != string(call.Args) || event.DeadlineMS != session.ClientToolDefaultDeadlineMS || event.Call != clientToolCallID(door.agent, call.TurnCall) { + string(event.Args) != string(call.Args) || event.DeadlineMS != session.ClientToolDefaultDeadlineMS || event.Call != clientToolCallID(door.agent, call.TurnCall) || event.SourceMessageID != call.SourceMessage { t.Fatalf("event shape: %+v", event) } second := clientToolStart(context.Background(), door, call) @@ -349,6 +502,19 @@ func TestClientToolsEventResultAndReplay(t *testing.T) { if len(emitter.calls) != 1 { t.Fatalf("%d events for one call", len(emitter.calls)) } + // A new registry represents a new process, with a different local counter. + fresh, freshDoor, freshEmitter := clientToolBackend(t) + clientToolAttach(t, fresh, freshDoor) + fresh.locked(func() { fresh.agents[freshDoor.agent].live.turn = 1 }) + replayed := clientToolStart(context.Background(), freshDoor, call) + again := clientToolAwait(t, freshEmitter.seen) + if again.Call != event.Call || again.SourceMessageID != event.SourceMessageID || again.Turn != 1 { + t.Fatalf("fresh process changed correlation: %+v / %+v", event, again) + } + if _, err := fresh.ClientToolResult(appwire.ClientToolResultParams{Call: again.Call, Result: json.RawMessage(`true`)}); err != nil { + t.Fatal(err) + } + clientToolAwait(t, replayed) } // Contract (b): the pending entry exists before an emitter can answer synchronously. @@ -630,13 +796,18 @@ func TestClientToolsAgentLimitsAndScope(t *testing.T) { } } -// Contract (d): a concurrent dispatch joins the actual pending object without emitting. +// Contract (d), source contract (b): pending joins retain the source without emitting. func TestClientToolsPendingJoinAndCancelledAnswer(t *testing.T) { b, door, emitter := clientToolBackend(t) call := clientToolAttach(t, b, door) + call.SourceMessage = "msg-pending" ctx, cancel := context.WithCancel(context.Background()) + defer cancel() done := clientToolStart(ctx, door, call) event := clientToolAwait(t, emitter.seen) + if event.SourceMessageID != call.SourceMessage { + t.Fatalf("pending event lost source: %+v", event) + } r := &b.clientTools r.mu.Lock() first := r.pending[event.Call] diff --git a/docs/changes/unreleased/2346-client-tool-source-message.md b/docs/changes/unreleased/2346-client-tool-source-message.md new file mode 100644 index 0000000000..2676eba4c7 --- /dev/null +++ b/docs/changes/unreleased/2346-client-tool-source-message.md @@ -0,0 +1,16 @@ +--- +kind: added +title: A client tool call names the message its turn answers +pr: 2346 +surface: [app, chat] +invalidates: [] +--- + +`agents.tool_call` carries an optional `source_message_id`: the durable id of +the message the running turn answers, the same string as `Entry.message_id` +on that message's chat entry. It is captured when the turn starts, so it is the +same after an engine restart and on a replayed call. A stopped turn asked again +carries its recorded message. Turns the engine starts itself, including a +carry-on after a quit or close, carry none. The hello feature +`agents.client_tools.source` is advertised with `agents.client_tools`. `turn` +remains the process-local counter. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index db8a8f1628..dd57beb28c 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -81,8 +81,10 @@ decides which combinations are accepted. ## Client tools -The optional feature `agents.client_tools` appears in `hello.features` exactly -when the engine implements the client-tools interface. Sets belong to this +The optional features `agents.client_tools` and `agents.client_tools.source` +appear together in `hello.features` exactly when the engine implements the +client-tools interface. The latter guarantees message identity on calls whenever +the running turn answers a message. Sets belong to this stdio connection and one conversation. They are memory-only: register again after reconnecting. Attachment does not open the conversation or start a turn. Children, workers and clarification conversations inherit neither tools nor @@ -121,10 +123,21 @@ the tail after all built-ins. | event | data | |---|---| -| `agents.tool_call` | `{agent, turn, call, turn_call, set, name, args: , deadline_ms}` | +| `agents.tool_call` | `{agent, turn, call, turn_call, source_message_id?, set, name, args: , deadline_ms}` | + +`source_message_id` names the message the running turn answers: the same string +as `Entry.message_id` on that message's chat entry in `agents.read` (and in +`entry.added` when present). It is captured when the turn starts, never changed +by a mid-turn steer or note, and survives journal restarts and call replays. +It is absent when no message can be named exactly. A stopped answer reuses its +recorded message id. Turns the engine starts itself, including ordinary session +wakes, mixed batches and a carry-on after a quit or close, carry none. +With `agents.client_tools.source` advertised, +an absent field means no such message, rather than an older engine. One model call emits exactly one `agents.tool_call`. `turn` is the engine's -current conversation turn; `turn_call` matches `turn.tool.call`. `call` is unique +process-local conversation counter, which resets after restart; +`turn_call` matches `turn.tool.call`. `call` is unique among pending calls and deterministic for the agent + provider call id. Without a provider id the engine mints a fresh id. A concurrent redispatch joins the pending wait and emits no second event. A settled redispatch returns the saved diff --git a/internal/appwire/client_tools.go b/internal/appwire/client_tools.go index 589f22fd07..a44a3fb508 100644 --- a/internal/appwire/client_tools.go +++ b/internal/appwire/client_tools.go @@ -8,6 +8,7 @@ import ( ) const FeatureClientTools = "agents.client_tools" +const FeatureClientToolsSource = "agents.client_tools.source" const EventAgentsToolCall = "agents.tool_call" // ClientTooler is optional because only a connected app can answer these tools. @@ -31,14 +32,15 @@ type ClientToolsSetResult struct { } type AgentsToolCall struct { - Agent string `json:"agent"` - Turn uint64 `json:"turn"` - Call string `json:"call"` - TurnCall string `json:"turn_call"` - Set string `json:"set"` - Name string `json:"name"` - Args json.RawMessage `json:"args"` - DeadlineMS int `json:"deadline_ms"` + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Call string `json:"call"` + TurnCall string `json:"turn_call"` + SourceMessageID string `json:"source_message_id,omitempty"` + Set string `json:"set"` + Name string `json:"name"` + Args json.RawMessage `json:"args"` + DeadlineMS int `json:"deadline_ms"` } type ClientToolResultParams struct { @@ -130,7 +132,7 @@ func (p ClientToolResultParams) Validate() error { func withClientToolsFeature(hello Hello, engine Engine) Hello { if _, ok := engine.(ClientTooler); ok { - return withFeature(hello, FeatureClientTools) + return withFeature(withFeature(hello, FeatureClientTools), FeatureClientToolsSource) } return hello } diff --git a/internal/appwire/client_tools_test.go b/internal/appwire/client_tools_test.go index d01bed6a61..70f2b779b8 100644 --- a/internal/appwire/client_tools_test.go +++ b/internal/appwire/client_tools_test.go @@ -36,7 +36,7 @@ func validClientToolsParams() ClientToolsSetParams { InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionRead}}} } -// Contract (f): the actual Serve hello advertises only an implemented door. +// Contract (f) and source contract (d): both features accompany the implemented door. func TestClientToolsHelloFeature(t *testing.T) { for _, test := range []struct { engine Engine @@ -53,16 +53,48 @@ func TestClientToolsHelloFeature(t *testing.T) { if err := json.Unmarshal(bytes.Split(out.Bytes(), []byte("\n"))[0], &frame); err != nil { t.Fatal(err) } - found := false + found, source := false, false for _, feature := range frame.Data.Features { found = found || feature == FeatureClientTools + source = source || feature == FeatureClientToolsSource } - if found != test.want { + if found != test.want || source != test.want { t.Fatalf("features=%v want client tools=%v", frame.Data.Features, test.want) } } } +// Source contract (d): an unknown message is omitted, and a known one is exact. +func TestClientToolCallSourceMessageJSON(t *testing.T) { + for _, source := range []string{"", "msg-recorded"} { + raw, err := json.Marshal(AgentsToolCall{SourceMessageID: source}) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + value, present := fields["source_message_id"] + if present != (source != "") || present && string(value) != `"msg-recorded"` { + t.Fatalf("source %q encoded as %s", source, raw) + } + } +} + +// Source contract (d): the written feature guarantee and event shape match the wire. +func TestClientToolsSourceProtocol(t *testing.T) { + raw, err := os.ReadFile(protocolDoc) + if err != nil { + t.Fatal(err) + } + for _, phrase := range []string{FeatureClientToolsSource, "source_message_id?", "Entry.message_id", "process-local"} { + if !strings.Contains(string(raw), phrase) { + t.Errorf("protocol omits %q", phrase) + } + } +} + // Contract (b), (h): both typed methods preserve their declared shapes. func TestClientToolsTypedMethods(t *testing.T) { e := &clientToolsEngine{} diff --git a/internal/appwire/contract_test.go b/internal/appwire/contract_test.go index 1c6b32e270..19fad5e978 100644 --- a/internal/appwire/contract_test.go +++ b/internal/appwire/contract_test.go @@ -140,6 +140,7 @@ func TestTheSchemaIsGeneratedForTheWholeWire(t *testing.T) { Events map[string]json.RawMessage `json:"events"` Defs map[string]struct { Properties map[string]map[string]any `json:"properties"` + Required []string `json:"required"` } `json:"$defs"` } if err := json.Unmarshal(out, &doc); err != nil { @@ -148,6 +149,15 @@ func TestTheSchemaIsGeneratedForTheWholeWire(t *testing.T) { if len(doc.Methods) != len(Methods()) || len(doc.Events) != len(Events()) { t.Fatalf("schema has %d methods and %d events, want %d and %d", len(doc.Methods), len(doc.Events), len(Methods()), len(Events())) } + // Source contract (d): the event registry exposes the optional durable id. + if got := doc.Defs["AgentsToolCall"].Properties["source_message_id"]["type"]; got != "string" { + t.Fatalf("AgentsToolCall.source_message_id schema type=%v", got) + } + for _, field := range doc.Defs["AgentsToolCall"].Required { + if field == "source_message_id" { + t.Fatal("source_message_id became required") + } + } for _, field := range []string{"invoked", "rejected_before_run", "harness_made", "refused", "media_attempt"} { for _, name := range []string{"TurnTool", "Entry"} { if got := doc.Defs[name].Properties[field]["type"]; got != "boolean" { diff --git a/internal/session/agent.go b/internal/session/agent.go index 2acfd69468..6b7c970668 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1423,6 +1423,9 @@ type userMessage struct { // leave it empty, and the turn is then stamped with no person message — an // unknown, never a guess at the latest one. answersMessage string + // sourceMessage is the exact recorded message a resumed answer continues. + // Only ResumeStoppedTurn sets it; ordinary notes never inherit the latest message. + sourceMessage string // said is THE PERSON'S OWN WORDS, when what the model reads is not only // them. Empty in every ordinary case, and set where the session writes @@ -2042,7 +2045,7 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // turn, or one a task or member report opened, has a run and no person // message, which is the truth. A steer that lands later is NOT the opener // and changes nothing here. - turnCtx = withTurnOrigin(turnCtx, turnOrigin{run: runIdentity(turnCtx), turn: openingMessage(user)}) + turnCtx = withTurnOrigin(turnCtx, turnOriginLocked(user, runIdentity(turnCtx))) // AND THE TURN CARRIES THE CREATION CONTEXT the accepting door stated on // its opening message (creation_team.go), only when it stated one: any other // turn leaves the team unknown. Never rewritten from who manages a team now. diff --git a/internal/session/client_tools.go b/internal/session/client_tools.go index 55d4c2afbe..ee710beac6 100644 --- a/internal/session/client_tools.go +++ b/internal/session/client_tools.go @@ -50,6 +50,7 @@ type ClientToolSet struct { type ClientToolCall struct { Set, Name, TurnCall string + SourceMessage string Args json.RawMessage DeadlineMS int } @@ -205,6 +206,7 @@ func (a *Agent) clientTool(set ClientToolSet, spec ClientTool) bare.Tool { } answer, err := a.config.ClientTools.CallClientTool(ctx, ClientToolCall{ Set: set.ID, Name: spec.Name, Args: args, TurnCall: callIDFrom(ctx), DeadlineMS: set.DeadlineMS, + SourceMessage: turnOriginFrom(ctx).answers, }) if err != nil { return err.Error(), true, nil diff --git a/internal/session/client_tools_test.go b/internal/session/client_tools_test.go index 6b4f7fdb52..9f5f1e1d76 100644 --- a/internal/session/client_tools_test.go +++ b/internal/session/client_tools_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" "strings" "testing" "time" @@ -20,6 +22,279 @@ type clientToolsFakeDoor struct { answer ClientToolAnswer } +func clientToolSourceStep(id string) step { + return func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse(id, "app_note", `{}`), nil + } +} + +// Source contract (a): every submission door and a queued follow-up name their own line. +func TestClientToolsSourceSubmissionDoors(t *testing.T) { + for _, kind := range []string{"plain", "message", "composed", "follow-up"} { + t.Run(kind, func(t *testing.T) { + door := clientToolsFixture() + entered, release := make(chan struct{}), make(chan struct{}) + first := clientToolSourceStep("first") + if kind == "follow-up" { + first = func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return clientToolSourceStep("first")(ctx, messages) + } + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{first, finishingStep(), clientToolSourceStep("second"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var events, next <-chan Event + var err error + switch kind { + case "plain", "follow-up": + events, err = a.Submit(ctx, "first question") + case "message": + events, err = a.SubmitMessage(ctx, Submission{Text: "first question", ClientID: "another-window"}) + case "composed": + _, events, err = a.SubmitComposed(ctx, Submission{Text: "first question"}) + } + if err != nil { + t.Fatal(err) + } + if kind == "follow-up" { + waitSignal(t, entered, "the first provider step before queuing the follow-up") + next, err = a.FollowUp("second question") + close(release) + if err != nil { + t.Fatal(err) + } + } + collect(t, events) + if next == nil { + next = mustSubmit(t, a, "second question") + } + collect(t, next) + var ids []string + for _, entry := range a.Transcript() { + if entry.Role == "user" && entry.Person != nil && entry.Person.Composed != nil { + ids = append(ids, entry.Person.Composed.MessageID) + } + } + if len(ids) != 2 || ids[0] == "" || ids[0] == ids[1] || len(door.calls) != 2 { + t.Fatalf("messages=%v calls=%+v", ids, door.calls) + } + for i, call := range door.calls { + if call.SourceMessage != ids[i] { + t.Errorf("call %d source=%q want %q", i, call.SourceMessage, ids[i]) + } + } + }) + } +} + +// Source contract (b): takeover replay reuses the recorded id without changing crew provenance. +func TestClientToolsSourceStoppedTurnReplay(t *testing.T) { + path := takenOverMidThought(t, func(a *Agent) { a.InterruptFor(StopByTakeover) }) + want := journalPersonIDs(t, path)[0] + door := clientToolsFixture() + var crewOpener string + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + crewOpener = turnOriginFrom(ctx).turn + return clientToolSourceStep("resumed")(ctx, messages) + }, finishingStep(), + }}, func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + events, resumed := a.ResumeStoppedTurn(context.Background()) + if !resumed { + t.Fatal("stopped turn did not resume") + } + collect(t, events) + if len(door.calls) != 1 || door.calls[0].SourceMessage != want || crewOpener != "" { + t.Fatalf("calls=%+v crew opener=%q want message %q", door.calls, crewOpener, want) + } +} + +// Source contract (b), (d): resuming a legacy line never invents a message id. +func TestClientToolsSourceLegacyStoppedTurn(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + journal := `{"type":"session","version":1,"id":"legacy"}` + "\n" + + `{"type":"message","role":"user","content":"old question"}` + "\n" + + `{"type":"error","error":{"door":"taken over","message":"turn ended: taken over"}}` + "\n" + if err := os.WriteFile(path, []byte(journal), 0600); err != nil { + t.Fatal(err) + } + door := clientToolsFixture() + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("legacy"), finishingStep()}}, + func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + events, resumed := a.ResumeStoppedTurn(context.Background()) + if !resumed { + t.Fatal("legacy stopped turn did not resume") + } + collect(t, events) + if len(door.calls) != 1 || door.calls[0].SourceMessage != "" { + t.Fatalf("legacy line gained identity: %+v", door.calls) + } +} + +// Source contract (b), (d): quit and close carry-ons never name a source message, +// even when a person opened the interrupted run in the reopened journal. +func TestClientToolsSourceCarryOnReplay(t *testing.T) { + for _, closeKey := range []bool{false, true} { + for _, wake := range []bool{false, true} { + t.Run(fmt.Sprintf("close=%t/wake=%t", closeKey, wake), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + started := make(chan struct{}) + steps := []step{thinkingStep(started)} + if wake { + steps = append([]step{finishingStep()}, steps...) + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: steps}, func(c *Config) { c.SessionFile = path }) + t.Cleanup(a.Interrupt) + var events <-chan Event + if wake { + collect(t, mustSubmit(t, a, "original question")) + wakes, stop := a.WatchWakes() + defer stop() + if !a.enqueueNote(wakeNote("a job finished")) { + t.Fatal("initial wake note refused") + } + events = awaitWallStream(t, wakes, 10*time.Second) + } else { + events = mustSubmit(t, a, "original question") + } + waitSignal(t, started, "the interrupted provider step") + a.InterruptFor(StopByClosing) + collect(t, events) + if err := a.Close(); err != nil { + t.Fatal(err) + } + ids := journalPersonIDs(t, path) + if len(ids) != 1 { + t.Fatalf("recorded person messages=%v want one", ids) + } + door := clientToolsFixture() + fresh, _ := newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("carry"), finishingStep()}}, + func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + wakes, stop := fresh.WatchWakes() + defer stop() + accepted := false + if closeKey { + accepted = fresh.ResumeAfterCloseKey("continuing") + } else { + accepted = fresh.ResumeAfterQuit() + } + if !accepted { + t.Fatal("carry-on refused") + } + finishJobWake(t, wakes) + if len(door.calls) != 1 || door.calls[0].SourceMessage != "" { + t.Fatalf("carry-on calls=%+v want no source; recorded message=%q", door.calls, ids[0]) + } + }) + } + } +} + +// Source contract (c): only a wake answering one drained person's line names it. +func TestClientToolsSourceWakes(t *testing.T) { + for _, kind := range []string{"note", "one person", "two people", "mixed", "coalesced"} { + t.Run(kind, func(t *testing.T) { + door := clientToolsFixture() + var a *Agent + ending := func(context.Context, []ai.Message) (*ai.Response, error) { + var queued []userMessage + switch kind { + case "note": + queued = []userMessage{wakeNote("a job finished")} + case "one person": + queued = []userMessage{userText("drained question")} + case "two people": + queued = []userMessage{userText("drained question"), userText("another question")} + case "mixed": + queued = []userMessage{userText("drained question"), wakeNote("a job finished")} + case "coalesced": + queued = []userMessage{wakeNote("a job finished"), wakeNote("another job finished")} + } + for i := range queued { + if queued[i].wake { + queued[i].authored = true + } + } + // Land after the final request, through the same queue the end drain reads. + a.mu.Lock() + a.steering = append(a.steering, queued...) + a.mu.Unlock() + return textResponse("first answer"), nil + } + a, _ = newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("person"), ending, clientToolSourceStep("wake"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + wakes, stop := a.WatchWakes() + defer stop() + collect(t, mustSubmit(t, a, "original question")) + finishJobWake(t, wakes) + want := "" + for _, entry := range a.Transcript() { + if kind == "one person" && entry.Text == "drained question" && entry.Person != nil && entry.Person.Composed != nil { + want = entry.Person.Composed.MessageID + } + } + if kind == "one person" && want == "" { + t.Fatal("drained message has no recorded id") + } + if len(door.calls) != 2 || door.calls[0].SourceMessage == "" || door.calls[1].SourceMessage != want { + t.Fatalf("calls=%+v want wake source %q", door.calls, want) + } + }) + } +} + +// Source contract (d): a steer and a runtime note cannot replace a running turn's source. +func TestClientToolsSourceSurvivesSteering(t *testing.T) { + door := clientToolsFixture() + entered, release := make(chan struct{}), make(chan struct{}) + first := func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return clientToolSourceStep("before")(ctx, messages) + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{first, clientToolSourceStep("after"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events, err := a.Submit(ctx, "original question") + if err != nil { + t.Fatal(err) + } + waitSignal(t, entered, "the first provider step before steering") + steer := mustSubmit(t, a, "a correction") + if !a.enqueueNote(wakeNote("a job finished")) { + t.Fatal("runtime note refused") + } + close(release) + collect(t, events) + collect(t, steer) + var ids []string + for _, entry := range a.Transcript() { + if entry.Role == "user" && entry.Person != nil && entry.Person.Composed != nil { + ids = append(ids, entry.Person.Composed.MessageID) + } + } + if len(ids) != 2 || ids[0] == ids[1] || len(door.calls) != 2 { + t.Fatalf("ids=%v calls=%+v", ids, door.calls) + } + for _, call := range door.calls { + if call.SourceMessage != ids[0] { + t.Errorf("source=%q want opener %q, steer=%q", call.SourceMessage, ids[0], ids[1]) + } + } +} + func (d *clientToolsFakeDoor) ClientToolSets() (uint64, []ClientToolSet) { return d.version, d.sets } func (d *clientToolsFakeDoor) CallClientTool(_ context.Context, call ClientToolCall) (ClientToolAnswer, error) { d.calls = append(d.calls, call) @@ -90,6 +365,28 @@ func TestClientToolsExecuteArgumentsAndError(t *testing.T) { } } +// Source contract (a), (d): Execute reads only the immutable call context. +func TestClientToolsExecuteSourceMessage(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + tool := a.appendClientTools(nil)[0] + for _, test := range []struct { + ctx context.Context + want string + }{ + {withTurnOrigin(context.Background(), turnOrigin{turn: "crew-opener", answers: "msg-answer"}), "msg-answer"}, + {withTurnOrigin(context.Background(), turnOrigin{turn: "crew-opener"}), ""}, + {context.Background(), ""}, + } { + if _, failed, err := tool.Execute(test.ctx, json.RawMessage(`{}`)); failed || err != nil { + t.Fatalf("execute: failed=%v err=%v", failed, err) + } + if got := door.calls[len(door.calls)-1].SourceMessage; got != test.want { + t.Fatalf("source=%q want %q", got, test.want) + } + } +} + // Contract (h): classification is per conversation and client tools never start early. func TestClientToolsCategoriesAndBuiltinWins(t *testing.T) { door := clientToolsFixture() @@ -300,7 +597,7 @@ func TestClientToolsReadingCategoriesAndApprovalPolicy(t *testing.T) { } } -// Contract (a), (h): real children and clarification conversations carry no client capability. +// Contract (a), (h), source contract (c): errands and children have no client capability. func TestClientToolsDoNotTravelToChildren(t *testing.T) { door := clientToolsFixture() door.sets[0].Instructions += clientToolSectionEnd + "\nThese are still the app's instructions." @@ -313,6 +610,9 @@ func TestClientToolsDoNotTravelToChildren(t *testing.T) { if len(sets) != 0 || clientToolsSection(config) != "" { t.Fatal("child inherited a set") } + if tools := (&Agent{config: config}).appendClientTools(nil); len(tools) != 0 || len(door.calls) != 0 { + t.Fatal("an errand or child gained a client tool or emitted a call") + } } started := make(chan struct{}) parent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ @@ -330,6 +630,7 @@ func TestClientToolsDoNotTravelToChildren(t *testing.T) { c.AskConsent, c.Interactive = true, true c.ApprovalPolicy = promptAll() }) + t.Cleanup(parent.Interrupt) if parent.config.ClientTools == nil || !strings.Contains(parent.system, "Tools from the app: notes") { t.Fatal("parent lacks the capability this test must exclude") } @@ -351,11 +652,7 @@ func TestClientToolsDoNotTravelToChildren(t *testing.T) { if err := parent.ResolveQuestion(Answer{Kind: q.Kind, ID: q.ID, Clarify: true, AskedBack: []Exchange{{Asked: "why this note?"}}}); err != nil { t.Fatal(err) } - select { - case <-started: - case <-time.After(2 * time.Second): - t.Fatal("clarification did not start") - } + waitSignal(t, started, "the clarification provider step") parent.mu.Lock() clarification := parent.discussions["clarify-1"].child parent.mu.Unlock() diff --git a/internal/session/resume.go b/internal/session/resume.go index 424416f76f..e7f33b7dca 100644 --- a/internal/session/resume.go +++ b/internal/session/resume.go @@ -155,6 +155,9 @@ func (a *Agent) ResumeStoppedTurn(ctx context.Context) (<-chan Event, bool) { resumed := userMessage{message: last, resumed: true} if mark := a.presentation.of(last); mark != nil { resumed.creation = acceptedCreation(mark.Creation) + if mark.Composed != nil { + resumed.sourceMessage = mark.Composed.MessageID + } } events := a.startTurnLocked(ctx, resumed, nil) a.mu.Unlock() diff --git a/internal/session/turn_origin.go b/internal/session/turn_origin.go index 1804318433..98e513e508 100644 --- a/internal/session/turn_origin.go +++ b/internal/session/turn_origin.go @@ -6,26 +6,39 @@ import ( "github.com/Agent-Field/codeaf/internal/teams" ) -// turnOrigin is the two facts that say which turn caused a line of team -// traffic: the run that is writing it and the person's message that OPENED that -// run. It is built once, at the moment a turn starts, and never changed. +// turnOrigin holds the run, the person's message that opened it for team +// traffic, and the message it answers for client tools. It is built once, at +// the moment a turn starts, and never changed. // // IT IS CARRIED ON THE TURN'S CONTEXT AND NEVER READ FROM THE AGENT. A line is // stamped from the context of the call that produced it, or from a copy taken // when a write was queued, so a turn that was abandoned and replaced, a late // callback and a steer that landed mid-turn can never charge their line to the // turn that happens to be current when the write finally runs. The same law as -// [withRunIdentity], one value wider. +// [withRunIdentity], with the message identities beside it. type turnOrigin struct { // run is the turn's run identity, the id its journal records as `usage_run`. run string // turn is the durable id of the person's message that opened the turn, "" // when no person message opened it or none can be named exactly. turn string + // answers is the durable id of the message this turn answers. Resuming an + // answer can name it without changing who opened the run for crew traffic. + answers string } type turnOriginKey struct{} +// turnOriginLocked captures the message being answered before the turn runs. +// A resumed stopped answer reuses its recorded id independently of crew provenance. +func turnOriginLocked(user userMessage, run string) turnOrigin { + origin := turnOrigin{run: run, turn: openingMessage(user), answers: openingMessage(user)} + if user.resumed { + origin.answers = user.sourceMessage + } + return origin +} + // withTurnOrigin hangs origin on ctx for the life of the turn. func withTurnOrigin(ctx context.Context, origin turnOrigin) context.Context { return context.WithValue(ctx, turnOriginKey{}, origin)