From 2af56ae3036cb043502c379c978ae2cc4cec9376 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 21:09:12 -0500 Subject: [PATCH 1/7] 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 --- cmd/codeaf/collections.go | 17 +- cmd/codeaf/collections_factory_job_test.go | 53 +++ internal/appwire/collections.go | 14 +- .../appwire/collections_factory_job_test.go | 41 ++ internal/manual/chat/collections.md | 60 ++- 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 +++++++ 11 files changed, 1037 insertions(+), 245 deletions(-) create mode 100644 cmd/codeaf/collections_factory_job_test.go create mode 100644 internal/appwire/collections_factory_job_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 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/internal/appwire/collections.go b/internal/appwire/collections.go index 579d05e7a6..ac7fa2f466 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"` @@ -252,6 +256,9 @@ func plainText(s string, limit int) bool { // 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 +270,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/manual/chat/collections.md b/internal/manual/chat/collections.md index 0b878f3181..8252b887b9 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,43 @@ 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. + +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. +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) + } +} From 5da1e01c50e02625dc859ed0a324ff4eb31469ef Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 21:09:12 -0500 Subject: [PATCH 2/7] 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 --- internal/workstream/goaheads.go | 203 +++++++ internal/workstream/legacy_test.go | 76 +++ internal/workstream/markdown.go | 87 +++ internal/workstream/pages.go | 512 ++++++++++++++++++ internal/workstream/pages_test.go | 202 +++++++ internal/workstream/plan.go | 147 +++++ internal/workstream/records.go | 223 ++++++++ internal/workstream/records_goaheads_test.go | 240 +++++++++ internal/workstream/store.go | 530 +++++++++++++++++++ internal/workstream/store_test.go | 215 ++++++++ internal/workstream/workstream.go | 233 ++++++++ 11 files changed, 2668 insertions(+) create mode 100644 internal/workstream/goaheads.go create mode 100644 internal/workstream/legacy_test.go create mode 100644 internal/workstream/markdown.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/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/markdown.go b/internal/workstream/markdown.go new file mode 100644 index 0000000000..61b152b56e --- /dev/null +++ b/internal/workstream/markdown.go @@ -0,0 +1,87 @@ +package workstream + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Markdown is an export of the editor JSON. Marks retain their plain text; +// unfamiliar nodes retain their own and their children's text as well. +func Markdown(doc json.RawMessage) (string, error) { + if err := checkDoc(doc); err != nil { + return "", err + } + var root map[string]any + dec := json.NewDecoder(strings.NewReader(string(doc))) + dec.UseNumber() + if err := dec.Decode(&root); err != nil { + return "", err + } + var render func(map[string]any) string + render = func(n map[string]any) string { + kind, _ := n["type"].(string) + text, _ := n["text"].(string) + content, _ := n["content"].([]any) + attrs, _ := n["attrs"].(map[string]any) + switch kind { + case "bulletList", "bullet_list", "orderedList", "ordered_list": + var list strings.Builder + for i, item := range content { + child, ok := item.(map[string]any) + if !ok { + continue + } + prefix := "- " + if kind == "orderedList" || kind == "ordered_list" { + prefix = fmt.Sprintf("%d. ", i+1) + } + lines := strings.Split(strings.TrimSpace(render(child)), "\n") + for j, line := range lines { + if j == 0 { + list.WriteString(prefix) + } else { + list.WriteString(strings.Repeat(" ", len(prefix))) + } + list.WriteString(line) + list.WriteByte('\n') + } + } + return list.String() + "\n" + } + var b strings.Builder + b.WriteString(text) + for _, item := range content { + if child, ok := item.(map[string]any); ok { + b.WriteString(render(child)) + } + } + text = b.String() + switch kind { + case "heading": + number, _ := attrs["level"].(json.Number) + level, _ := number.Int64() + return strings.Repeat("#", int(max(1, min(level, 6)))) + " " + strings.TrimSpace(text) + "\n\n" + case "paragraph": + return text + "\n\n" + case "hardBreak", "hard_break": + return "\n" + case "codeBlock", "code_block": + language, _ := attrs["language"].(string) + fence := "```" + for strings.Contains(text, fence) { + fence += "`" + } + return fence + language + "\n" + strings.TrimRight(text, "\n") + "\n" + fence + "\n\n" + case "blockquote": + lines := strings.Split(strings.TrimSpace(text), "\n") + for i := range lines { + lines[i] = "> " + lines[i] + } + return strings.Join(lines, "\n") + "\n\n" + default: + return text + } + } + return strings.TrimSpace(render(root)), nil +} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go new file mode 100644 index 0000000000..b1281a0251 --- /dev/null +++ b/internal/workstream/pages.go @@ -0,0 +1,512 @@ +package workstream + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "unicode/utf8" +) + +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 = 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..d9569feeee --- /dev/null +++ b/internal/workstream/pages_test.go @@ -0,0 +1,202 @@ +package workstream + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// 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) + } +} + +func TestPartAMarkdownExportSupportsEditorNodes(t *testing.T) { + doc := json.RawMessage(`{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title","marks":[{"type":"bold"}]}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item"}]}]}]},{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"code"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quote"}]}]},{"type":"unknown","content":[{"type":"text","text":"Retained"}]}]}`) + got, err := Markdown(doc) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"## Title", "- Item", "```go\ncode\n```", "> Quote", "Retained"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } +} + +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..4206d4d9ef --- /dev/null +++ b/internal/workstream/plan.go @@ -0,0 +1,147 @@ +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 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") + } + 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..85c196a17f --- /dev/null +++ b/internal/workstream/store.go @@ -0,0 +1,530 @@ +package workstream + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + "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.ContainsRune(s, 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) == "" || !textWithin(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) || !textWithin(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) || !textWithin(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 4c1860acd06d88e8bbb7af0bbc5fdc362c928111 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 21:09:16 -0500 Subject: [PATCH 3/7] 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 --- cmd/codeaf/app.go | 1 + cmd/codeaf/appcollections.go | 40 +- cmd/codeaf/appworkstreams.go | 903 ++++++++++++++++- cmd/codeaf/appworkstreams_test.go | 1043 ++++++++++++++++++-- cmd/codeaf/chatv3_layout.go | 3 + docs/design/app-v3/PROTOCOL.md | 35 +- internal/appwire/workstreams.go | 24 +- internal/appwire/workstreams_check.go | 349 +++++++ internal/appwire/workstreams_check_test.go | 68 ++ 9 files changed, 2325 insertions(+), 141 deletions(-) create mode 100644 internal/appwire/workstreams_check.go create mode 100644 internal/appwire/workstreams_check_test.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..e3b3ed0b27 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -1,108 +1,905 @@ package main -import "github.com/Agent-Field/codeaf/internal/appwire" +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "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/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{} +} + +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 +} -func (b *appBackend) WorkstreamList(appwire.WorkstreamListParams) (appwire.WorkstreamListResult, error) { - return appwire.WorkstreamListResult{}, appwire.ErrWorkstreamsNotYet +// workstreamDo emits only the committed domain receipt. Reads return an empty +// receipt; unchanged and refused mutations therefore say nothing to 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) + if err != nil { + return zero, appWorkstreamError(err) + } + b.workstreamChanged(change) + return out, nil } -func (b *appBackend) WorkstreamGet(appwire.WorkstreamGetParams) (appwire.WorkstreamGetResult, error) { - return appwire.WorkstreamGetResult{}, 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) WorkstreamCreate(appwire.WorkstreamCreateParams) (appwire.WorkstreamCreateResult, error) { - return appwire.WorkstreamCreateResult{}, 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) WorkstreamUpdate(appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { - return appwire.WorkstreamUpdateResult{}, 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) WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams) (appwire.WorkstreamMarkDoneResult, error) { - return appwire.WorkstreamMarkDoneResult{}, 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) WorkstreamReopen(appwire.WorkstreamReopenParams) (appwire.WorkstreamReopenResult, error) { - return appwire.WorkstreamReopenResult{}, 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) WorkstreamPlanGet(appwire.WorkstreamPlanGetParams) (appwire.WorkstreamPlanGetResult, error) { - return appwire.WorkstreamPlanGetResult{}, 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 { + w, err = b.workstreamObserved(s, w, facts) + 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) WorkstreamPlanSet(appwire.WorkstreamPlanSetParams) (appwire.WorkstreamPlanSetResult, error) { - return appwire.WorkstreamPlanSetResult{}, 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, err = b.workstreamObserved(s, w, facts) + return appwire.WorkstreamGetResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, workstream.Change{}, err + }) } -func (b *appBackend) WorkstreamPageList(appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { - return appwire.WorkstreamPageListResult{}, 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) WorkstreamPageGet(appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { - return appwire.WorkstreamPageGetResult{}, 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) WorkstreamPageSave(appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { - return appwire.WorkstreamPageSaveResult{}, 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) WorkstreamPageCreate(appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { - return appwire.WorkstreamPageCreateResult{}, appwire.ErrWorkstreamsNotYet +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 && c.ID != "" && p.Patch.Name != nil { + b.collectionChanged(w.ID, false) + } + return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, c, err + }) } -func (b *appBackend) WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams) (appwire.WorkstreamPageUpdateResult, error) { - return appwire.WorkstreamPageUpdateResult{}, appwire.ErrWorkstreamsNotYet +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 + } + // 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 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) + } + } + return appwire.WorkstreamMarkDoneResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, workstream.Change{}, err + }) } -func (b *appBackend) WorkstreamPageMove(appwire.WorkstreamPageMoveParams) (appwire.WorkstreamPageMoveResult, error) { - return appwire.WorkstreamPageMoveResult{}, appwire.ErrWorkstreamsNotYet +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) + return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, c, err + }) } -func (b *appBackend) WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams) (appwire.WorkstreamPageDeleteResult, error) { - return appwire.WorkstreamPageDeleteResult{}, appwire.ErrWorkstreamsNotYet +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) WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams) (appwire.WorkstreamPageHistoryResult, error) { - return appwire.WorkstreamPageHistoryResult{}, 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) workstreamQueueRead { + state := &b.workstreams + state.cacheMu.Lock() + if entry, ok := state.cache[factory]; ok && time.Since(entry.at) < workstreamFactoryFresh { + state.cacheMu.Unlock() + return entry + } + if flight := state.flights[factory]; flight != nil { + state.cacheMu.Unlock() + select { + case <-flight: + return b.workstreamQueue(ctx, factory) + case <-ctx.Done(): + return workstreamQueueRead{err: ctx.Err()} + } + } + if state.flights == nil { + state.flights = map[string]chan struct{}{} + } + flight := make(chan struct{}) + state.flights[factory] = flight + state.cacheMu.Unlock() + read := state.queue + if read == nil { + read = b.FactoryQueue + } + queue, err := read(ctx, factory) + entry := workstreamQueueRead{at: time.Now(), err: err, jobs: map[int]api.Job{}} + for _, j := range queue.Jobs { + entry.jobs[j.ID] = j + } + state.cacheMu.Lock() + 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) + state.cacheMu.Unlock() + return entry } -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 + slots := make(chan struct{}, workstreamFactoryParallel) + for factory, members := range grouped { + wg.Add(1) + go 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 { + mu.Lock() + allowed := missing < workstreamMissingJobsMax + if allowed { + missing++ + } + mu.Unlock() + 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 + mu.Lock() + out[j] = workstreamJobRead{job: job, err: err, notFound: notFound} + mu.Unlock() + } + }() + } + 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 +func (b *appBackend) workstreamObserved(s *workstream.Store, w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts) (workstream.Workstream, error) { + if !w.StartedAt.IsZero() { + return w, 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, nil + } + c, err := s.ObserveJobStates(context.Background(), w.ID, states) + if err != nil { + return w, appWorkstreamError(err) + } + b.workstreamChanged(c) + // Another reader may have won the write, so reread even with an empty receipt. + w, err = s.Get(context.Background(), w.ID) + return w, 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..66f6424041 100644 --- a/cmd/codeaf/appworkstreams_test.go +++ b/cmd/codeaf/appworkstreams_test.go @@ -1,142 +1,1037 @@ package main import ( + "bytes" + "context" + "encoding/json" "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "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/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) + go 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" + b.workstreams.cacheMu.Lock() + b.workstreams.cache = nil + b.workstreams.cacheMu.Unlock() + 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) + } +} + +// (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 + }}, + {"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.supersede", func() error { - _, err := backend.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{}) + {"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 }}, - {"goahead.grant", func() error { - _, err := backend.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{}) + {"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 }}, - {"goahead.revoke", func() error { - _, err := backend.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{}) + {"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 }}, } - 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) + 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 wireErr.Message != "workstreams: not implemented yet" { - t.Errorf("message = %s", wireErr.Message) + 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{} + b.workstreams.queue = func(ctx context.Context, factory string) (api.Queue, error) { + mu.Lock() + calls[factory]++ + mu.Unlock() + 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) + go 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) + } +} + +// 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/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index ebaa8eb44c..cd01df67c4 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,25 @@ 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. +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/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..dd60ca2c19 --- /dev/null +++ b/internal/appwire/workstreams_check.go @@ -0,0 +1,349 @@ +package appwire + +import ( + "bytes" + "encoding/json" + "math" + "reflect" + "strings" + "time" + "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) || strings.ContainsRune(text, 0) || len(text) < min || len(text) > max || (min > 0 && strings.TrimSpace(text) == "") { + return Errorf(CodeBadParams, "%s is %d to %d bytes", field, min, max) + } + return nil +} + +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), workstreamOptionalText(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"), workstreamOptionalText(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 := workstreamText(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), workstreamOptionalText(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), workstreamText(*item.Title, 1, 200, "draft title")); 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), workstreamText(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), workstreamOptionalText(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..4013c078bf --- /dev/null +++ b/internal/appwire/workstreams_check_test.go @@ -0,0 +1,68 @@ +package appwire + +import ( + "encoding/json" + "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) + } + } +} From 86d24650081d5965e7906639ad626a7ba869c8f8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 21:09:43 -0500 Subject: [PATCH 4/7] docs: change entry for the workstream store Co-Authored-By: Claude Opus 5.5 --- .../unreleased/2270-app-workstreams-store.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/changes/unreleased/2270-app-workstreams-store.md 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..3bc76f1ae2 --- /dev/null +++ b/docs/changes/unreleased/2270-app-workstreams-store.md @@ -0,0 +1,18 @@ +--- +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. From 750cac730ae44ffbb856812388510195f8fa598c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 21:35:10 -0500 Subject: [PATCH 5/7] 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 --- cmd/codeaf/appworkstreams.go | 91 ++++++++++++------ cmd/codeaf/appworkstreams_test.go | 94 +++++++++++++++++-- .../unreleased/2270-app-workstreams-store.md | 4 + docs/design/app-v3/PROTOCOL.md | 5 + internal/appwire/collections.go | 6 +- internal/appwire/workstreams_check.go | 39 ++++++-- internal/appwire/workstreams_check_test.go | 69 ++++++++++++++ internal/manual/chat/collections.md | 5 + internal/workstream/plan.go | 9 ++ internal/workstream/store.go | 13 ++- 10 files changed, 286 insertions(+), 49 deletions(-) diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index e3b3ed0b27..39ceda5c23 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -13,6 +13,7 @@ import ( "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" @@ -362,10 +363,14 @@ func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire 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 && c.ID != "" && p.Patch.Name != nil { + if err != nil { + return appwire.WorkstreamUpdateResult{}, c, err + } + if c.ID != "" && p.Patch.Name != nil { b.collectionChanged(w.ID, false) } - return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, c, err + facts := b.workstreamFacts(w.Jobs()) + return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, nil }) } @@ -394,14 +399,22 @@ func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (app err = archive(ids) } } - return appwire.WorkstreamMarkDoneResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, workstream.Change{}, err + if err != nil { + return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err + } + facts := b.workstreamFacts(w.Jobs()) + 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) - return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, c, err + if err != nil { + return appwire.WorkstreamReopenResult{}, c, err + } + facts := b.workstreamFacts(w.Jobs()) + return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, nil }) } @@ -597,13 +610,11 @@ func (b *appBackend) WorkstreamGoAheadRevoke(p appwire.WorkstreamGoAheadRevokePa // in-flight read, so concurrent list/get calls cannot multiply Factory work. func (b *appBackend) workstreamQueue(ctx context.Context, factory string) workstreamQueueRead { state := &b.workstreams - state.cacheMu.Lock() - if entry, ok := state.cache[factory]; ok && time.Since(entry.at) < workstreamFactoryFresh { - state.cacheMu.Unlock() + entry, flight, readQueue := state.workstreamQueueStart(factory) + if flight == nil { return entry } - if flight := state.flights[factory]; flight != nil { - state.cacheMu.Unlock() + if !readQueue { select { case <-flight: return b.workstreamQueue(ctx, factory) @@ -611,22 +622,39 @@ func (b *appBackend) workstreamQueue(ctx context.Context, factory string) workst return workstreamQueueRead{err: ctx.Err()} } } - if state.flights == nil { - state.flights = map[string]chan struct{}{} - } - flight := make(chan struct{}) - state.flights[factory] = flight - state.cacheMu.Unlock() read := state.queue if read == nil { read = b.FactoryQueue } queue, err := read(ctx, factory) - entry := workstreamQueueRead{at: time.Now(), err: err, jobs: map[int]api.Job{}} + entry = workstreamQueueRead{at: time.Now(), err: err, jobs: map[int]api.Job{}} for _, j := range queue.Jobs { entry.jobs[j.ID] = j } + state.workstreamQueueFinish(factory, flight, entry) + return entry +} + +func (state *appWorkstreams) workstreamQueueStart(factory string) (workstreamQueueRead, chan struct{}, bool) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if entry, ok := state.cache[factory]; ok && time.Since(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{} } @@ -643,8 +671,6 @@ func (b *appBackend) workstreamQueue(ctx context.Context, factory string) workst state.cache[factory] = entry delete(state.flights, factory) close(flight) - state.cacheMu.Unlock() - return entry } type workstreamJobRead struct { @@ -672,10 +698,24 @@ func (b *appBackend) workstreamJobReads(jobs []workstream.Job) map[workstream.Jo 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) - go func() { + guard.Go("app/workstream-factory", func() { defer wg.Done() select { case slots <- struct{}{}: @@ -688,12 +728,7 @@ func (b *appBackend) workstreamJobReads(jobs []workstream.Job) map[workstream.Jo job, ok := queue.jobs[j.Number] err := queue.err if err == nil && !ok { - mu.Lock() - allowed := missing < workstreamMissingJobsMax - if allowed { - missing++ - } - mu.Unlock() + allowed := workstreamMissingAllowed() switch { case !allowed: err = errors.New("the missing-job read allowance was reached") @@ -712,11 +747,9 @@ func (b *appBackend) workstreamJobReads(jobs []workstream.Job) map[workstream.Jo } var wire *appwire.Error notFound := queue.err == nil && errors.As(err, &wire) && wire.Code == appwire.CodeNotFound - mu.Lock() - out[j] = workstreamJobRead{job: job, err: err, notFound: notFound} - mu.Unlock() + workstreamRememberRead(j, workstreamJobRead{job: job, err: err, notFound: notFound}) } - }() + }) } wg.Wait() return out diff --git a/cmd/codeaf/appworkstreams_test.go b/cmd/codeaf/appworkstreams_test.go index 66f6424041..1a9b650ba8 100644 --- a/cmd/codeaf/appworkstreams_test.go +++ b/cmd/codeaf/appworkstreams_test.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -89,7 +90,12 @@ func TestAppWorkstreamCreateIsIdempotent(t *testing.T) { errs := make(chan error, 8) for range 8 { wg.Add(1) - go func() { defer wg.Done(); v, err := b.WorkstreamCreate(ask); results <- v; errs <- err }() + guard.Go("app/workstream-create-test", func() { + defer wg.Done() + v, err := b.WorkstreamCreate(ask) + results <- v + errs <- err + }) } wg.Wait() close(results) @@ -211,9 +217,11 @@ func TestAppWorkstreamStageAndMeasuredNumbers(t *testing.T) { } before := len(workstreamEvents(rec)) state = "queued" - b.workstreams.cacheMu.Lock() - b.workstreams.cache = nil - b.workstreams.cacheMu.Unlock() + 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) @@ -243,6 +251,75 @@ func TestAppWorkstreamStageAndMeasuredNumbers(t *testing.T) { } } +// (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, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil { + t.Fatal(err) + } + 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.Workstream = 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.Workstream) || len(workstreamEvents(rec)) != events) { + t.Fatal("marking an already done workstream changed it or emitted an event") + } + }) + } +} + // (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) { @@ -833,10 +910,13 @@ func TestAppWorkstreamFactoryBatchCacheAndCollectionTargets(t *testing.T) { release := make(chan struct{}) var mu sync.Mutex calls := map[string]int{} - b.workstreams.queue = func(ctx context.Context, factory string) (api.Queue, error) { + workstreamRecordCall := func(factory string) { mu.Lock() + defer mu.Unlock() calls[factory]++ - mu.Unlock() + } + b.workstreams.queue = func(ctx context.Context, factory string) (api.Queue, error) { + workstreamRecordCall(factory) entered <- factory select { case <-release: @@ -850,7 +930,7 @@ func TestAppWorkstreamFactoryBatchCacheAndCollectionTargets(t *testing.T) { 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) - go func() { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); done <- err }() + guard.Go("app/workstream-list-test", func() { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); done <- err }) for range 2 { select { case <-entered: diff --git a/docs/changes/unreleased/2270-app-workstreams-store.md b/docs/changes/unreleased/2270-app-workstreams-store.md index 3bc76f1ae2..ab20ac9d24 100644 --- a/docs/changes/unreleased/2270-app-workstreams-store.md +++ b/docs/changes/unreleased/2270-app-workstreams-store.md @@ -16,3 +16,7 @@ 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. Marking an already done workstream remains unchanged. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index cd01df67c4..bc8348c8bd 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -1522,6 +1522,11 @@ 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. An already done workstream remains unchanged on mark_done. +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. diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go index ac7fa2f466..4f0d750963 100644 --- a/internal/appwire/collections.go +++ b/internal/appwire/collections.go @@ -250,7 +250,11 @@ 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 diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go index dd60ca2c19..c549a4312e 100644 --- a/internal/appwire/workstreams_check.go +++ b/internal/appwire/workstreams_check.go @@ -7,6 +7,7 @@ import ( "reflect" "strings" "time" + "unicode" "unicode/utf8" "github.com/Agent-Field/codeaf/internal/workspace" @@ -76,12 +77,29 @@ func workstreamOptionalID(id *string) error { } func workstreamText(text string, min, max int, field string) error { - if !utf8.ValidString(text) || strings.ContainsRune(text, 0) || len(text) < min || len(text) > max || (min > 0 && strings.TrimSpace(text) == "") { + 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 @@ -150,7 +168,7 @@ func workstreamString(p *string) string { } func (p WorkstreamSource) Check() error { - if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.Label, 0, 200, "source label")); err != nil { + if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Label, 0, 200, "source label")); err != nil { return err } switch p.Kind { @@ -198,7 +216,7 @@ func (p WorkstreamCreateParams) Check() error { if _, err := collectionName(p.Name); err != nil { return err } - if err := workstreamChecks(workstreamOptionalText(p.Why, 0, 4000, "why"), workstreamOptionalText(p.ClientID, 0, 128, "client_id"), workstreamOptionalID(p.OriginChatID), workstreamOptionalID(p.Model)); err != nil { + 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 { @@ -243,7 +261,7 @@ func (p WorkstreamPlanSetParams) Check() error { return err } } - if err := workstreamText(m.Title, 1, 200, "milestone title"); err != nil { + if err := workstreamLine(m.Title, 1, 200, "milestone title"); err != nil { return err } } @@ -253,7 +271,7 @@ func (p WorkstreamPlanSetParams) Check() error { return Errorf(CodeBadParams, "waves must be positive, unique, ascending and have items arrays") } last = w.N - if err := workstreamChecks(workstreamOptionalID(w.MilestoneID), workstreamOptionalText(w.Title, 0, 200, "wave title")); err != nil { + if err := workstreamChecks(workstreamOptionalID(w.MilestoneID), workstreamOptionalLine(w.Title, 0, 200, "wave title")); err != nil { return err } for _, item := range w.Items { @@ -275,9 +293,14 @@ func (p WorkstreamPlanSetParams) Check() error { if item.DraftID == nil || item.Title == nil { return Errorf(CodeBadParams, "draft requires draft_id and title") } - if err := workstreamChecks(workstreamID(*item.DraftID), workstreamText(*item.Title, 1, 200, "draft title")); err != nil { + 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) } @@ -293,10 +316,10 @@ func (p WorkstreamPageSaveParams) Check() error { 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), workstreamText(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)) } func (p WorkstreamPageUpdateParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent")) + 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)) diff --git a/internal/appwire/workstreams_check_test.go b/internal/appwire/workstreams_check_test.go index 4013c078bf..117f128e14 100644 --- a/internal/appwire/workstreams_check_test.go +++ b/internal/appwire/workstreams_check_test.go @@ -2,6 +2,7 @@ package appwire import ( "encoding/json" + "fmt" "strings" "testing" ) @@ -66,3 +67,71 @@ func TestWorkstreamBoundsPreserveExplicitZeroAndFalse(t *testing.T) { } } } + +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 8252b887b9..25ac92b8b0 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -152,6 +152,9 @@ 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 @@ -162,5 +165,7 @@ 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 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/workstream/plan.go b/internal/workstream/plan.go index 4206d4d9ef..b3b0443243 100644 --- a/internal/workstream/plan.go +++ b/internal/workstream/plan.go @@ -102,6 +102,9 @@ func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []M 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") } @@ -115,6 +118,12 @@ func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []M 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") } diff --git a/internal/workstream/store.go b/internal/workstream/store.go index 85c196a17f..1d7ecebf7b 100644 --- a/internal/workstream/store.go +++ b/internal/workstream/store.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "time" + "unicode" "unicode/utf8" "github.com/Agent-Field/codeaf/internal/workspace" @@ -64,7 +65,11 @@ func nullInt(n int) any { } 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.ContainsRune(s, 0) + 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 == "" { @@ -76,7 +81,7 @@ func actor(by string) (string, error) { return by, nil } func validTitle(title string) error { - if strings.TrimSpace(title) == "" || !textWithin(title, 200) { + if strings.TrimSpace(title) == "" || !lineWithin(title, 200) { return invalid("title must be 1–200 bytes") } return nil @@ -85,7 +90,7 @@ func sourceJSON(source *Source) (any, error) { if source == nil { return nil, nil } - if source.ID == "" || !textWithin(source.ID, 4096) || !textWithin(source.Label, 200) { + 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 { @@ -246,7 +251,7 @@ func (s *Store) Create(ctx context.Context, p Create) (Workstream, Change, error if err := workspace.ValidateName(p.Name); err != nil { return Workstream{}, Change{}, err } - if !textWithin(p.Why, 4000) || !textWithin(p.ClientID, 128) || len(p.Refs) > 200 { + 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...) From 5d2d994f42b2514e140466a005e57545fa9d8fd7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 22:15:16 -0500 Subject: [PATCH 6/7] 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 --- internal/workstream/markdown.go | 87 ------------------------------- internal/workstream/pages.go | 4 +- internal/workstream/pages_test.go | 29 ++++++++--- 3 files changed, 25 insertions(+), 95 deletions(-) delete mode 100644 internal/workstream/markdown.go diff --git a/internal/workstream/markdown.go b/internal/workstream/markdown.go deleted file mode 100644 index 61b152b56e..0000000000 --- a/internal/workstream/markdown.go +++ /dev/null @@ -1,87 +0,0 @@ -package workstream - -import ( - "encoding/json" - "fmt" - "strings" -) - -// Markdown is an export of the editor JSON. Marks retain their plain text; -// unfamiliar nodes retain their own and their children's text as well. -func Markdown(doc json.RawMessage) (string, error) { - if err := checkDoc(doc); err != nil { - return "", err - } - var root map[string]any - dec := json.NewDecoder(strings.NewReader(string(doc))) - dec.UseNumber() - if err := dec.Decode(&root); err != nil { - return "", err - } - var render func(map[string]any) string - render = func(n map[string]any) string { - kind, _ := n["type"].(string) - text, _ := n["text"].(string) - content, _ := n["content"].([]any) - attrs, _ := n["attrs"].(map[string]any) - switch kind { - case "bulletList", "bullet_list", "orderedList", "ordered_list": - var list strings.Builder - for i, item := range content { - child, ok := item.(map[string]any) - if !ok { - continue - } - prefix := "- " - if kind == "orderedList" || kind == "ordered_list" { - prefix = fmt.Sprintf("%d. ", i+1) - } - lines := strings.Split(strings.TrimSpace(render(child)), "\n") - for j, line := range lines { - if j == 0 { - list.WriteString(prefix) - } else { - list.WriteString(strings.Repeat(" ", len(prefix))) - } - list.WriteString(line) - list.WriteByte('\n') - } - } - return list.String() + "\n" - } - var b strings.Builder - b.WriteString(text) - for _, item := range content { - if child, ok := item.(map[string]any); ok { - b.WriteString(render(child)) - } - } - text = b.String() - switch kind { - case "heading": - number, _ := attrs["level"].(json.Number) - level, _ := number.Int64() - return strings.Repeat("#", int(max(1, min(level, 6)))) + " " + strings.TrimSpace(text) + "\n\n" - case "paragraph": - return text + "\n\n" - case "hardBreak", "hard_break": - return "\n" - case "codeBlock", "code_block": - language, _ := attrs["language"].(string) - fence := "```" - for strings.Contains(text, fence) { - fence += "`" - } - return fence + language + "\n" + strings.TrimRight(text, "\n") + "\n" + fence + "\n\n" - case "blockquote": - lines := strings.Split(strings.TrimSpace(text), "\n") - for i := range lines { - lines[i] = "> " + lines[i] - } - return strings.Join(lines, "\n") + "\n\n" - default: - return text - } - } - return strings.TrimSpace(render(root)), nil -} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go index b1281a0251..cc3a238bb2 100644 --- a/internal/workstream/pages.go +++ b/internal/workstream/pages.go @@ -6,6 +6,8 @@ import ( "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=?` @@ -99,7 +101,7 @@ func (s *Store) Page(ctx context.Context, id string, markdown bool) (Page, error }) if err == nil && markdown { var md string - md, err = Markdown(p.Doc) + md, err = pagedoc.Markdown(p.Doc) p.Markdown = &md } return p, err diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go index d9569feeee..595ac51678 100644 --- a/internal/workstream/pages_test.go +++ b/internal/workstream/pages_test.go @@ -10,6 +10,7 @@ import ( "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; @@ -156,16 +157,30 @@ func TestPartAPlanMembershipVersionsAndDone(t *testing.T) { } } -func TestPartAMarkdownExportSupportsEditorNodes(t *testing.T) { - doc := json.RawMessage(`{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title","marks":[{"type":"bold"}]}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item"}]}]}]},{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"code"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quote"}]}]},{"type":"unknown","content":[{"type":"text","text":"Retained"}]}]}`) - got, err := Markdown(doc) +// 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) } - for _, want := range []string{"## Title", "- Item", "```go\ncode\n```", "> Quote", "Retained"} { - if !strings.Contains(got, want) { - t.Fatalf("missing %q in %q", want, got) - } + 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) } } From 003fddd36de49f123b63eb2f9dddfb17981e6d57 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Nov 2026 23:12:21 -0500 Subject: [PATCH 7/7] 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 --- cmd/codeaf/appworkstreams.go | 73 +++++-- cmd/codeaf/appworkstreams_test.go | 206 +++++++++++++++++- .../unreleased/2270-app-workstreams-store.md | 7 +- docs/design/app-v3/PROTOCOL.md | 6 +- internal/manual/chat/collections.md | 4 +- 5 files changed, 263 insertions(+), 33 deletions(-) diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 39ceda5c23..138e9753de 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -61,8 +62,8 @@ func (b *appBackend) workstreamStore(create bool) (*workstream.Store, error) { return workstream.New(s, workstream.Options{Now: b.now}), nil } -// workstreamDo emits only the committed domain receipt. Reads return an empty -// receipt; unchanged and refused mutations therefore say nothing to watchers. +// 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 { @@ -76,10 +77,11 @@ func workstreamDo[P interface{ Check() error }, R any](b *appBackend, p P, call 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) } - b.workstreamChanged(change) return out, nil } @@ -177,7 +179,9 @@ func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.Wor } facts := b.workstreamFacts(jobs) for _, w := range rows { - w, err = b.workstreamObserved(s, w, facts) + var c workstream.Change + w, c, err = b.workstreamObserved(s, w, facts, workstream.Change{}) + b.workstreamChanged(c) if err != nil { return appwire.WorkstreamListResult{}, err } @@ -194,8 +198,8 @@ func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.Works return appwire.WorkstreamGetResult{}, workstream.Change{}, err } facts := b.workstreamFacts(w.Jobs()) - w, err = b.workstreamObserved(s, w, facts) - return appwire.WorkstreamGetResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, workstream.Change{}, err + w, c, err := b.workstreamObserved(s, w, facts, workstream.Change{}) + return appwire.WorkstreamGetResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err }) } @@ -370,7 +374,8 @@ func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire b.collectionChanged(w.ID, false) } facts := b.workstreamFacts(w.Jobs()) - return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, nil + w, c, err = b.workstreamObserved(s, w, facts, c) + return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err }) } @@ -380,10 +385,15 @@ func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (app 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 { @@ -402,7 +412,6 @@ func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (app if err != nil { return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err } - facts := b.workstreamFacts(w.Jobs()) return appwire.WorkstreamMarkDoneResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, workstream.Change{}, nil }) } @@ -414,7 +423,8 @@ func (b *appBackend) WorkstreamReopen(p appwire.WorkstreamReopenParams) (appwire return appwire.WorkstreamReopenResult{}, c, err } facts := b.workstreamFacts(w.Jobs()) - return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, nil + w, c, err = b.workstreamObserved(s, w, facts, c) + return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err }) } @@ -608,9 +618,9 @@ func (b *appBackend) WorkstreamGoAheadRevoke(p appwire.WorkstreamGoAheadRevokePa // 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) workstreamQueueRead { +func (b *appBackend) workstreamQueue(ctx context.Context, factory string) (entry workstreamQueueRead) { state := &b.workstreams - entry, flight, readQueue := state.workstreamQueueStart(factory) + entry, flight, readQueue := state.workstreamQueueStart(factory, b.now()) if flight == nil { return entry } @@ -622,23 +632,30 @@ func (b *appBackend) workstreamQueue(ctx context.Context, factory string) workst 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) - entry = workstreamQueueRead{at: time.Now(), err: err, jobs: map[int]api.Job{}} + jobs := map[int]api.Job{} for _, j := range queue.Jobs { - entry.jobs[j.ID] = j + jobs[j.ID] = j } - state.workstreamQueueFinish(factory, flight, entry) + entry = workstreamQueueRead{err: err, jobs: jobs} return entry } -func (state *appWorkstreams) workstreamQueueStart(factory string) (workstreamQueueRead, chan struct{}, bool) { +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 && time.Since(entry.at) < workstreamFactoryFresh { + if entry, ok := state.cache[factory]; ok && now.Sub(entry.at) < workstreamFactoryFresh { return entry, nil, false } if flight := state.flights[factory]; flight != nil { @@ -816,9 +833,11 @@ func (r *collectionResolver) prepareWorkstreamTree(level workspace.Level) { r.prepareWorkstreamRefs(members) } -func (b *appBackend) workstreamObserved(s *workstream.Store, w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts) (workstream.Workstream, error) { +// 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, nil + return w, c, nil } states := map[workstream.Job]string{} active := false @@ -830,16 +849,24 @@ func (b *appBackend) workstreamObserved(s *workstream.Store, w workstream.Workst } } if !active { - return w, nil + return w, c, nil } - c, err := s.ObserveJobStates(context.Background(), w.ID, states) + observed, err := s.ObserveJobStates(context.Background(), w.ID, states) if err != nil { - return w, appWorkstreamError(err) + 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) + } + } } - b.workstreamChanged(c) // Another reader may have won the write, so reread even with an empty receipt. w, err = s.Get(context.Background(), w.ID) - return w, appWorkstreamError(err) + return w, c, appWorkstreamError(err) } func (b *appBackend) workstreamSpend(id string) *float64 { diff --git a/cmd/codeaf/appworkstreams_test.go b/cmd/codeaf/appworkstreams_test.go index 1a9b650ba8..7351dc9d41 100644 --- a/cmd/codeaf/appworkstreams_test.go +++ b/cmd/codeaf/appworkstreams_test.go @@ -12,6 +12,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "testing" "time" @@ -267,16 +268,13 @@ func TestAppWorkstreamMutationNumbersMatchGet(t *testing.T) { if _, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Ready?"}); err != nil { t.Fatal(err) } - before, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if 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.Workstream = done.Workstream + before = done.Workstream } events := len(workstreamEvents(rec)) var result appwire.Workstream @@ -313,13 +311,82 @@ func TestAppWorkstreamMutationNumbersMatchGet(t *testing.T) { if method == "reopen" && result.Stage != "building" { t.Fatalf("reopen stage = %q", result.Stage) } - if method == "mark_done_again" && (!reflect.DeepEqual(result, before.Workstream) || len(workstreamEvents(rec)) != events) { + 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) { @@ -966,6 +1033,133 @@ func TestAppWorkstreamFactoryBatchCacheAndCollectionTargets(t *testing.T) { } } +// 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) { diff --git a/docs/changes/unreleased/2270-app-workstreams-store.md b/docs/changes/unreleased/2270-app-workstreams-store.md index ab20ac9d24..1966231869 100644 --- a/docs/changes/unreleased/2270-app-workstreams-store.md +++ b/docs/changes/unreleased/2270-app-workstreams-store.md @@ -19,4 +19,9 @@ 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. Marking an already done workstream remains unchanged. +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 bc8348c8bd..2a7927952d 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -1523,7 +1523,11 @@ 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. An already done workstream remains unchanged on mark_done. +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. diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 25ac92b8b0..825d277aff 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -165,7 +165,7 @@ 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 return those numbers using the same reads -as getting the workstream. +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.