From 89f24a46f860280c9dcf0756f570ed7cf12ab0c6 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 20:01:12 -0500 Subject: [PATCH 1/3] app: workstream wire types and handlers (contract v0) The Workstreams plan, WP-0: the 25 workstream, page, record and goahead methods and the workstream.changed event, registered through typed() and delegating to an optional Workstreamer. Hello carries the workstreams feature only when the engine says it serves them, so a build without the store never announces it. PROTOCOL.md indexes every method and the event. Co-Authored-By: Claude Opus 5.5 --- docs/design/app-v3/PROTOCOL.md | 56 +++ internal/appwire/server.go | 1 + internal/appwire/workstreams.go | 556 +++++++++++++++++++++++++++ internal/appwire/workstreams_test.go | 246 ++++++++++++ 4 files changed, 859 insertions(+) create mode 100644 internal/appwire/workstreams.go create mode 100644 internal/appwire/workstreams_test.go 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 f2edb57765184aea084ff733a0aabb438248cba4 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 20:01:13 -0500 Subject: [PATCH 2/3] app: the engine answers every workstream method not implemented yet appBackend implements Workstreamer with stubs that answer unknown_method "workstreams: not implemented yet" and does not serve workstreams, so the feature stays off until the store lands (WP-1). Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 108 +++++++++++++++++++++++ cmd/codeaf/appworkstreams_test.go | 142 ++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 cmd/codeaf/appworkstreams.go create mode 100644 cmd/codeaf/appworkstreams_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) + } + }) + } +} From 27c5c38f6cc5cb2ce56f65d3d3b926e0e90fe655 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 20:01:41 -0500 Subject: [PATCH 3/3] docs: change entry for the workstream wire contract Co-Authored-By: Claude Opus 5.5 --- .../unreleased/2263-app-workstreams-contract.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/changes/unreleased/2263-app-workstreams-contract.md 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.