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) + } + } +}