From 769d259c2e14fa38cf1944268da20d8ac4b9894d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 17:55:23 -0500 Subject: [PATCH 1/5] collections: schema 7 keeps every collection and Factory job ref, drops development tables The collections store now writes schema 7: collections and memberships in the generic shape (factory_job refs with their factory, five-column UNIQUE), and nothing else. Versions 1-7 are read as they stand; the first write migrates inside its own transaction. Schemas 1 and 2 are copied into the generic tables; schemas 3-6, written only by development builds, lose their extra tables, indexes and triggers while every collection, membership and ref stays. A failed first write leaves the file as it was; 8+ is refused. Co-Authored-By: Claude Opus 5.5 --- internal/workspace/schema3_test.go | 247 -------- internal/workspace/schema4_test.go | 111 ---- internal/workspace/schema6_test.go | 228 ------- internal/workspace/schema7.go | 32 + internal/workspace/schema7_test.go | 574 ++++++++++++++++++ internal/workspace/store.go | 242 +++----- internal/workspace/views.go | 6 +- internal/workspace/workspace.go | 5 +- internal/workspace/workstream_sources.go | 36 -- internal/workspace/workstream_sources_test.go | 155 ----- internal/workspace/workstreams.go | 298 --------- .../workspace/workstreams_fields_v02_test.go | 44 -- internal/workspace/workstreams_test.go | 162 ----- 13 files changed, 690 insertions(+), 1450 deletions(-) delete mode 100644 internal/workspace/schema4_test.go delete mode 100644 internal/workspace/schema6_test.go create mode 100644 internal/workspace/schema7.go create mode 100644 internal/workspace/schema7_test.go delete mode 100644 internal/workspace/workstream_sources.go delete mode 100644 internal/workspace/workstream_sources_test.go delete mode 100644 internal/workspace/workstreams.go delete mode 100644 internal/workspace/workstreams_fields_v02_test.go delete mode 100644 internal/workspace/workstreams_test.go diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go index 403d7f026d..fdb8483631 100644 --- a/internal/workspace/schema3_test.go +++ b/internal/workspace/schema3_test.go @@ -8,8 +8,6 @@ import ( "reflect" "strings" "testing" - - "github.com/Agent-Field/codeaf/internal/sqlitefile" ) // Part A (a): this literal is schema 2, independent of the schema being written. @@ -124,12 +122,6 @@ func TestPartASchemaTwoReadsWithoutWritingAndMigratesAllRows(t *testing.T) { if !reflect.DeepEqual(memberTimes, [][2]int64{{2, 4567}, {6, 5678}, {11, 6789}}) { t.Fatalf("membership times changed: %+v", memberTimes) } - for _, name := range []string{"workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans", "workstream_wakes", "workstream_wake_items", "workstream_sources"} { - var n int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n); err != nil || n != 1 { - t.Fatalf("missing %s: %v", name, err) - } - } if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "17", Factory: "factory-a"}); err != nil { t.Fatal(err) } @@ -212,242 +204,3 @@ DELETE FROM memberships WHERE ref_id='gone';`); err != nil { }) } } - -// WP-F1 (b), Storage: schema 3 is amended in place; either missing deletion -// column identifies an obsolete schema-3 file and must fail at Open. -func TestWorkstreamSchemaThreeRequiresDeletionColumns(t *testing.T) { - for _, column := range []string{"deleted_at", "deleted_root"} { - t.Run(column, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "obsolete.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Schema three") - workstreamSchemaThreeFixture(t, s) - if userVersion(t, s) != 3 { - t.Fatal("fixture is not schema 3") - } - if _, err := s.db.Exec("ALTER TABLE workstream_pages DROP COLUMN " + column); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - _ = opened.Close() - } - if err == nil || !strings.Contains(err.Error(), column) { - t.Fatalf("obsolete schema 3 opened without naming %s: %v", column, err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatalf("refusal changed obsolete schema 3: %v", err) - } - }) - } -} - -func workstreamSchemaTwoWriter(t *testing.T, path string) *sql.Conn { - t.Helper() - db, err := sql.Open("sqlite", sqlitefile.URI(path, "_pragma=foreign_keys(1)")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = db.Close() }) - conn, err := db.Conn(context.Background()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = conn.Close() }) - var version, foreignKeys int - if err := conn.QueryRowContext(context.Background(), "PRAGMA user_version").Scan(&version); err != nil || version != 2 { - t.Fatalf("older writer did not open schema 2: %d, %v", version, err) - } - if err := conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil || foreignKeys != 1 { - t.Fatalf("older writer has foreign keys disabled: %d, %v", foreignKeys, err) - } - return conn -} - -func workstreamStoredRows(t *testing.T, db *sql.DB, table string) [][]any { - t.Helper() - rows, err := db.Query("SELECT * FROM " + table + " ORDER BY 1,2") - if err != nil { - t.Fatal(err) - } - defer rows.Close() - columns, err := rows.Columns() - if err != nil { - t.Fatal(err) - } - var out [][]any - for rows.Next() { - values := make([]any, len(columns)) - pointers := make([]any, len(columns)) - for i := range values { - pointers[i] = &values[i] - } - if err := rows.Scan(pointers...); err != nil { - t.Fatal(err) - } - out = append(out, values) - } - if err := rows.Err(); err != nil { - t.Fatal(err) - } - return out -} - -// E-B1-a: a connection opened before migration must not cascade away a -// workstream, even when its writer bypasses the current Store.Delete guard. -func TestStabEB1AnOlderWriterCannotDeleteAWorkstream(t *testing.T) { - ctx := context.Background() - path := writeSchemaTwo(t) - older := workstreamSchemaTwoWriter(t, path) - s := openTestStore(t, path) - seedWorkstream(t, s, "protected", "Protected") - if userVersion(t, s) != schemaVersion { - t.Fatal("workstream creation did not migrate schema 2") - } - // The domain store imports workspace, so this fixture uses its shared SQL - // transaction to create the dependent rows without an import cycle. - if err := s.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES - ('protected','conversation','agent-protected','',''),('protected','factory_job','7','','f'); -INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection) - VALUES ('aaaa','collection','protected','','protected'); -INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) - VALUES ('page','protected','Notes','you',0,2,200,'you','{"type":"doc","content":[]}'); -INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES - ('page',1,100,'you','Notes','{"type":"doc","content":[]}',27), - ('page',2,200,'you','Notes','{"type":"doc","content":[]}',27); -INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) - VALUES ('record','protected','decision','Keep this decision','kept','you',100); -INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) - VALUES ('grant','protected','wave',1,1,200,'active',100); -INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) - VALUES ('protected',1,100,'you','[]','[]'); -`) - return err - }); err != nil { - t.Fatal(err) - } - tables := []struct { - name string - count int - }{ - {"collections", 3}, {"memberships", 6}, {"workstreams", 1}, {"workstream_jobs", 1}, - {"workstream_pages", 1}, {"workstream_page_versions", 2}, {"workstream_records", 1}, - {"workstream_goaheads", 1}, {"workstream_plans", 1}, - } - before := make(map[string][][]any, len(tables)) - for _, table := range tables { - before[table.name] = workstreamStoredRows(t, s.db, table.name) - if len(before[table.name]) != table.count { - t.Fatalf("%s fixture has %d rows, want %d", table.name, len(before[table.name]), table.count) - } - } - if _, err := older.ExecContext(ctx, "DELETE FROM collections WHERE id=?", "protected"); err == nil || !strings.Contains(err.Error(), ErrWorkstreamDelete.Error()) { - t.Fatalf("older writer was not refused with the workstream explanation: %v", err) - } - for _, table := range tables { - if after := workstreamStoredRows(t, s.db, table.name); !reflect.DeepEqual(after, before[table.name]) { - t.Errorf("refused delete changed %s: before %v, after %v", table.name, before[table.name], after) - } - } -} - -// E-B1-b: the SQL guard preserves plain collection deletion and its cascades, -// and Store.Delete still returns the friendly workstream refusal. -func TestStabEB1PlainCollectionsStillDelete(t *testing.T) { - ctx := context.Background() - path := writeSchemaTwo(t) - older := workstreamSchemaTwoWriter(t, path) - s := openTestStore(t, path) - seedWorkstream(t, s, "protected", "Protected") - for _, writer := range []string{"older writer", "store"} { - t.Run(writer, func(t *testing.T) { - plain := createTestCollection(t, s, "Plain "+writer) - if err := s.Add(ctx, plain.ID, Ref{Kind: ConversationKind, ID: "plain-chat"}); err != nil { - t.Fatal(err) - } - if err := s.Add(ctx, "aaaa", Ref{Kind: CollectionKind, ID: plain.ID}); err != nil { - t.Fatal(err) - } - var members int - if err := s.db.QueryRow("SELECT count(*) FROM memberships WHERE collection_id=? OR target_collection=?", plain.ID, plain.ID).Scan(&members); err != nil || members != 2 { - t.Fatalf("plain membership fixture: %d, %v", members, err) - } - if writer == "older writer" { - result, err := older.ExecContext(ctx, "DELETE FROM collections WHERE id=?", plain.ID) - if err != nil { - t.Fatal(err) - } - if n, err := result.RowsAffected(); err != nil || n != 1 { - t.Fatalf("older writer deleted %d collections: %v", n, err) - } - } else if parents, err := s.Delete(ctx, plain.ID); err != nil || !reflect.DeepEqual(parents, []string{"aaaa"}) { - t.Fatalf("plain Store.Delete: %v, %v", parents, err) - } - var collections int - if err := s.db.QueryRow("SELECT count(*) FROM collections WHERE id=?", plain.ID).Scan(&collections); err != nil || collections != 0 { - t.Fatalf("plain collection survived: %d, %v", collections, err) - } - if err := s.db.QueryRow("SELECT count(*) FROM memberships WHERE collection_id=? OR target_collection=?", plain.ID, plain.ID).Scan(&members); err != nil || members != 0 { - t.Fatalf("plain memberships did not cascade: %d, %v", members, err) - } - }) - } - if _, err := s.Delete(ctx, "protected"); err != ErrWorkstreamDelete { - t.Fatalf("Store.Delete lost its friendly workstream refusal: %v", err) - } -} - -// E-B1-c: earlier schema-3 files remain readable without modification and gain -// the database guard in the transaction of their next write. -func TestStabEB1AnEarlierSchemaThreeFileGainsTheGuardOnItsNextWrite(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "earlier.db") - fixture := openTestStore(t, path) - plain := createTestCollection(t, fixture, "Earlier") - workstreamSchemaThreeFixture(t, fixture) - if _, err := fixture.db.Exec("DROP TRIGGER IF EXISTS workstream_collection_keep"); err != nil { - t.Fatal(err) - } - if err := fixture.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - s := openTestStore(t, path) - if userVersion(t, s) != 3 { - t.Fatal("fixture is not schema 3") - } - list, err := s.List(ctx, nil) - if err != nil || len(list) != 1 || list[0].ID != plain.ID { - t.Fatalf("earlier schema-3 read: %+v, %v", list, err) - } - var triggers int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='workstream_collection_keep'").Scan(&triggers); err != nil || triggers != 0 { - t.Fatalf("read installed the guard: %d, %v", triggers, err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatalf("open or read changed earlier schema 3: %v", err) - } - if err := s.Rename(ctx, plain.ID, "Updated"); err != nil { - t.Fatal(err) - } - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='workstream_collection_keep'").Scan(&triggers); err != nil || triggers != 1 { - t.Fatalf("first write did not install the guard: %d, %v", triggers, err) - } - if err := s.Rename(ctx, plain.ID, "Updated again"); err != nil { - t.Fatalf("installing the guard again was not idempotent: %v", err) - } -} diff --git a/internal/workspace/schema4_test.go b/internal/workspace/schema4_test.go deleted file mode 100644 index bad69c4218..0000000000 --- a/internal/workspace/schema4_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -func workstreamSchemaThreeFixture(t *testing.T, s *Store) { - t.Helper() - if _, err := s.db.Exec("DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions; DROP TABLE workstream_sources; DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3"); err != nil { - t.Fatal(err) - } - s.version = 3 -} - -func TestWakeI_SchemaThreeMigratesAdditively(t *testing.T) { - path := filepath.Join(t.TempDir(), "schema.db") - s := openTestStore(t, path) - seedWorkstream(t, s, "w", "Watching") - if _, err := s.db.Exec(` - INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('p','w','Notes','agent',0,1,100,'agent','{}'); - INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('p',1,100,'agent','Notes','{}',2); - INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) VALUES ('r','w','decision','Keep it','kept','agent',100); - INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('w',1,100,'you','[]','[]');`); err != nil { - t.Fatal(err) - } - workstreamSchemaThreeFixture(t, s) - queries := []string{"SELECT * FROM workstreams", "SELECT * FROM workstream_pages", "SELECT * FROM workstream_page_versions", "SELECT * FROM workstream_records", "SELECT * FROM workstream_plans"} - snapshot := func() [][]string { - all := [][]string{} - for _, q := range queries { - rows, err := s.db.Query(q) - if err != nil { - t.Fatal(err) - } - columns, _ := rows.Columns() - for rows.Next() { - cells := make([]sql.NullString, len(columns)) - args := make([]any, len(cells)) - for i := range args { - args[i] = &cells[i] - } - if err := rows.Scan(args...); err != nil { - t.Fatal(err) - } - values := []string{} - for _, cell := range cells { - values = append(values, cell.String) - } - all = append(all, values) - } - rows.Close() - } - return all - } - before := snapshot() - if err := s.Write(context.Background(), func(*sql.Tx) error { return nil }); err != nil { - t.Fatal(err) - } - if userVersion(t, s) != 6 || !reflect.DeepEqual(snapshot(), before) { - t.Fatal("migration lost existing rows") - } - var count int - if err := s.db.QueryRow("SELECT count(*) FROM workstream_wakes").Scan(&count); err != nil || count != 0 { - t.Fatalf("wakes: %d %v", count, err) - } -} - -func TestWakeI_NewerSchemaIsRefusedWithoutWriting(t *testing.T) { - path := filepath.Join(t.TempDir(), "future.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Future") - if _, err := s.db.Exec("PRAGMA user_version=7"); err != nil { - t.Fatal(err) - } - s.Close() - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - opened.Close() - } - if err == nil || !strings.Contains(err.Error(), "version 7") { - t.Fatalf("newer schema: %v", err) - } - after, err := os.ReadFile(path) - if err != nil || string(before) != string(after) { - t.Fatal("newer file changed") - } -} - -func TestWakeI_FailedFirstWriteRollsBackTheMigration(t *testing.T) { - s := openTestStore(t, filepath.Join(t.TempDir(), "rollback.db")) - seedWorkstream(t, s, "w", "Watching") - workstreamSchemaThreeFixture(t, s) - err := s.Write(context.Background(), func(*sql.Tx) error { return ErrInvalid }) - if err != ErrInvalid || userVersion(t, s) != 3 { - t.Fatal("failed write migrated", err) - } - var n int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE name IN ('workstream_wakes','workstream_wake_items')").Scan(&n); err != nil || n != 0 { - t.Fatal("partial migration", n, err) - } -} diff --git a/internal/workspace/schema6_test.go b/internal/workspace/schema6_test.go deleted file mode 100644 index ed237f3212..0000000000 --- a/internal/workspace/schema6_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -// WP-6d Schema: use the actual DDL for each step, without any later tables. -func writeWorkstreamSuggestionPredecessor(t *testing.T, version int) string { - t.Helper() - path := filepath.Join(t.TempDir(), "collections.db") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - defer db.Close() - ddl := schemaTables("collections", "memberships") + schemaIndexes + workstreamSchema - if version >= WorkstreamWakeSchemaVersion { - ddl += workstreamWakeSchema - } - if version >= WorkstreamSourcesSchemaVersion { - ddl += workstreamSourcesSchema - } - if _, err := db.Exec(ddl + fmt.Sprintf(` -PRAGMA application_id=%d; PRAGMA user_version=%d; -INSERT INTO collections(id,name,created_at) VALUES ('existing','Existing',100); -INSERT INTO workstreams(id,agent_id,created_at,updated_at) VALUES ('existing','agent',100,100); -INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('existing','factory_job','7','','f'); -INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('page','existing','Notes','you',0,1,100,'you','{"type":"doc","content":[]}'); -INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('page',1,100,'you','Notes','{"type":"doc","content":[]}',27); -INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) VALUES ('record','existing','decision','Keep','kept','you',100); -INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) VALUES ('grant','existing','wave',1,1,200,'active',100); -INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('existing',1,100,'you','[]','[]'); -`, applicationID, version)); err != nil { - t.Fatal(err) - } - if version >= WorkstreamWakeSchemaVersion { - if _, err := db.Exec(` -INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,cost_usd,started_at,settled_at) VALUES ('wake','existing','morning:2026-11-03','{}','settled','Existing wake',0.25,100,200); -INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,applied_at) VALUES ('item','wake',0,'page','applied','Existing change','{}','{}',200); -`); err != nil { - t.Fatal(err) - } - } - if version >= WorkstreamSourcesSchemaVersion { - if _, err := db.Exec(` -INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,why,read_by_jobs,proposed,added_by,added_at) VALUES ('source','existing','link','https://example.com','Example','https://example.com','Existing context',1,1,'agent',100); -`); err != nil { - t.Fatal(err) - } - } - return path -} - -// WP-6d Schema: opening, reading and a refused first write preserve the file; -// the first committed write upgrades every missing step and keeps all old rows. -func TestWorkstreamSchemaSixIsAnAtomicAdditiveMigration(t *testing.T) { - for _, version := range []int{3, 4, 5} { - t.Run(fmt.Sprint(version), func(t *testing.T) { - path := writeWorkstreamSuggestionPredecessor(t, version) - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - s := openTestStore(t, path) - tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} - if version >= WorkstreamWakeSchemaVersion { - tables = append(tables, "workstream_wakes", "workstream_wake_items") - } - if version >= WorkstreamSourcesSchemaVersion { - tables = append(tables, "workstream_sources") - } - rows := map[string][][]any{} - for _, table := range tables { - rows[table] = workstreamStoredRows(t, s.db, table) - if len(rows[table]) == 0 { - t.Fatalf("fixture has no rows in %s", table) - } - } - if list, err := s.List(context.Background(), nil); err != nil || len(list) != 1 || list[0].ID != "existing" { - t.Fatalf("schema %d list: %+v %v", version, list, err) - } - if err := s.Read(context.Background(), func(_ *sql.Tx, got int) error { - if got != version { - t.Fatalf("read version %d, want %d", got, version) - } - return nil - }); err != nil { - t.Fatal(err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) || userVersion(t, s) != version { - t.Fatal("open or read changed the old file", err) - } - refusal := errors.New("refused write") - if err := s.Write(context.Background(), func(tx *sql.Tx) error { - if _, err := tx.Exec("INSERT INTO collections(id,name) VALUES ('rollback','Rollback')"); err != nil { - return err - } - return refusal - }); !errors.Is(err, refusal) { - t.Fatal(err) - } - after, err = os.ReadFile(path) - if err != nil || string(after) != string(before) || userVersion(t, s) != version { - t.Fatal("refused write changed the old file", err) - } - var n int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE name IN ('workstream_wakes','workstream_wake_items','workstream_sources','workstream_suggestions','workstream_suggestion_edits')").Scan(&n); err != nil || n != len(tables)-9 { - t.Fatal("refused write left migration tables", n, err) - } - if err := s.Write(context.Background(), func(tx *sql.Tx) error { - _, err := tx.Exec(` -INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,created_at) VALUES ('suggestion','existing','page',1,'Clarify','agent',200); -INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) VALUES ('suggestion','e1',0,'[0]',0,'Old','New','Old','Clearer','proposed');`) - return err - }); err != nil { - t.Fatal(err) - } - if userVersion(t, s) != 6 { - t.Fatal("first write did not install schema 6") - } - for _, table := range tables { - if !reflect.DeepEqual(rows[table], workstreamStoredRows(t, s.db, table)) { - t.Errorf("migration changed %s", table) - } - } - for _, table := range []string{"workstream_wakes", "workstream_wake_items", "workstream_sources", "workstream_suggestions", "workstream_suggestion_edits"} { - if _, err := s.db.Exec("SELECT * FROM " + table + " LIMIT 0"); err != nil { - t.Fatal(err) - } - } - }) - } -} - -// WP-6d Schema: blank files write 6; future and incomplete current files refuse -// without repair, including wake and source tables inherited from schemas 4/5. -func TestWorkstreamSchemaSixFreshFutureAndDamaged(t *testing.T) { - for _, damage := range []string{"future", "workstream_suggestions", "workstream_suggestion_edits", "workstream_wakes", "workstream_wake_items", "workstream_sources"} { - t.Run(damage, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "collections.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Fresh") - if userVersion(t, s) != 6 { - t.Fatal("fresh schema is not 6") - } - query := "DROP TABLE " + damage - switch damage { - case "future": - query = "PRAGMA user_version=7" - case "workstream_suggestions": - query = "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions" - case "workstream_wakes": - query = "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes" - } - if _, err := s.db.Exec(query); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - _ = opened.Close() - } - if err == nil || damage == "future" && !strings.Contains(err.Error(), "unsupported collections database") || damage != "future" && !strings.Contains(err.Error(), damage) { - t.Fatal("accepted unsupported file or returned the wrong refusal", err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatal("refusal changed file", err) - } - }) - } -} - -// WP-6d Schema: accepting schemas 4/5 must still verify their wake/source tables. -func TestWorkstreamSuggestionPredecessorsStillRequireTheirTables(t *testing.T) { - for _, version := range []int{4, 5} { - tables := []string{"workstream_wakes", "workstream_wake_items"} - if version >= WorkstreamSourcesSchemaVersion { - tables = append(tables, "workstream_sources") - } - for _, table := range tables { - t.Run(fmt.Sprintf("%d/%s", version, table), func(t *testing.T) { - path := writeWorkstreamSuggestionPredecessor(t, version) - s := openTestStore(t, path) - query := "DROP TABLE " + table - if table == "workstream_wakes" { - query = "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes" - } - if _, err := s.db.Exec(query); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - _ = opened.Close() - } - if err == nil || !strings.Contains(err.Error(), table) { - t.Fatalf("schema %d accepted missing %s: %v", version, table, err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatal("refusal changed predecessor file", err) - } - }) - } - } -} diff --git a/internal/workspace/schema7.go b/internal/workspace/schema7.go new file mode 100644 index 0000000000..404c0d7ef9 --- /dev/null +++ b/internal/workspace/schema7.go @@ -0,0 +1,32 @@ +package workspace + +// schemaSevenCleanup removes tables left by development builds (schemas 3–6), +// including their indexes and triggers. +const schemaSevenCleanup = ` +DROP TRIGGER IF EXISTS workstream_collection_keep; +DROP TRIGGER IF EXISTS workstream_job_promote; +DROP TRIGGER IF EXISTS workstream_job_update; +DROP TRIGGER IF EXISTS workstream_job_insert; +DROP INDEX IF EXISTS workstream_suggestion_page; +DROP INDEX IF EXISTS workstream_sources_order; +DROP INDEX IF EXISTS workstream_wake_item_list; +DROP INDEX IF EXISTS workstream_wake_list; +DROP INDEX IF EXISTS workstream_goahead_list; +DROP INDEX IF EXISTS workstream_record_list; +DROP INDEX IF EXISTS workstream_overview; +DROP INDEX IF EXISTS workstream_page_tree; +DROP INDEX IF EXISTS workstream_job_owner; +DROP INDEX IF EXISTS workstreams_client; +DROP TABLE IF EXISTS workstream_suggestion_edits; +DROP TABLE IF EXISTS workstream_suggestions; +DROP TABLE IF EXISTS workstream_sources; +DROP TABLE IF EXISTS workstream_wake_items; +DROP TABLE IF EXISTS workstream_wakes; +DROP TABLE IF EXISTS workstream_plans; +DROP TABLE IF EXISTS workstream_goaheads; +DROP TABLE IF EXISTS workstream_records; +DROP TABLE IF EXISTS workstream_page_versions; +DROP TABLE IF EXISTS workstream_pages; +DROP TABLE IF EXISTS workstream_jobs; +DROP TABLE IF EXISTS workstreams; +` diff --git a/internal/workspace/schema7_test.go b/internal/workspace/schema7_test.go new file mode 100644 index 0000000000..af5dfed7fa --- /dev/null +++ b/internal/workspace/schema7_test.go @@ -0,0 +1,574 @@ +package workspace + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/sqlitefile" +) + +// Contract (c): frozen SQL from the schema-6 writer and the schema-2 merge base. +// These fixtures deliberately do not call the current schema builders. +const schemaSevenTwoDDL = ` +CREATE TABLE collections ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process')), + ref_id TEXT NOT NULL, + session_id TEXT NOT NULL, + target_collection TEXT REFERENCES collections(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL DEFAULT 0, + CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) + OR (kind!='collection' AND target_collection IS NULL)), + UNIQUE(collection_id,kind,ref_id,session_id) +); + +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_target ON memberships(target_collection); +` + +const schemaSevenCoreDDL = ` +CREATE TABLE collections ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process','factory_job')), + ref_id TEXT NOT NULL, + session_id TEXT NOT NULL, + target_collection TEXT REFERENCES collections(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL DEFAULT 0, + factory TEXT NOT NULL DEFAULT '', + CHECK ((kind='factory_job' AND length(factory)>0) OR (kind!='factory_job' AND factory='')), + CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) + OR (kind!='collection' AND target_collection IS NULL)), + UNIQUE(collection_id,kind,ref_id,session_id,factory) +); + +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id,factory); +CREATE INDEX memberships_target ON memberships(target_collection); + +` + +const schemaSevenThreeDDL = `CREATE TABLE workstreams ( + id TEXT PRIMARY KEY REFERENCES collections(id) ON DELETE CASCADE, + why TEXT NOT NULL DEFAULT '', origin_chat_id TEXT NOT NULL DEFAULT '', agent_id TEXT NOT NULL, + autonomy TEXT NOT NULL DEFAULT 'propose' CHECK(autonomy IN ('propose','small','off')), + wake_morning INTEGER NOT NULL DEFAULT 0, wake_related_issues INTEGER NOT NULL DEFAULT 0, + budget_usd REAL CHECK(budget_usd IS NULL OR budget_usd>0), + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, started_at INTEGER, done_at INTEGER, + how_it_went TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX workstreams_client ON workstreams(client_id) WHERE client_id!=''; +CREATE TABLE workstream_jobs ( + member_seq INTEGER PRIMARY KEY REFERENCES memberships(seq) ON DELETE CASCADE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + factory TEXT NOT NULL, job TEXT NOT NULL +); +CREATE UNIQUE INDEX workstream_job_owner ON workstream_jobs(factory,job); +CREATE TRIGGER workstream_job_insert AFTER INSERT ON memberships + WHEN NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id) + BEGIN INSERT INTO workstream_jobs VALUES(NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id); END; +CREATE TRIGGER workstream_job_update AFTER UPDATE ON memberships + BEGIN + DELETE FROM workstream_jobs WHERE member_seq=OLD.seq; + INSERT INTO workstream_jobs SELECT NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id + WHERE NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id); + END; +CREATE TRIGGER workstream_job_promote AFTER INSERT ON workstreams + BEGIN INSERT INTO workstream_jobs SELECT seq,collection_id,factory,ref_id FROM memberships + WHERE collection_id=NEW.id AND kind='factory_job'; END; +CREATE TABLE workstream_pages ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES workstream_pages(id) ON DELETE CASCADE, + title TEXT NOT NULL, owner TEXT NOT NULL CHECK(owner IN ('you','agent')), + read_by_jobs INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL CHECK(position>=0), + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), updated_from TEXT, + doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)), + deleted_at INTEGER, deleted_root TEXT +); +CREATE INDEX workstream_page_tree ON workstream_pages(workstream_id,parent_id,position); +CREATE UNIQUE INDEX workstream_overview ON workstream_pages(workstream_id) WHERE overview=1; +CREATE TABLE workstream_page_versions ( + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + version INTEGER NOT NULL, saved_at INTEGER NOT NULL, saved_by TEXT NOT NULL CHECK(saved_by IN ('you','agent')), + source TEXT, title TEXT NOT NULL, doc TEXT NOT NULL, bytes INTEGER NOT NULL, + PRIMARY KEY(page_id,version) +); +CREATE TABLE workstream_records ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('decision','question','goahead')), text TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped','superseded')), + actor TEXT NOT NULL CHECK(actor IN ('you','agent')), source TEXT, created_at INTEGER NOT NULL, + decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id), + goahead TEXT, goahead_id TEXT +); +CREATE INDEX workstream_record_list ON workstream_records(workstream_id,created_at); +CREATE TABLE workstream_goaheads ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK(scope IN ('job','wave','milestone')), + factory TEXT, job INTEGER, wave INTEGER, milestone_id TEXT, + cap_usd REAL NOT NULL CHECK(cap_usd>0 AND cap_usd<=10000), ends_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK(state IN ('active','ended')), + end_reason TEXT CHECK(end_reason IN ('expired','revoked','workstream_done')), + granted_at INTEGER NOT NULL, ended_at INTEGER, + CHECK((scope='job' AND factory IS NOT NULL AND length(factory)>0 AND job IS NOT NULL AND job>0 AND wave IS NULL AND milestone_id IS NULL) + OR (scope='wave' AND wave IS NOT NULL AND wave>0 AND factory IS NULL AND job IS NULL AND milestone_id IS NULL) + OR (scope='milestone' AND milestone_id IS NOT NULL AND length(milestone_id)>0 AND factory IS NULL AND job IS NULL AND wave IS NULL)), + CHECK(ends_at>granted_at AND ends_at<=granted_at+7776000000) +); +CREATE INDEX workstream_goahead_list ON workstream_goaheads(workstream_id,state); +CREATE TABLE workstream_plans ( + workstream_id TEXT PRIMARY KEY REFERENCES workstreams(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), + milestones TEXT NOT NULL, waves TEXT NOT NULL +); + +CREATE TRIGGER IF NOT EXISTS workstream_collection_keep BEFORE DELETE ON collections + WHEN EXISTS(SELECT 1 FROM workstreams WHERE id=OLD.id) + BEGIN SELECT RAISE(ABORT,'mark a workstream done instead'); END; + +` + +const schemaSevenFourDDL = `CREATE TABLE workstream_wakes ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + event_key TEXT NOT NULL, event_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('running','settled','failed')), + summary TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', cost_usd REAL, + started_at INTEGER NOT NULL, settled_at INTEGER, dismissed_at INTEGER, + UNIQUE(workstream_id,event_key) +); +CREATE INDEX workstream_wake_list ON workstream_wakes(workstream_id,started_at); +CREATE TABLE workstream_wake_items ( + id TEXT PRIMARY KEY, wake_id TEXT NOT NULL REFERENCES workstream_wakes(id) ON DELETE CASCADE, + position INTEGER NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('move','draft','page','decision','question')), + state TEXT NOT NULL CHECK(state IN ('applied','undone','proposed','dismissed')), + why TEXT NOT NULL DEFAULT '', item_json TEXT NOT NULL, undo_json TEXT, + note TEXT NOT NULL DEFAULT '', applied_at INTEGER, undone_at INTEGER +); +CREATE INDEX workstream_wake_item_list ON workstream_wake_items(wake_id,position); + +` + +const schemaSevenFiveDDL = `CREATE TABLE workstream_sources ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('file','repo','issue','link','kept')), + target TEXT NOT NULL, + title TEXT NOT NULL, + path TEXT, repo TEXT, number INTEGER, url TEXT, ref TEXT, why TEXT, + read_by_jobs INTEGER NOT NULL DEFAULT 0, + proposed INTEGER NOT NULL DEFAULT 0, + added_by TEXT NOT NULL CHECK(added_by IN ('you','agent')), + added_at INTEGER NOT NULL, + UNIQUE(workstream_id,kind,target) +); +CREATE INDEX workstream_sources_order ON workstream_sources(workstream_id,seq); + +` + +const schemaSevenSixDDL = `CREATE TABLE workstream_suggestions ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + base_version INTEGER NOT NULL, why TEXT NOT NULL, actor TEXT NOT NULL, source TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX workstream_suggestion_page ON workstream_suggestions(page_id,created_at); +CREATE TABLE workstream_suggestion_edits ( + suggestion_id TEXT NOT NULL REFERENCES workstream_suggestions(id) ON DELETE CASCADE, + id TEXT NOT NULL, position INTEGER NOT NULL, block TEXT NOT NULL, at_offset INTEGER NOT NULL, + del TEXT NOT NULL, ins TEXT NOT NULL, block_text TEXT NOT NULL, why TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped')), decided_at INTEGER, + PRIMARY KEY(suggestion_id,id) +); +` + +const schemaSevenRows = ` +INSERT INTO collections(seq,id,name,created_at) VALUES + (4,'root','Root',1234),(9,'nested','Nested',2345),(12,'former','Former owner',3456); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) VALUES + (2,'root','conversation','chat','',NULL,4567), + (6,'root','task','3','chat',NULL,5678), + (11,'root','collection','nested','','nested',6789), + (15,'nested','collection','former','','former',7000), + (16,'nested','crew','crew','',NULL,7100), + (17,'nested','process','chat:p1','chat',NULL,7200), + (18,'former','conversation','agent','',NULL,7300), + (19,'nested','artifact','/tmp/report.md','',NULL,7400), + (20,'nested','standing','retired','',NULL,7500); +INSERT INTO collections(seq,id,name) VALUES (100,'gone','Deleted'); +DELETE FROM collections WHERE id='gone'; +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id) VALUES (200,'root','conversation','gone',''); +DELETE FROM memberships WHERE ref_id='gone'; +` + +const schemaSevenThreeRows = ` +INSERT INTO workstreams(id,agent_id,created_at,updated_at,client_id) VALUES ('former','agent',3456,4000,'client'); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,added_at,factory) VALUES + (21,'former','factory_job','7','',7600,'factory-a'),(22,'root','factory_job','7','',7700,'factory-a'); +INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc,overview) + VALUES ('page','former','Overview','you',0,1,4000,'you','{}',1); +INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) + VALUES ('page',1,4000,'you','Overview','{}',2); +INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) + VALUES ('record','former','decision','Keep','kept','you',4000); +INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) + VALUES ('grant','former','wave',1,1,5000,'active',4000); +INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) + VALUES ('former',1,4000,'you','[]','[]'); +` +const schemaSevenFourRows = ` +INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,started_at) + VALUES ('wake','former','morning:2026-11-03','{}','settled',4000); +INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,item_json) + VALUES ('item','wake',0,'page','applied','{}'); +` +const schemaSevenFiveRows = ` +INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) + VALUES ('source','former','link','https://example.com','Example','you',4000); +` +const schemaSevenSixRows = ` +INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,created_at) + VALUES ('suggestion','former','page',1,'Clarify','agent',4000); +INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) + VALUES ('suggestion','edit',0,'[0]',0,'Old','New','Old','Clearer','proposed'); +` + +func schemaSevenFixture(t *testing.T, version int) string { + t.Helper() + ddl, rows := schemaSevenTwoDDL, schemaSevenRows + if version >= 3 { + ddl = schemaSevenCoreDDL + schemaSevenThreeDDL + rows += schemaSevenThreeRows + } + if version >= 4 { + ddl += schemaSevenFourDDL + rows += schemaSevenFourRows + } + if version >= 5 { + ddl += schemaSevenFiveDDL + rows += schemaSevenFiveRows + } + if version >= 6 { + ddl += schemaSevenSixDDL + rows += schemaSevenSixRows + } + path := filepath.Join(t.TempDir(), "collections.db") + db, err := sql.Open("sqlite", sqlitefile.URI(path, "_pragma=foreign_keys(1)")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(ddl + rows + fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d;", applicationID, version)); err != nil { + t.Fatal(err) + } + return path +} + +func schemaSevenFile(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return b +} + +func schemaSevenQuery(t *testing.T, db *sql.DB, query string) [][]any { + t.Helper() + rows, err := db.Query(query) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + t.Fatal(err) + } + var out [][]any + for rows.Next() { + values, pointers := make([]any, len(columns)), make([]any, len(columns)) + for i := range values { + pointers[i] = &values[i] + } + if err := rows.Scan(pointers...); err != nil { + t.Fatal(err) + } + out = append(out, values) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func schemaSevenSchema(t *testing.T, s *Store) [][]any { + t.Helper() + schema := schemaSevenQuery(t, s.db, "SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name") + // ALTER TABLE quotes renamed table identifiers; the logical shape is identical. + for _, row := range schema { + if text, ok := row[3].(string); ok { + row[3] = strings.ReplaceAll(text, `"`, "") + } + } + return schema +} + +func schemaSevenSequences(t *testing.T, s *Store) [][]any { + t.Helper() + return schemaSevenQuery(t, s.db, "SELECT name,seq FROM sqlite_sequence WHERE name IN ('collections','memberships') ORDER BY name") +} + +// Contract (c), G2: every supported predecessor reads without writing, migrates +// on the requested successful write, preserves every row and cursor high-water +// mark, permits former-owner deletion, and performs no DDL on subsequent writes. +func TestSchemaSevenMigratesAndPreservesGenericData(t *testing.T) { + for _, version := range []int{2, 3, 4, 5, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + ctx := context.Background() + path := schemaSevenFixture(t, version) + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + wantCollections := schemaSevenQuery(t, s.db, "SELECT seq,id,name,created_at FROM collections ORDER BY seq") + projection := "seq,collection_id,kind,ref_id,session_id,target_collection,added_at,''" + if version >= 3 { + projection = "seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory" + } + wantMembers := schemaSevenQuery(t, s.db, "SELECT "+projection+" FROM memberships ORDER BY seq") + wantSequences := schemaSevenSequences(t, s) + if !reflect.DeepEqual(wantSequences, [][]any{{"collections", int64(100)}, {"memberships", int64(200)}}) { + t.Fatalf("fixture lacks deleted-row gaps: %v", wantSequences) + } + if version == 6 { + // Every frozen legacy table contains data; cleanup is exercised with foreign keys on. + for _, row := range schemaSevenQuery(t, s.db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('collections','memberships','sqlite_sequence')") { + count := schemaSevenQuery(t, s.db, "SELECT count(*) FROM "+row[0].(string)) + if count[0][0].(int64) == 0 { + t.Fatalf("empty fixture table: %s", row[0]) + } + } + } + collections, err := s.Collections(ctx) + if err != nil || len(collections) != 3 { + t.Fatalf("collections: %v, %v", collections, err) + } + summaries, err := s.List(ctx, nil) + if err != nil || len(summaries) != 3 || summaries[0].CreatedAt != 1234 { + t.Fatalf("summaries: %+v, %v", summaries, err) + } + wantRefs := map[string][]Ref{} + wantTrees := map[string]Tree{} + for _, c := range collections { + refs, err := s.Members(ctx, c.ID) + if err != nil { + t.Fatal(err) + } + wantRefs[c.ID] = refs + tree, err := s.Tree(ctx, c.ID, 3, 200, "") + if err != nil { + t.Fatal(err) + } + wantTrees[c.ID] = tree + expected := []Ref{} + for _, row := range wantMembers { + if row[1] == c.ID { + expected = append(expected, Ref{Kind: Kind(row[2].(string)), ID: row[3].(string), SessionID: row[4].(string), Factory: row[7].(string)}) + } + } + if !reflect.DeepEqual(refs, expected) { + t.Fatalf("read refs: %v, want %v", refs, expected) + } + } + if version >= 3 { + parents, err := s.CollectionsFor(ctx, Ref{Kind: FactoryJobKind, ID: "7", Factory: "factory-a"}) + if err != nil || !reflect.DeepEqual(parents, []Collection{{ID: "root", Name: "Root"}, {ID: "former", Name: "Former owner"}}) { + t.Fatalf("shared job: %v, %v", parents, err) + } + } + if userVersion(t, s) != version || !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("open/read modified predecessor") + } + // Renaming to the existing name is still a successful public write and + // lets us compare every original row without editing its expected values. + if err := s.Rename(ctx, "root", "Root"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 7 { + t.Fatal("first write did not install schema 7") + } + if got := schemaSevenQuery(t, s.db, "SELECT seq,id,name,created_at FROM collections ORDER BY seq"); !reflect.DeepEqual(got, wantCollections) { + t.Fatalf("collections changed: %v", got) + } + if got := schemaSevenQuery(t, s.db, "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships ORDER BY seq"); !reflect.DeepEqual(got, wantMembers) { + t.Fatalf("memberships changed: %v", got) + } + if got := schemaSevenSequences(t, s); !reflect.DeepEqual(got, wantSequences) { + t.Fatalf("high-water marks changed: %v", got) + } + if got, err := s.List(ctx, nil); err != nil || !reflect.DeepEqual(got, summaries) { + t.Fatalf("summaries changed: %v, %v", got, err) + } + for _, c := range collections { + if got, err := s.Members(ctx, c.ID); err != nil || !reflect.DeepEqual(got, wantRefs[c.ID]) { + t.Fatalf("refs changed: %v, %v", got, err) + } + if got, err := s.Tree(ctx, c.ID, 3, 200, ""); err != nil || !reflect.DeepEqual(got, wantTrees[c.ID]) { + t.Fatalf("tree changed: %v, %v", got, err) + } + } + fresh := openTestStore(t, filepath.Join(t.TempDir(), "fresh.db")) + if err := fresh.ensureSchema(ctx); err != nil { + t.Fatal(err) + } + if userVersion(t, fresh) != 7 { + t.Fatal("blank file did not initialize as schema 7") + } + if got, want := schemaSevenSchema(t, s), schemaSevenSchema(t, fresh); !reflect.DeepEqual(got, want) { + t.Fatalf("migration left legacy objects or wrong schema: %v; want %v", got, want) + } + schema := schemaSevenSchema(t, s) + var schemaCookie int + if err := s.db.QueryRow("PRAGMA schema_version").Scan(&schemaCookie); err != nil { + t.Fatal(err) + } + if err := s.Rename(ctx, "root", "Root again"); err != nil { + t.Fatal(err) + } + var afterCookie int + if err := s.db.QueryRow("PRAGMA schema_version").Scan(&afterCookie); err != nil { + t.Fatal(err) + } + if got := schemaSevenSchema(t, s); !reflect.DeepEqual(got, schema) || afterCookie != schemaCookie { + t.Fatal("second write ran DDL") + } + // IF EXISTS makes the predecessor cleanup safe when repeated or partially absent. + if _, err := s.db.Exec(schemaSevenCleanup); err != nil { + t.Fatal(err) + } + if got := schemaSevenSchema(t, s); !reflect.DeepEqual(got, schema) { + t.Fatal("repeated cleanup changed schema") + } + if _, err := s.Delete(ctx, "former"); err != nil { + t.Fatalf("former owner is not deletable: %v", err) + } + c, err := s.Create(ctx, "After") + if err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, Ref{Kind: FactoryJobKind, ID: "7", Factory: "factory-a"}); err != nil { + t.Fatal(err) + } + var collectionSeq, memberSeq int + if err := s.db.QueryRow("SELECT seq FROM collections WHERE id=?", c.ID).Scan(&collectionSeq); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT seq FROM memberships WHERE collection_id=?", c.ID).Scan(&memberSeq); err != nil { + t.Fatal(err) + } + if collectionSeq <= 100 || memberSeq <= 200 { + t.Fatalf("reused retired sequences: %d, %d", collectionSeq, memberSeq) + } + }) + } +} + +// Contract (c): a failed requested write rolls back DDL, data and version bytes. +func TestSchemaSevenFailedFirstWriteLeavesPredecessorUntouched(t *testing.T) { + for _, version := range []int{2, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := schemaSevenFixture(t, version) + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + schema := schemaSevenSchema(t, s) + if err := s.Rename(context.Background(), "root", "Nested"); err != ErrNameTaken { + t.Fatalf("expected name conflict: %v", err) + } + if userVersion(t, s) != version || !reflect.DeepEqual(schemaSevenFile(t, path), before) || !reflect.DeepEqual(schemaSevenSchema(t, s), schema) { + t.Fatal("failed write migrated predecessor") + } + }) + } +} + +// Contract (c): future versions and foreign application IDs are refused without +// modifying the file, including a file that changed after this handle opened. +func TestSchemaSevenRefusesUnknownDatabases(t *testing.T) { + for _, pragma := range []string{"PRAGMA user_version=8", "PRAGMA application_id=12345"} { + t.Run(pragma, func(t *testing.T) { + path := schemaSevenFixture(t, 6) + s := openTestStore(t, path) + if _, err := s.db.Exec(pragma); err != nil { + t.Fatal(err) + } + before := schemaSevenFile(t, path) + opened, err := Open(path) + if opened != nil { + opened.Close() + } + if err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("open accepted unknown database: %v", err) + } + if _, err := s.Collections(context.Background()); err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("read accepted unknown database: %v", err) + } + if err := s.Rename(context.Background(), "root", "Changed"); err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("write accepted unknown database: %v", err) + } + if !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("refusal changed file") + } + }) + } +} + +// Contract (c): schemas 3–6 are recognized by the generic tables alone. +func TestSchemaSevenReadsCleanPredecessorShapes(t *testing.T) { + for _, version := range []int{3, 4, 5, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "clean.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(schemaSevenCoreDDL + schemaSevenRows + fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d;", applicationID, version)); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + if got, err := s.Members(context.Background(), "root"); err != nil || len(got) != 3 { + t.Fatalf("clean predecessor: %v, %v", got, err) + } + if !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("read changed clean predecessor") + } + if err := s.Rename(context.Background(), "root", "Root"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 7 { + t.Fatal("clean predecessor not upgraded") + } + }) + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index 5d2532d03c..5c28fe4372 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -23,7 +23,7 @@ import ( // schemaVersion is the schema this build writes. Earlier schemas remain // readable without migration; the first successful write upgrades them. const ( - schemaVersion = WorkstreamSuggestionSchemaVersion + schemaVersion = 7 legacySchemaVersion = 1 ) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. @@ -37,36 +37,22 @@ const busyTimeout = 10 * time.Second type schemaState uint8 const ( - schemaBlank schemaState = iota - schemaReady - schemaLegacy - schemaTwo - schemaThree - schemaFour - schemaFive - schemaRefused + schemaBlank schemaState = 0 + schemaLegacy schemaState = 1 + schemaTwo schemaState = 2 + schemaReady schemaState = schemaVersion + schemaRefused schemaState = schemaVersion + 1 ) -// version is the schema a state reads through: 0 for a blank file. +// version is the schema a state reads through: 0 for a blank or refused file. func (s schemaState) version() int { - switch s { - case schemaReady: - return schemaVersion - case schemaTwo: - return 2 - case schemaThree: - return 3 - case schemaFour: - return WorkstreamWakeSchemaVersion - case schemaFive: - return WorkstreamSourcesSchemaVersion - case schemaLegacy: - return legacySchemaVersion + if s < schemaRefused { + return int(s) } return 0 } -// Store owns collection and workstream metadata. Transcripts, task checkpoints, standing +// Store owns collection metadata only. Transcripts, task checkpoints, standing // items and artifacts retain their existing owners and persistence formats. // // THE SCHEMA IS BUILT BY THE FIRST WRITE THAT NEEDS IT, never by Open. Opening @@ -183,65 +169,24 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := q.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return schemaBlank, err } - if app == applicationID && (version == schemaVersion || version == WorkstreamSourcesSchemaVersion || version == WorkstreamWakeSchemaVersion || version == 3 || version == 2 || version == legacySchemaVersion) { - state := schemaReady + if app == applicationID && version >= legacySchemaVersion && version <= schemaVersion { + state := schemaState(version) collections := "SELECT seq,id,name,created_at FROM collections LIMIT 0" memberships := "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at FROM memberships LIMIT 0" - if version == 2 { - state = schemaTwo - } - if version == 3 { - state = schemaThree - } - if version == WorkstreamWakeSchemaVersion { - state = schemaFour - } - if version == WorkstreamSourcesSchemaVersion { - state = schemaFive - } if version >= 3 { memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships LIMIT 0" } if version == legacySchemaVersion { - state = schemaLegacy collections = "SELECT seq,id,name FROM collections LIMIT 0" memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection FROM memberships LIMIT 0" } - // Preparing the actual reads catches missing tables before reporting a - // successful open. We never recreate a damaged initialized schema. + // Only the generic tables define a supported file's readable shape. if _, err := q.ExecContext(ctx, collections); err != nil { return state, err } if _, err := q.ExecContext(ctx, memberships); err != nil { return state, err } - if version >= 3 { - for _, query := range workstreamSchemaReads { - if _, err := q.ExecContext(ctx, query); err != nil { - return state, err - } - } - } - if version >= WorkstreamWakeSchemaVersion { - if _, err := q.ExecContext(ctx, "SELECT id,workstream_id,event_key,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at FROM workstream_wakes LIMIT 0"); err != nil { - return state, err - } - if _, err := q.ExecContext(ctx, "SELECT id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at FROM workstream_wake_items LIMIT 0"); err != nil { - return state, err - } - } - if version >= WorkstreamSourcesSchemaVersion { - if _, err := q.ExecContext(ctx, workstreamSourcesRead); err != nil { - return state, err - } - } - if version >= WorkstreamSuggestionSchemaVersion { - for _, query := range workstreamSuggestionSchemaReads { - if _, err := q.ExecContext(ctx, query); err != nil { - return state, err - } - } - } return state, nil } if app != 0 || version != 0 { @@ -292,8 +237,8 @@ CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id,factory CREATE INDEX memberships_target ON memberships(target_collection); ` -// migrateLegacy rebuilds a schema-1 or schema-2 file as schema 3 inside the caller's -// immediate transaction: either every row arrives in the new tables, in the +// migrateLegacy rebuilds a schema-1 or schema-2 file in the current generic +// shape inside the caller's immediate transaction: every row arrives in the // same order under the same sequence numbers, or the file is left exactly as // it was. A row from schema 1 says nothing of when it was made, so its // created_at and added_at are 0, which every reader takes as unknown. @@ -325,7 +270,7 @@ DROP TABLE memberships; DROP TABLE collections; ALTER TABLE collections_v3 RENAME TO collections; ALTER TABLE memberships_v3 RENAME TO memberships; -`, created, added)+schemaIndexes+workstreamSchema) +`, created, added)+schemaIndexes) return err } @@ -347,44 +292,21 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { } state, err := inspectSchema(ctx, tx) if err == nil { - switch state { - case schemaReady, schemaFive: - _, err = tx.ExecContext(ctx, workstreamCollectionKeep) - case schemaFour: - _, err = tx.ExecContext(ctx, workstreamCollectionKeep) - if err == nil { - err = migrateWorkstreamSources(ctx, tx) - } - case schemaThree: - err = migrateWorkstreamWakes(ctx, tx) - if err == nil { - err = migrateWorkstreamSources(ctx, tx) - } - case schemaLegacy, schemaTwo: + switch { + case state == schemaReady: + // A current file needs no DDL. + case state == schemaLegacy || state == schemaTwo: err = migrateLegacy(ctx, tx, state.version()) - if err == nil { - err = migrateWorkstreamWakes(ctx, tx) - } - if err == nil { - err = migrateWorkstreamSources(ctx, tx) - } - case schemaBlank: + case state >= 3 && state < schemaReady: + _, err = tx.ExecContext(ctx, schemaSevenCleanup) + case state == schemaBlank: if !create { err = ErrNotFound } else { - _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes+workstreamSchema) - if err == nil { - err = migrateWorkstreamWakes(ctx, tx) - } - if err == nil { - err = migrateWorkstreamSources(ctx, tx) - } + _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes) } } } - if err == nil && state != schemaReady { - _, err = tx.ExecContext(ctx, workstreamSuggestionSchema) - } if err == nil && state != schemaReady { _, err = tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)) } @@ -397,7 +319,39 @@ func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { // ensureSchema is used by bulk fixtures that initialize without adding rows. func (s *Store) ensureSchema(ctx context.Context) error { - return s.Write(ctx, func(*sql.Tx) error { return nil }) + tx, err := s.beginWrite(ctx, true) + if err != nil { + return err + } + defer tx.Rollback() + return storeError(tx.Commit()) +} + +// Read runs fn against a consistent read-only snapshot. Version 0 means blank. +// Reads never initialize or migrate the database. +func (s *Store) Read(ctx context.Context, fn func(tx *sql.Tx, version int) error) error { + tx, version, err := s.beginRead(ctx) + if err != nil { + return err + } + defer tx.Rollback() + return storeError(fn(tx, version)) +} + +// beginRead keeps the schema decision and projected rows in one snapshot. A +// peer may migrate between separate transactions, adding fields an old projection +// cannot supply (notably factory on a new job ref). +func (s *Store) beginRead(ctx context.Context) (*sql.Tx, int, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, 0, storeError(err) + } + state, err := inspectSchema(ctx, tx) + if err != nil { + _ = tx.Rollback() + return nil, 0, storeError(err) + } + return tx, state.version(), nil } // readyForRead answers the schema a read goes through, and 0 for a blank @@ -490,8 +444,11 @@ func (s *Store) Create(ctx context.Context, name string) (Collection, error) { return Collection{}, storeError(err) } defer tx.Rollback() + if err := nameTaken(ctx, tx, name, ""); err != nil { + return Collection{}, storeError(err) + } c := Collection{ID: id, Name: name} - if err := CreateIn(ctx, tx, id, name, s.millis()); err != nil { + if _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", c.ID, c.Name, s.millis()); err != nil { return Collection{}, storeError(err) } if err := tx.Commit(); err != nil { @@ -509,10 +466,13 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { return storeError(err) } defer tx.Rollback() - if err := RenameIn(ctx, tx, id, name); err != nil { + if err := requireCollection(ctx, tx, id); err != nil { + return storeError(err) + } + if err := nameTaken(ctx, tx, name, id); err != nil { return storeError(err) } - if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { + if _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id); err != nil { return storeError(err) } return storeError(tx.Commit()) @@ -531,13 +491,6 @@ func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { if err := requireCollection(ctx, tx, id); err != nil { return nil, storeError(err) } - var isWorkstream bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstreams WHERE id=?)", id).Scan(&isWorkstream); err != nil { - return nil, storeError(err) - } - if isWorkstream { - return nil, ErrWorkstreamDelete - } rows, err := tx.QueryContext(ctx, `SELECT c.id FROM collections c WHERE c.id!=? AND EXISTS( SELECT 1 FROM memberships m WHERE m.collection_id=c.id AND m.kind='collection' AND m.ref_id=?) ORDER BY c.seq`, id, id) if err != nil { @@ -555,11 +508,6 @@ func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { if err := rows.Close(); err != nil { return nil, storeError(err) } - for _, parent := range parents { - if err := touchWorkstream(ctx, tx, parent, s.millis()); err != nil { - return nil, storeError(err) - } - } if _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", id); err != nil { return nil, storeError(err) } @@ -610,7 +558,7 @@ func (s *Store) Collections(ctx context.Context) ([]Collection, error) { if version == 0 { return nil } - rows, err := tx.QueryContext(ctx, "SELECT c.id,c.name,"+workstreamColumn(version)+" FROM collections c ORDER BY seq") + rows, err := tx.QueryContext(ctx, "SELECT c.id,c.name FROM collections c ORDER BY seq") if err != nil { return err } @@ -628,7 +576,7 @@ func readCollections(rows *sql.Rows) ([]Collection, error) { result := make([]Collection, 0) for rows.Next() { var c Collection - if err := rows.Scan(&c.ID, &c.Name, &c.Workstream); err != nil { + if err := rows.Scan(&c.ID, &c.Name); err != nil { return nil, err } result = append(result, c) @@ -676,31 +624,11 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb return nil, nil, storeError(err) } defer tx.Rollback() - added, already, err := AddRefsIn(ctx, tx, id, refs, s.millis()) - if err != nil { - return nil, nil, storeError(err) - } - if err := tx.Commit(); err != nil { - return nil, nil, storeError(err) - } - return added, already, nil -} - -// AddRefsIn applies collection rules inside a transaction obtained from Write. -// It is shared with workstream creation so memberships and metadata commit together. -func AddRefsIn(ctx context.Context, tx *sql.Tx, id string, refs []Addition, now int64) ([]Member, []Ref, error) { - for _, ref := range refs { - if err := ref.Validate(); err != nil { - return nil, nil, err - } - if ref.AddedAt < 0 { - return nil, nil, ErrInvalid - } - } if err := requireCollection(ctx, tx, id); err != nil { return nil, nil, storeError(err) } + now := s.millis() added, already := make([]Member, 0, len(refs)), make([]Ref, 0) for _, ref := range refs { var target any @@ -710,9 +638,6 @@ func AddRefsIn(ctx context.Context, tx *sql.Tx, id string, refs []Addition, now } target = ref.ID } - if err := checkWorkstreamJob(ctx, tx, id, ref.Ref); err != nil { - return nil, nil, err - } at := ref.AddedAt if at == 0 { at = now @@ -732,10 +657,8 @@ func AddRefsIn(ctx context.Context, tx *sql.Tx, id string, refs []Addition, now added = append(added, Member{Ref: ref.Ref, AddedAt: at}) } } - if len(added) > 0 { - if err := touchWorkstream(ctx, tx, id, now); err != nil { - return nil, nil, err - } + if err := tx.Commit(); err != nil { + return nil, nil, storeError(err) } return added, already, nil } @@ -808,9 +731,6 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo if _, err := tx.ExecContext(ctx, "DELETE FROM memberships WHERE seq=?", seq); err != nil { return Member{}, false, storeError(err) } - if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { - return Member{}, false, err - } if err := tx.Commit(); err != nil { return Member{}, false, storeError(err) } @@ -818,9 +738,6 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo } else if !errors.Is(err, sql.ErrNoRows) { return Member{}, false, storeError(err) } - if err := checkWorkstreamJob(ctx, tx, id, to); err != nil { - return Member{}, false, err - } var target any if to.Kind == CollectionKind { if err := nestable(ctx, tx, id, to.ID); err != nil { @@ -832,9 +749,6 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo to.Kind, to.ID, to.SessionID, target, to.Factory, seq); err != nil { return Member{}, false, storeError(err) } - if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { - return Member{}, false, err - } if err := tx.Commit(); err != nil { return Member{}, false, storeError(err) } @@ -877,11 +791,6 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member } removed = append(removed, Member{Ref: ref, AddedAt: at}) } - if len(removed) > 0 { - if err := touchWorkstream(ctx, tx, id, s.millis()); err != nil { - return nil, err - } - } if err := tx.Commit(); err != nil { return nil, storeError(err) } @@ -926,7 +835,7 @@ func (s *Store) CollectionsFor(ctx context.Context, ref Ref) ([]Collection, erro if version == 0 { return nil } - rows, err := tx.QueryContext(ctx, `SELECT c.id,c.name,`+workstreamColumn(version)+` FROM collections c JOIN memberships m ON m.collection_id=c.id + rows, err := tx.QueryContext(ctx, `SELECT c.id,c.name FROM collections c JOIN memberships m ON m.collection_id=c.id WHERE m.kind=? AND m.ref_id=? AND m.session_id=? AND `+factoryColumn(version)+`=? ORDER BY c.seq`, ref.Kind, ref.ID, ref.SessionID, ref.Factory) if err != nil { return err @@ -1023,3 +932,10 @@ func pathReason(err error) error { } return err } + +func factoryColumn(version int) string { + if version >= 3 { + return "factory" + } + return "''" +} diff --git a/internal/workspace/views.go b/internal/workspace/views.go index 3c8e78b4a3..3ca1c469c8 100644 --- a/internal/workspace/views.go +++ b/internal/workspace/views.go @@ -224,8 +224,8 @@ func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only st rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT c.seq,c.id,c.name,%s, (SELECT count(*) FROM memberships m WHERE m.collection_id=c.id), (SELECT coalesce(max(%s),0) FROM memberships m WHERE m.collection_id=c.id), - %s, %s -FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, workstreamColumn(version), clause), args...) + %s +FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, clause), args...) if err != nil { return nil, err } @@ -234,7 +234,7 @@ FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, workstreamC for rows.Next() { var c Summary var h bool - if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h, &c.Workstream); err != nil { + if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h); err != nil { return nil, err } if holds != nil { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 4e2dde954d..0414537657 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -62,9 +62,8 @@ type Ref struct { } type Collection struct { - ID string `json:"id"` - Name string `json:"name"` - Workstream bool `json:"workstream,omitempty"` + ID string `json:"id"` + Name string `json:"name"` } // Member is a reference as one collection holds it, with when it was added in diff --git a/internal/workspace/workstream_sources.go b/internal/workspace/workstream_sources.go deleted file mode 100644 index eef802eb1e..0000000000 --- a/internal/workspace/workstream_sources.go +++ /dev/null @@ -1,36 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" -) - -// WorkstreamSourcesSchemaVersion is the first schema with source pointers. -const WorkstreamSourcesSchemaVersion = 5 - -// Sources are pointers with their own lifetime, separate from memberships. -const workstreamSourcesSchema = ` -CREATE TABLE workstream_sources ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE, - workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK(kind IN ('file','repo','issue','link','kept')), - target TEXT NOT NULL, - title TEXT NOT NULL, - path TEXT, repo TEXT, number INTEGER, url TEXT, ref TEXT, why TEXT, - read_by_jobs INTEGER NOT NULL DEFAULT 0, - proposed INTEGER NOT NULL DEFAULT 0, - added_by TEXT NOT NULL CHECK(added_by IN ('you','agent')), - added_at INTEGER NOT NULL, - UNIQUE(workstream_id,kind,target) -); -CREATE INDEX workstream_sources_order ON workstream_sources(workstream_id,seq); -` - -const workstreamSourcesRead = "SELECT seq,id,workstream_id,kind,target,title,path,repo,number,url,ref,why,read_by_jobs,proposed,added_by,added_at FROM workstream_sources LIMIT 0" - -// migrateWorkstreamSources is additive so existing rows and schema text stay intact. -func migrateWorkstreamSources(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, workstreamSourcesSchema) - return err -} diff --git a/internal/workspace/workstream_sources_test.go b/internal/workspace/workstream_sources_test.go deleted file mode 100644 index 9ec77d623f..0000000000 --- a/internal/workspace/workstream_sources_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -func workstreamSchemaFourFixture(t *testing.T, s *Store) { - t.Helper() - if _, err := s.db.Exec("DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions; DROP TABLE workstream_sources; PRAGMA user_version=4"); err != nil { - t.Fatal(err) - } - s.version = 4 -} - -// WP-12 (i): every existing table and value survives the additive migration; -// failed first writes and all reads leave schemas three and four unchanged. -func TestWorkstreamSourcesSchemaFiveMigration(t *testing.T) { - t.Run("schema three", func(t *testing.T) { workstreamSourcesMigration(t, 3) }) - t.Run("schema four", func(t *testing.T) { workstreamSourcesMigration(t, 4) }) -} - -func workstreamSourcesMigration(t *testing.T, version int) { - t.Helper() - ctx := context.Background() - path := filepath.Join(t.TempDir(), "sources.db") - s := openTestStore(t, path) - seedWorkstream(t, s, "stream", "Sources") - if err := s.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.Exec(` -INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('stream','factory_job','7','','f'); -INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc) VALUES ('page','stream','Overview','you',0,1,100,'you','{"type":"doc","content":[]}'); -INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) VALUES ('page',1,100,'you','Overview','{"type":"doc","content":[]}',27); -INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) VALUES ('grant','stream','wave',1,1,200,'active',100); -INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at,goahead,goahead_id) VALUES ('record','stream','goahead','Keep this','kept','agent',100,'{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"1970-01-01T00:00:00.2Z"}','grant'); -INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES ('stream',1,100,'you','[]','[]');`) - return err - }); err != nil { - t.Fatal(err) - } - tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} - if version == 4 { - if _, err := s.db.Exec(` -INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at) - VALUES ('wake','stream','morning:2026-11-03','{"kind":"morning","slot":"2026-11-03"}','settled','Review this.','',0.04,100,200,300); -INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at) - VALUES ('wake-item','wake',0,'decision','undone','Keep the context.','{"kind":"decision","text":"Review this."}','{"record_id":"record"}','Changed since.',150,250);`); err != nil { - t.Fatal(err) - } - workstreamSchemaFourFixture(t, s) - tables = append(tables, "workstream_wakes", "workstream_wake_items") - } else { - workstreamSchemaThreeFixture(t, s) - } - before := map[string][][]any{} - for _, table := range tables { - before[table] = workstreamStoredRows(t, s.db, table) - if len(before[table]) == 0 { - t.Fatalf("empty migration fixture: %s", table) - } - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - bytesBefore, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - s = openTestStore(t, path) - if _, err := s.Members(ctx, "stream"); err != nil { - t.Fatal(err) - } - if err := s.Read(ctx, func(_ *sql.Tx, gotVersion int) error { - if gotVersion != version { - t.Fatalf("read schema %d, want %d", gotVersion, version) - } - return nil - }); err != nil { - t.Fatal(err) - } - refused := errors.New("first write refused") - if err := s.Write(ctx, func(tx *sql.Tx) error { - if _, err := tx.Exec("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) VALUES ('rollback','stream','link','https://example.com','Example','you',100)"); err != nil { - return err - } - return refused - }); !errors.Is(err, refused) { - t.Fatal(err) - } - bytesAfter, err := os.ReadFile(path) - if err != nil || string(bytesAfter) != string(bytesBefore) || userVersion(t, s) != version { - t.Fatalf("read/refusal changed schema %d: %v", version, err) - } - if err := s.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.Exec("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) VALUES ('source','stream','link','https://example.com','Example','you',100)") - return err - }); err != nil { - t.Fatal(err) - } - if userVersion(t, s) != 6 { - t.Fatal("first write did not migrate") - } - for _, table := range tables { - if got := workstreamStoredRows(t, s.db, table); !reflect.DeepEqual(got, before[table]) { - t.Fatalf("migration changed %s: %v", table, got) - } - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - opened := openTestStore(t, path) - if userVersion(t, opened) != 6 { - t.Fatal("current schema did not reopen") - } -} - -// WP-12 (i): missing sources in schema five and future versions are refused. -func TestWorkstreamSourcesSchemaFiveRequiresItsTable(t *testing.T) { - for _, future := range []bool{false, true} { - path := filepath.Join(t.TempDir(), "invalid.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Sources") - query := "DROP TABLE workstream_sources" - if future { - query = "PRAGMA user_version=7" - } - if _, err := s.db.Exec(query); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - opened.Close() - } - if err == nil || future && !strings.Contains(err.Error(), "unsupported collections database") || !future && !strings.Contains(err.Error(), "workstream_sources") { - t.Fatalf("invalid schema opened: %v", err) - } - after, err := os.ReadFile(path) - if err != nil || string(before) != string(after) { - t.Fatalf("refusal changed invalid schema: %v", err) - } - } -} diff --git a/internal/workspace/workstreams.go b/internal/workspace/workstreams.go deleted file mode 100644 index 63b411a9b2..0000000000 --- a/internal/workspace/workstreams.go +++ /dev/null @@ -1,298 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strconv" - "time" -) - -// These bounds are shared by SQLite constraints and the domain validator. -const ( - WorkstreamGoAheadCapMax = 10000 - WorkstreamGoAheadDurationMax = 90 * 24 * time.Hour -) - -var ErrWorkstreamDelete = errors.New("mark a workstream done instead") - -// WorkstreamTaken identifies the existing owner so adapters can explain an -// atomic refusal without another read that could race a rename. -type WorkstreamTaken struct { - Factory string - Job int - WorkstreamID string - WorkstreamName string -} - -func (e *WorkstreamTaken) Error() string { return "that job belongs to another workstream" } - -// Write runs fn in the store's immediate transaction, including any schema -// upgrade. Returning an error rolls back everything. The callback must use tx -// rather than reenter the Store, which owns a single database connection. -func (s *Store) Write(ctx context.Context, fn func(tx *sql.Tx) error) error { - tx, err := s.beginWrite(ctx, true) - if err != nil { - return storeError(err) - } - defer tx.Rollback() - if err := fn(tx); err != nil { - return storeError(err) - } - return storeError(tx.Commit()) -} - -// Read runs fn against a consistent read-only snapshot. Version 0 means blank; -// workstream readers must answer empty/not found for every version below 3. -// Reads never initialize or migrate the database, and share Write's busy mapping. -func (s *Store) Read(ctx context.Context, fn func(tx *sql.Tx, version int) error) error { - tx, version, err := s.beginRead(ctx) - if err != nil { - return err - } - defer tx.Rollback() - return storeError(fn(tx, version)) -} - -// beginRead keeps the schema decision and projected rows in one snapshot. A -// peer may migrate between separate transactions, adding fields an old projection -// cannot supply (notably factory on a new job ref). -func (s *Store) beginRead(ctx context.Context) (*sql.Tx, int, error) { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return nil, 0, storeError(err) - } - state, err := inspectSchema(ctx, tx) - if err != nil { - _ = tx.Rollback() - return nil, 0, storeError(err) - } - return tx, state.version(), nil -} - -// CreateIn preserves collection naming rules within a larger atomic write. -func CreateIn(ctx context.Context, tx *sql.Tx, id, name string, at int64) error { - if err := ValidateName(name); err != nil { - return err - } - if err := nameTaken(ctx, tx, name, ""); err != nil { - return err - } - _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", id, name, at) - return err -} - -// RenameIn preserves Unicode name uniqueness within a larger atomic write. -func RenameIn(ctx context.Context, tx *sql.Tx, id, name string) error { - if err := ValidateName(name); err != nil { - return err - } - if err := requireCollection(ctx, tx, id); err != nil { - return err - } - if err := nameTaken(ctx, tx, name, id); err != nil { - return err - } - _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id) - return err -} - -func touchWorkstream(ctx context.Context, tx *sql.Tx, id string, at int64) error { - _, err := tx.ExecContext(ctx, "UPDATE workstreams SET updated_at=max(updated_at+1,?) WHERE id=?", at, id) - return err -} - -func checkWorkstreamJob(ctx context.Context, tx *sql.Tx, id string, ref Ref) error { - if ref.Kind != FactoryJobKind { - return nil - } - var taken WorkstreamTaken - var isWorkstream bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstreams WHERE id=?)", id).Scan(&isWorkstream); err != nil { - return err - } - if !isWorkstream { - return nil - } - err := tx.QueryRowContext(ctx, `SELECT j.workstream_id,c.name FROM workstream_jobs j JOIN collections c ON c.id=j.workstream_id - WHERE j.factory=? AND j.job=? AND j.workstream_id!=?`, ref.Factory, ref.ID, id).Scan(&taken.WorkstreamID, &taken.WorkstreamName) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return err - } - n, _ := strconv.ParseInt(ref.ID, 10, 64) - taken.Factory, taken.Job = ref.Factory, int(n) - return &taken -} - -func factoryColumn(version int) string { - if version >= 3 { - return "factory" - } - return "''" -} - -func workstreamColumn(version int) string { - if version >= 3 { - return "EXISTS(SELECT 1 FROM workstreams w WHERE w.id=c.id)" - } - return "0" -} - -// Claims are maintained by triggers so even SQL writes through Write cannot -// accidentally bypass the unique job owner. Plain collections have no claims. -var workstreamSchema = fmt.Sprintf(` -CREATE TABLE workstreams ( - id TEXT PRIMARY KEY REFERENCES collections(id) ON DELETE CASCADE, - why TEXT NOT NULL DEFAULT '', origin_chat_id TEXT NOT NULL DEFAULT '', agent_id TEXT NOT NULL, - autonomy TEXT NOT NULL DEFAULT 'propose' CHECK(autonomy IN ('propose','small','off')), - wake_morning INTEGER NOT NULL DEFAULT 0, wake_related_issues INTEGER NOT NULL DEFAULT 0, - budget_usd REAL CHECK(budget_usd IS NULL OR budget_usd>0), - created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, started_at INTEGER, done_at INTEGER, - how_it_went TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '' -); -CREATE UNIQUE INDEX workstreams_client ON workstreams(client_id) WHERE client_id!=''; -CREATE TABLE workstream_jobs ( - member_seq INTEGER PRIMARY KEY REFERENCES memberships(seq) ON DELETE CASCADE, - workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - factory TEXT NOT NULL, job TEXT NOT NULL -); -CREATE UNIQUE INDEX workstream_job_owner ON workstream_jobs(factory,job); -CREATE TRIGGER workstream_job_insert AFTER INSERT ON memberships - WHEN NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id) - BEGIN INSERT INTO workstream_jobs VALUES(NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id); END; -CREATE TRIGGER workstream_job_update AFTER UPDATE ON memberships - BEGIN - DELETE FROM workstream_jobs WHERE member_seq=OLD.seq; - INSERT INTO workstream_jobs SELECT NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id - WHERE NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id); - END; -CREATE TRIGGER workstream_job_promote AFTER INSERT ON workstreams - BEGIN INSERT INTO workstream_jobs SELECT seq,collection_id,factory,ref_id FROM memberships - WHERE collection_id=NEW.id AND kind='factory_job'; END; -CREATE TABLE workstream_pages ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - parent_id TEXT REFERENCES workstream_pages(id) ON DELETE CASCADE, - title TEXT NOT NULL, owner TEXT NOT NULL CHECK(owner IN ('you','agent')), - read_by_jobs INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL CHECK(position>=0), - version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, - updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), updated_from TEXT, - doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)), - deleted_at INTEGER, deleted_root TEXT -); -CREATE INDEX workstream_page_tree ON workstream_pages(workstream_id,parent_id,position); -CREATE UNIQUE INDEX workstream_overview ON workstream_pages(workstream_id) WHERE overview=1; -CREATE TABLE workstream_page_versions ( - page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, - version INTEGER NOT NULL, saved_at INTEGER NOT NULL, saved_by TEXT NOT NULL CHECK(saved_by IN ('you','agent')), - source TEXT, title TEXT NOT NULL, doc TEXT NOT NULL, bytes INTEGER NOT NULL, - PRIMARY KEY(page_id,version) -); -CREATE TABLE workstream_records ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK(kind IN ('decision','question','goahead')), text TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped','superseded')), - actor TEXT NOT NULL CHECK(actor IN ('you','agent')), source TEXT, created_at INTEGER NOT NULL, - decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id), - goahead TEXT, goahead_id TEXT -); -CREATE INDEX workstream_record_list ON workstream_records(workstream_id,created_at); -CREATE TABLE workstream_goaheads ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - scope TEXT NOT NULL CHECK(scope IN ('job','wave','milestone')), - factory TEXT, job INTEGER, wave INTEGER, milestone_id TEXT, - cap_usd REAL NOT NULL CHECK(cap_usd>0 AND cap_usd<=%d), ends_at INTEGER NOT NULL, - state TEXT NOT NULL CHECK(state IN ('active','ended')), - end_reason TEXT CHECK(end_reason IN ('expired','revoked','workstream_done')), - granted_at INTEGER NOT NULL, ended_at INTEGER, - CHECK((scope='job' AND factory IS NOT NULL AND length(factory)>0 AND job IS NOT NULL AND job>0 AND wave IS NULL AND milestone_id IS NULL) - OR (scope='wave' AND wave IS NOT NULL AND wave>0 AND factory IS NULL AND job IS NULL AND milestone_id IS NULL) - OR (scope='milestone' AND milestone_id IS NOT NULL AND length(milestone_id)>0 AND factory IS NULL AND job IS NULL AND wave IS NULL)), - CHECK(ends_at>granted_at AND ends_at<=granted_at+%d) -); -CREATE INDEX workstream_goahead_list ON workstream_goaheads(workstream_id,state); -CREATE TABLE workstream_plans ( - workstream_id TEXT PRIMARY KEY REFERENCES workstreams(id) ON DELETE CASCADE, - version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, - updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), - milestones TEXT NOT NULL, waves TEXT NOT NULL -); -`, WorkstreamGoAheadCapMax, WorkstreamGoAheadDurationMax.Milliseconds()) + workstreamCollectionKeep - -// Older writers may already hold a connection opened before workstreams existed. -// The database must enforce the same deletion refusal as Store.Delete. -const workstreamCollectionKeep = ` -CREATE TRIGGER IF NOT EXISTS workstream_collection_keep BEFORE DELETE ON collections - WHEN EXISTS(SELECT 1 FROM workstreams WHERE id=OLD.id) - BEGIN SELECT RAISE(ABORT,'mark a workstream done instead'); END; -` - -// Opening a schema-3 file verifies the tables needed by both domain reads and -// collection ownership checks. A partial schema is refused rather than repaired. -var workstreamSchemaReads = []string{ - "SELECT id,why,origin_chat_id,agent_id,autonomy,wake_morning,wake_related_issues,budget_usd,created_at,updated_at,started_at,done_at,how_it_went,client_id FROM workstreams LIMIT 0", - "SELECT member_seq,workstream_id,factory,job FROM workstream_jobs LIMIT 0", - "SELECT id,workstream_id,parent_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview,deleted_at,deleted_root FROM workstream_pages LIMIT 0", - "SELECT page_id,version,saved_at,saved_by,source,title,doc,bytes FROM workstream_page_versions LIMIT 0", - "SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes,superseded_by,goahead,goahead_id FROM workstream_records LIMIT 0", - "SELECT id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,end_reason,granted_at,ended_at FROM workstream_goaheads LIMIT 0", - "SELECT workstream_id,version,updated_at,updated_by,milestones,waves FROM workstream_plans LIMIT 0", -} - -// WorkstreamWakeSchemaVersion keeps admission reads tied to this additive step. -const WorkstreamWakeSchemaVersion = 4 - -// Schema 4 is additive; its tables can be renumbered without changing schema 3. -const workstreamWakeSchema = ` -CREATE TABLE workstream_wakes ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - event_key TEXT NOT NULL, event_json TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('running','settled','failed')), - summary TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', cost_usd REAL, - started_at INTEGER NOT NULL, settled_at INTEGER, dismissed_at INTEGER, - UNIQUE(workstream_id,event_key) -); -CREATE INDEX workstream_wake_list ON workstream_wakes(workstream_id,started_at); -CREATE TABLE workstream_wake_items ( - id TEXT PRIMARY KEY, wake_id TEXT NOT NULL REFERENCES workstream_wakes(id) ON DELETE CASCADE, - position INTEGER NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('move','draft','page','decision','question')), - state TEXT NOT NULL CHECK(state IN ('applied','undone','proposed','dismissed')), - why TEXT NOT NULL DEFAULT '', item_json TEXT NOT NULL, undo_json TEXT, - note TEXT NOT NULL DEFAULT '', applied_at INTEGER, undone_at INTEGER -); -CREATE INDEX workstream_wake_item_list ON workstream_wake_items(wake_id,position); -` - -func migrateWorkstreamWakes(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, workstreamWakeSchema+workstreamCollectionKeep) - return err -} - -// WorkstreamSuggestionSchemaVersion keeps suggestion reads tied to this step. -const WorkstreamSuggestionSchemaVersion = 6 - -// Suggestions are additive to schema 5 and are installed by the first write. -const workstreamSuggestionSchema = ` -CREATE TABLE workstream_suggestions ( - id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, - page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, - base_version INTEGER NOT NULL, why TEXT NOT NULL, actor TEXT NOT NULL, source TEXT, - created_at INTEGER NOT NULL -); -CREATE INDEX workstream_suggestion_page ON workstream_suggestions(page_id,created_at); -CREATE TABLE workstream_suggestion_edits ( - suggestion_id TEXT NOT NULL REFERENCES workstream_suggestions(id) ON DELETE CASCADE, - id TEXT NOT NULL, position INTEGER NOT NULL, block TEXT NOT NULL, at_offset INTEGER NOT NULL, - del TEXT NOT NULL, ins TEXT NOT NULL, block_text TEXT NOT NULL, why TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped')), decided_at INTEGER, - PRIMARY KEY(suggestion_id,id) -); -` - -var workstreamSuggestionSchemaReads = []string{ - "SELECT id,workstream_id,page_id,base_version,why,actor,source,created_at FROM workstream_suggestions LIMIT 0", - "SELECT suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state,decided_at FROM workstream_suggestion_edits LIMIT 0", -} diff --git a/internal/workspace/workstreams_fields_v02_test.go b/internal/workspace/workstreams_fields_v02_test.go deleted file mode 100644 index 6bf939c62a..0000000000 --- a/internal/workspace/workstreams_fields_v02_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package workspace - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// WP-F2 (d), Storage: missing proposal columns refuse at Open without repair. -func TestWorkstreamSchemaThreeRequiresGoAheadRecordColumns(t *testing.T) { - for _, column := range []string{"goahead", "goahead_id"} { - t.Run(column, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "obsolete.db") - s := openTestStore(t, path) - createTestCollection(t, s, "Schema three") - workstreamSchemaThreeFixture(t, s) - if userVersion(t, s) != 3 { - t.Fatal("fixture is not schema 3") - } - if _, err := s.db.Exec("ALTER TABLE workstream_records DROP COLUMN " + column); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - opened, err := Open(path) - if opened != nil { - _ = opened.Close() - } - if err == nil || !strings.Contains(err.Error(), column) { - t.Fatalf("obsolete schema opened: %v", err) - } - after, err := os.ReadFile(path) - if err != nil || string(before) != string(after) { - t.Fatalf("refusal changed file: %v", err) - } - }) - } -} diff --git a/internal/workspace/workstreams_test.go b/internal/workspace/workstreams_test.go deleted file mode 100644 index 128d73a254..0000000000 --- a/internal/workspace/workstreams_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package workspace - -import ( - "context" - "database/sql" - "errors" - "path/filepath" - "testing" -) - -func seedWorkstream(t *testing.T, s *Store, id, name string) { - t.Helper() - if err := s.Write(context.Background(), func(tx *sql.Tx) error { - if err := CreateIn(context.Background(), tx, id, name, 100); err != nil { - return err - } - _, err := tx.Exec("INSERT INTO workstreams(id,agent_id,created_at,updated_at) VALUES (?,?,100,100)", id, "agent-"+id) - return err - }); err != nil { - t.Fatal(err) - } -} - -// Part A (b): the collection store, including relink and raw SQL's unique -// constraint, enforces job ownership; plain collections make no job claim. -func TestPartAWorkstreamMembershipOwnershipIsEnforcedInSQLite(t *testing.T) { - s := openTestStore(t, filepath.Join(t.TempDir(), "claims.db")) - ctx := context.Background() - seedWorkstream(t, s, "a", "First") - seedWorkstream(t, s, "b", "Second") - job := Ref{Kind: FactoryJobKind, ID: "7", Factory: "f"} - fixedClock(s, 200) - if err := s.Add(ctx, "a", job); err != nil { - t.Fatal(err) - } - fixedClock(s, 300) - _, _, err := s.AddRefs(ctx, "b", []Addition{{Ref: Ref{Kind: ConversationKind, ID: "extra"}}, {Ref: job}}) - var taken *WorkstreamTaken - if !errors.As(err, &taken) || taken.WorkstreamID != "a" || taken.WorkstreamName != "First" || taken.Job != 7 || taken.Factory != "f" { - t.Fatalf("taken: %+v %v", taken, err) - } - for id, want := range map[string]int64{"a": 200, "b": 100} { - var at int64 - if err := s.db.QueryRow("SELECT updated_at FROM workstreams WHERE id=?", id).Scan(&at); err != nil || at != want { - t.Fatalf("timestamp %s: %d %v", id, at, err) - } - } - if members, err := s.Members(ctx, "b"); err != nil || len(members) != 0 { - t.Fatalf("partial add: %+v %v", members, err) - } - if err := s.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('b','factory_job','7','','f')") - return err - }); err == nil { - t.Fatal("SQL bypassed unique job owner") - } - plain := createTestCollection(t, s, "Plain") - if err := s.Add(ctx, plain.ID, job); err != nil { - t.Fatal(err) - } - differentFactory := Ref{Kind: FactoryJobKind, ID: "7", Factory: "other"} - if err := s.Add(ctx, "b", differentFactory); err != nil { - t.Fatal(err) - } - if _, _, err := s.Relink(ctx, "b", differentFactory, job); !errors.As(err, &taken) { - t.Fatalf("relink took another owner's job: %v", err) - } - if err := s.Remove(ctx, "a", job); err != nil { - t.Fatal(err) - } - if _, _, err := s.Relink(ctx, "b", differentFactory, job); err != nil { - t.Fatal(err) - } - a, err := s.Describe(ctx, "a") - if err != nil || !a.Workstream { - t.Fatalf("workstream flag: %+v %v", a, err) - } - copy, err := s.Duplicate(ctx, "b", "Copy") - if err != nil { - t.Fatal(err) - } - copySummary, err := s.Describe(ctx, copy.ID) - if err != nil || copySummary.Workstream { - t.Fatalf("plain duplicate: %+v %v", copySummary, err) - } - if _, err := s.Delete(ctx, "a"); !errors.Is(err, ErrWorkstreamDelete) { - t.Fatalf("delete: %v", err) - } -} - -// Part A (a), WP-12 (i): schema 1 upgrades directly to the current schema, -// preserving unknown timestamps and checking a job reference's factory. -func TestPartASchemaOneMigratesStraightToCurrent(t *testing.T) { - s := openTestStore(t, writeLegacyStore(t)) - ctx := context.Background() - if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "11", Factory: "f"}); err != nil { - t.Fatal(err) - } - if userVersion(t, s) != schemaVersion { - t.Fatal("did not migrate directly to the current schema") - } - var count int - if err := s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name LIKE 'workstream%'").Scan(&count); err != nil || count != 12 { - t.Fatalf("workstream tables: %d %v", count, err) - } - var created, added int64 - if err := s.db.QueryRow("SELECT created_at FROM collections WHERE id='aaaa'").Scan(&created); err != nil { - t.Fatal(err) - } - if err := s.db.QueryRow("SELECT added_at FROM memberships WHERE kind='task'").Scan(&added); err != nil { - t.Fatal(err) - } - if created != 0 || added != 0 { - t.Fatal("invented legacy timestamps") - } - for _, q := range []string{ - "INSERT INTO memberships(collection_id,kind,ref_id,session_id) VALUES ('aaaa','factory_job','12','')", - "INSERT INTO memberships(collection_id,kind,ref_id,session_id,factory) VALUES ('aaaa','conversation','other','','f')", - } { - if _, err := s.db.Exec(q); err == nil { - t.Fatalf("accepted invalid factory: %s", q) - } - } -} - -func TestPartAFactoryIsPartOfEveryCollectionReadIdentity(t *testing.T) { - s := openTestStore(t, filepath.Join(t.TempDir(), "identity.db")) - ctx := context.Background() - c := createTestCollection(t, s, "Jobs") - a, b := Ref{Kind: FactoryJobKind, ID: "1", Factory: "a"}, Ref{Kind: FactoryJobKind, ID: "1", Factory: "b"} - if err := s.Add(ctx, c.ID, a); err != nil { - t.Fatal(err) - } - if err := s.Add(ctx, c.ID, b); err != nil { - t.Fatal(err) - } - for _, r := range []Ref{a, b} { - parents, err := s.CollectionsFor(ctx, r) - if err != nil || len(parents) != 1 { - t.Fatalf("find %+v: %+v %v", r, parents, err) - } - } - expansion, err := s.Expand(ctx, c.ID, 1) - if err != nil || len(expansion.Refs) != 2 { - t.Fatalf("expansion: %+v %v", expansion, err) - } - if err := s.Remove(ctx, c.ID, a); err != nil { - t.Fatal(err) - } - members, err := s.Members(ctx, c.ID) - if err != nil || len(members) != 1 || members[0] != b { - t.Fatalf("remove: %+v %v", members, err) - } - list, err := s.List(ctx, &a) - if err != nil || len(list) != 1 || *list[0].Holds { - t.Fatalf("holds: %+v %v", list, err) - } - parents, err := s.CollectionsFor(ctx, a) - if err != nil || len(parents) != 0 { - t.Fatalf("removed find: %+v %v", parents, err) - } -} From 6112e779a61835037ee146e84a2bb5f7ef97ac5b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 17:55:29 -0500 Subject: [PATCH 2/5] app: the engine keeps only the generic pieces of the Workstreams plan Workstreams move into the app, so the engine drops everything that existed only for them: the workstream store and page renderer, the wire methods and hello features, the agent's built-in tools and prompt section, the morning wake scheduler and its replay purpose, the conformance fixtures, and the old launch-cleanup hook. Removing the agent door also removes the copy of it that Fresh and Open carried. What stays is generic: collections hold Factory jobs (the resolver moves to appcollections_factory.go under generic names, with its own tests), client tools, and collection-held conversations that survive cleanup. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf-replay/request.go | 3 - .../workstream_wake_fix2_test.go | 13 - cmd/codeaf/app.go | 9 +- cmd/codeaf/appcollections.go | 28 +- cmd/codeaf/appcollections_factory.go | 246 +++ cmd/codeaf/appcollections_factory_test.go | 319 +++ cmd/codeaf/appworkstreams.go | 1117 ---------- cmd/codeaf/appworkstreams_agent.go | 374 ---- cmd/codeaf/appworkstreams_agent_brief_test.go | 122 -- cmd/codeaf/appworkstreams_agent_done_test.go | 59 - cmd/codeaf/appworkstreams_agent_sources.go | 188 -- .../appworkstreams_agent_sources_test.go | 311 --- cmd/codeaf/appworkstreams_agent_test.go | 583 ------ cmd/codeaf/appworkstreams_conformance_test.go | 1562 -------------- cmd/codeaf/appworkstreams_fields_v02_test.go | 308 --- cmd/codeaf/appworkstreams_pages_test.go | 192 -- .../appworkstreams_source_open_other.go | 30 - cmd/codeaf/appworkstreams_source_open_unix.go | 21 - .../appworkstreams_source_open_unix_test.go | 52 - cmd/codeaf/appworkstreams_sources.go | 51 - cmd/codeaf/appworkstreams_sources_test.go | 514 ----- cmd/codeaf/appworkstreams_suggestions_test.go | 323 --- cmd/codeaf/appworkstreams_test.go | 1338 ------------ cmd/codeaf/appworkstreams_wake.go | 369 ---- cmd/codeaf/appworkstreams_wake_test.go | 452 ---- cmd/codeaf/chatv3_layout.go | 4 +- cmd/codeaf/chatv3_layout_stab_test.go | 85 - cmd/codeaf/collections.go | 6 +- cmd/codeaf/engine.go | 1 - cmd/codeaf/testdata/model_spend_owners.tsv | 1 - .../testdata/workstreams-wire/README.md | 10 - .../workstreams-wire/collections.add.json | 42 - .../testdata/workstreams-wire/contract.json | 1865 ----------------- .../testdata/workstreams-wire/extract.py | 103 - .../workstreams-wire/goahead.grant.json | 39 - .../workstreams-wire/goahead.list.json | 36 - .../workstreams-wire/goahead.revoke.json | 35 - .../workstreams-wire/page.create.json | 59 - .../workstreams-wire/page.delete.json | 25 - .../testdata/workstreams-wire/page.get.json | 60 - .../workstreams-wire/page.history.json | 45 - .../testdata/workstreams-wire/page.list.json | 69 - .../testdata/workstreams-wire/page.move.json | 55 - .../workstreams-wire/page.restore.json | 62 - .../testdata/workstreams-wire/page.save.json | 93 - .../workstreams-wire/page.update.json | 37 - .../workstreams-wire/record.drop.json | 39 - .../workstreams-wire/record.keep.json | 67 - .../workstreams-wire/record.list.json | 70 - .../workstreams-wire/record.propose.json | 88 - .../workstreams-wire/record.supersede.json | 66 - .../testdata/workstreams-wire/source.add.json | 59 - .../workstreams-wire/source.list.json | 73 - .../workstreams-wire/source.remove.json | 34 - .../workstreams-wire/source.update.json | 37 - .../workstreams-wire/suggestion.drop.json | 111 - .../workstreams-wire/suggestion.keep.json | 203 -- .../workstreams-wire/suggestion.list.json | 65 - .../testdata/workstreams-wire/wake.apply.json | 50 - .../testdata/workstreams-wire/wake.list.json | 67 - .../workstreams-wire/wake.not_now.json | 46 - .../testdata/workstreams-wire/wake.undo.json | 51 - .../workstreams-wire/workstream.changed.json | 24 - .../workstreams-wire/workstream.create.json | 63 - .../workstreams-wire/workstream.get.json | 69 - .../workstreams-wire/workstream.list.json | 51 - .../workstream.mark_done.json | 47 - .../workstreams-wire/workstream.plan_get.json | 28 - .../workstreams-wire/workstream.plan_set.json | 103 - .../workstreams-wire/workstream.reopen.json | 43 - .../workstreams-wire/workstream.update.json | 52 - internal/appwire/collections.go | 7 +- .../appwire/collections_factory_job_test.go | 6 - internal/appwire/server.go | 1 - internal/appwire/workstreams.go | 748 ------- internal/appwire/workstreams_check.go | 450 ---- internal/appwire/workstreams_check_test.go | 213 -- .../appwire/workstreams_fields_v02_test.go | 84 - internal/appwire/workstreams_sources.go | 146 -- internal/appwire/workstreams_sources_test.go | 63 - internal/appwire/workstreams_test.go | 296 --- internal/manual/chat/collections.md | 236 +-- internal/session/actioncategory.go | 5 +- internal/session/clientdoor.go | 4 +- internal/session/inherit.go | 2 +- internal/session/manual_test.go | 18 - internal/session/programhold_test.go | 3 +- internal/session/prompt.go | 1 - internal/session/questionconversation.go | 3 +- internal/session/session.go | 3 - internal/session/toolargs_test.go | 2 - internal/session/tools.go | 1 - internal/session/workstream_contract.go | 104 - internal/session/workstream_factory_test.go | 108 - .../session/workstream_fields_v02_test.go | 90 - internal/session/workstream_limits.go | 22 - internal/session/workstream_schema.go | 181 -- internal/session/workstream_sources.go | 53 - internal/session/workstream_sources_test.go | 105 - .../session/workstream_suggestions_test.go | 126 -- internal/session/workstream_tools.go | 545 ----- internal/session/workstream_tools_test.go | 458 ---- .../session/workstream_validation_test.go | 158 -- internal/session/workstream_wake.go | 89 - internal/session/workstream_wake_test.go | 87 - internal/workstream/goaheads.go | 232 -- internal/workstream/legacy_test.go | 76 - internal/workstream/limits_test.go | 134 -- internal/workstream/pagedoc/frommarkdown.go | 359 ---- .../workstream/pagedoc/frommarkdown_test.go | 136 -- internal/workstream/pagedoc/pagedoc.go | 395 ---- internal/workstream/pagedoc/pagedoc_test.go | 414 ---- .../workstream/pagedoc/testdata/example.json | 25 - .../workstream/pagedoc/testdata/example.md | 20 - .../pagedoc/testdata/suggestion-keep.json | 306 --- internal/workstream/pagedoc/textblocks.go | 275 --- .../workstream/pagedoc/textblocks_test.go | 235 --- internal/workstream/pages.go | 711 ------- internal/workstream/pages_test.go | 517 ----- internal/workstream/plan.go | 197 -- internal/workstream/records.go | 310 --- internal/workstream/records_goaheads_test.go | 240 --- internal/workstream/sources.go | 391 ---- internal/workstream/sources_migration_test.go | 210 -- internal/workstream/sources_test.go | 556 ----- internal/workstream/sources_validation.go | 297 --- internal/workstream/store.go | 565 ----- internal/workstream/store_test.go | 215 -- internal/workstream/suggestions.go | 532 ----- internal/workstream/suggestions_test.go | 554 ----- internal/workstream/wake_actions.go | 454 ---- internal/workstream/wake_answer.go | 132 -- internal/workstream/wake_answer_test.go | 44 - internal/workstream/wakes.go | 500 ----- internal/workstream/wakes_test.go | 434 ---- internal/workstream/workstream.go | 264 --- internal/workstream/workstream_agent.go | 57 - .../workstream/workstream_agent_done_test.go | 155 -- internal/workstream/workstream_agent_test.go | 106 - .../workstream/workstream_fields_v02_test.go | 398 ---- .../workstream/workstream_wake_fix1_test.go | 320 --- .../workstream/workstream_wake_fix2_test.go | 185 -- 142 files changed, 590 insertions(+), 27701 deletions(-) delete mode 100644 cmd/codeaf-replay/workstream_wake_fix2_test.go create mode 100644 cmd/codeaf/appcollections_factory.go create mode 100644 cmd/codeaf/appcollections_factory_test.go delete mode 100644 cmd/codeaf/appworkstreams.go delete mode 100644 cmd/codeaf/appworkstreams_agent.go delete mode 100644 cmd/codeaf/appworkstreams_agent_brief_test.go delete mode 100644 cmd/codeaf/appworkstreams_agent_done_test.go delete mode 100644 cmd/codeaf/appworkstreams_agent_sources.go delete mode 100644 cmd/codeaf/appworkstreams_agent_sources_test.go delete mode 100644 cmd/codeaf/appworkstreams_agent_test.go delete mode 100644 cmd/codeaf/appworkstreams_conformance_test.go delete mode 100644 cmd/codeaf/appworkstreams_fields_v02_test.go delete mode 100644 cmd/codeaf/appworkstreams_pages_test.go delete mode 100644 cmd/codeaf/appworkstreams_source_open_other.go delete mode 100644 cmd/codeaf/appworkstreams_source_open_unix.go delete mode 100644 cmd/codeaf/appworkstreams_source_open_unix_test.go delete mode 100644 cmd/codeaf/appworkstreams_sources.go delete mode 100644 cmd/codeaf/appworkstreams_sources_test.go delete mode 100644 cmd/codeaf/appworkstreams_suggestions_test.go delete mode 100644 cmd/codeaf/appworkstreams_test.go delete mode 100644 cmd/codeaf/appworkstreams_wake.go delete mode 100644 cmd/codeaf/appworkstreams_wake_test.go delete mode 100644 cmd/codeaf/chatv3_layout_stab_test.go delete mode 100644 cmd/codeaf/testdata/workstreams-wire/README.md delete mode 100644 cmd/codeaf/testdata/workstreams-wire/collections.add.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/contract.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/extract.py delete mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.grant.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.create.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.delete.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.get.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.history.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.move.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.restore.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.save.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/page.update.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.drop.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.keep.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.propose.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/record.supersede.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/source.add.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/source.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/source.remove.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/source.update.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/suggestion.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.apply.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.not_now.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/wake.undo.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.changed.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.create.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.get.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.list.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json delete mode 100644 cmd/codeaf/testdata/workstreams-wire/workstream.update.json delete mode 100644 internal/appwire/workstreams.go delete mode 100644 internal/appwire/workstreams_check.go delete mode 100644 internal/appwire/workstreams_check_test.go delete mode 100644 internal/appwire/workstreams_fields_v02_test.go delete mode 100644 internal/appwire/workstreams_sources.go delete mode 100644 internal/appwire/workstreams_sources_test.go delete mode 100644 internal/appwire/workstreams_test.go delete mode 100644 internal/session/workstream_contract.go delete mode 100644 internal/session/workstream_factory_test.go delete mode 100644 internal/session/workstream_fields_v02_test.go delete mode 100644 internal/session/workstream_limits.go delete mode 100644 internal/session/workstream_schema.go delete mode 100644 internal/session/workstream_sources.go delete mode 100644 internal/session/workstream_sources_test.go delete mode 100644 internal/session/workstream_suggestions_test.go delete mode 100644 internal/session/workstream_tools.go delete mode 100644 internal/session/workstream_tools_test.go delete mode 100644 internal/session/workstream_validation_test.go delete mode 100644 internal/session/workstream_wake.go delete mode 100644 internal/session/workstream_wake_test.go delete mode 100644 internal/workstream/goaheads.go delete mode 100644 internal/workstream/legacy_test.go delete mode 100644 internal/workstream/limits_test.go delete mode 100644 internal/workstream/pagedoc/frommarkdown.go delete mode 100644 internal/workstream/pagedoc/frommarkdown_test.go delete mode 100644 internal/workstream/pagedoc/pagedoc.go delete mode 100644 internal/workstream/pagedoc/pagedoc_test.go delete mode 100644 internal/workstream/pagedoc/testdata/example.json delete mode 100644 internal/workstream/pagedoc/testdata/example.md delete mode 100644 internal/workstream/pagedoc/testdata/suggestion-keep.json delete mode 100644 internal/workstream/pagedoc/textblocks.go delete mode 100644 internal/workstream/pagedoc/textblocks_test.go delete mode 100644 internal/workstream/pages.go delete mode 100644 internal/workstream/pages_test.go delete mode 100644 internal/workstream/plan.go delete mode 100644 internal/workstream/records.go delete mode 100644 internal/workstream/records_goaheads_test.go delete mode 100644 internal/workstream/sources.go delete mode 100644 internal/workstream/sources_migration_test.go delete mode 100644 internal/workstream/sources_test.go delete mode 100644 internal/workstream/sources_validation.go delete mode 100644 internal/workstream/store.go delete mode 100644 internal/workstream/store_test.go delete mode 100644 internal/workstream/suggestions.go delete mode 100644 internal/workstream/suggestions_test.go delete mode 100644 internal/workstream/wake_actions.go delete mode 100644 internal/workstream/wake_answer.go delete mode 100644 internal/workstream/wake_answer_test.go delete mode 100644 internal/workstream/wakes.go delete mode 100644 internal/workstream/wakes_test.go delete mode 100644 internal/workstream/workstream.go delete mode 100644 internal/workstream/workstream_agent.go delete mode 100644 internal/workstream/workstream_agent_done_test.go delete mode 100644 internal/workstream/workstream_agent_test.go delete mode 100644 internal/workstream/workstream_fields_v02_test.go delete mode 100644 internal/workstream/workstream_wake_fix1_test.go delete mode 100644 internal/workstream/workstream_wake_fix2_test.go diff --git a/cmd/codeaf-replay/request.go b/cmd/codeaf-replay/request.go index f6dc4866b9..1dd449ccb1 100644 --- a/cmd/codeaf-replay/request.go +++ b/cmd/codeaf-replay/request.go @@ -299,9 +299,6 @@ var callSiteRoles = map[string]lane.Role{ // it sets lane.RoleStanding on its own context. The older tag stays so a // log written before the change still reads. "automation-check": lane.RoleStanding, - // internal/session/workstream_wake.go: one workstream's event turn sets - // lane.RoleStanding on its context; nobody is waiting on its stream. - "workstream-wake": lane.RoleStanding, // internal/session/tools_doc.go: the model's own eyes on a document the // `read` tool cannot open as text. A TOOL CALL INSIDE A TURN, so somebody IS // waiting — the one of the three whose seconds are a person's. diff --git a/cmd/codeaf-replay/workstream_wake_fix2_test.go b/cmd/codeaf-replay/workstream_wake_fix2_test.go deleted file mode 100644 index 962a818460..0000000000 --- a/cmd/codeaf-replay/workstream_wake_fix2_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "testing" - - "github.com/Agent-Field/codeaf/internal/lane" -) - -func TestR2_M1_WorkstreamWakeResolvesToStandingRole(t *testing.T) { - if got := roleOf("workstream-wake"); got != lane.RoleStanding { - t.Fatalf("workstream-wake resolved to %q, want %q", got, lane.RoleStanding) - } -} diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 158db343d5..3b68246d0a 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -201,7 +201,6 @@ func runApp(args []string) error { // ONE SUPERVISOR FOR EVERY CONVERSATION THE APP BUILDS (appservices.go). services := openAppServices() appServiceDoor.Store(services) - appWorkstreamAgents.Store(backend) appClientToolsBackend.Store(backend) backend.attachServices(services) guard.Go("app/crews-start", func() { backend.crews() }) @@ -215,7 +214,6 @@ func runApp(args []string) error { defer keepAutomationsWindow("app")() } guard.Go("app/orders", func() { backend.watchOrders(backend.sweepStop) }) - guard.Go("app/workstream-wakes", func() { backend.watchWorkstreamWakes(backend.sweepStop) }) // And what the last quit stopped in the middle of a reply carries on. guard.Go("app/resume-after-quit", backend.resumeAfterQuit) backend.startCloseWatch() @@ -746,7 +744,7 @@ type appBackend struct { collections *workspace.Store collectionsClosed bool collectionsPath string - workstreams appWorkstreams + collectionFactory collectionFactory // services is the app's one supervisor of kept-running processes // (appservices.go); nil is the capability absent. services *appServices @@ -2998,10 +2996,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if held { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - b.spendMoved(func(spend appwire.Spend) { - b.moneyAfter(a, live, spend.TodayUSD) - b.workstreamAgentTurnSettled(id) - }) + b.spendMoved(func(spend appwire.Spend) { b.moneyAfter(a, live, spend.TodayUSD) }) } // ── entry ids ─────────────────────────────────────────────────────────────── diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 370454a14e..8355d5d926 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -35,7 +35,6 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream" ) // appCollectionsPath is where the collections store is, the CLI's own default. @@ -98,16 +97,11 @@ func (b *appBackend) closeCollections() { // appCollectionError is a store refusal in the wire's codes. func appCollectionError(err error) error { var wire *appwire.Error - var taken *workspace.WorkstreamTaken switch { case err == nil: return nil case errors.As(err, &wire): return wire - case errors.As(err, &taken): - return &appwire.Error{Code: appwire.CodeInOtherWorkstream, Message: taken.Error(), Detail: appwire.WorkstreamTaken{Factory: taken.Factory, Job: taken.Job, WorkstreamID: taken.WorkstreamID, WorkstreamName: taken.WorkstreamName}} - case errors.Is(err, workspace.ErrWorkstreamDelete): - return appwire.Errorf(appwire.CodeBadParams, "%s", err) case errors.Is(err, workspace.ErrNameTaken): return appwire.Errorf(appwire.CodeConflict, "%s", workspace.ErrNameTaken.Error()) case errors.Is(err, workspace.ErrCycle): @@ -150,7 +144,7 @@ func appWireMember(m workspace.Member) appwire.Member { } func appWireCollection(s workspace.Summary) appwire.Collection { - return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds, Workstream: s.Workstream} + return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds} } // describe is one collection as the wire says it. @@ -208,20 +202,10 @@ func (b *appBackend) RenameCollection(id, name string) (appwire.Collection, erro if err != nil { return appwire.Collection{}, err } - before, err := s.Describe(context.Background(), id) - if err != nil { - return appwire.Collection{}, appCollectionError(err) - } - if before.Workstream && before.Name == name { - return appWireCollection(before), nil - } if err := s.Rename(context.Background(), id, name); err != nil { return appwire.Collection{}, appCollectionError(err) } b.collectionChanged(id, false) - if before.Workstream && before.Name != name { - b.workstreamCollectionChanged(id, workstream.PartWorkstream) - } return describeCollection(s, id) } @@ -253,7 +237,6 @@ func (b *appBackend) DeleteCollection(id string) error { b.collectionChanged(id, true) for _, parent := range parents { b.collectionChanged(parent, false) - b.workstreamCollectionChanged(parent, workstream.PartJobs) } return nil } @@ -285,7 +268,6 @@ func (b *appBackend) AddToCollection(id string, refs []appwire.Member) (appwire. } if len(added) > 0 { b.collectionChanged(id, false) - b.workstreamCollectionChanged(id, workstream.PartJobs) } return out, nil } @@ -310,7 +292,6 @@ func (b *appBackend) RemoveFromCollection(id string, refs []appwire.Ref) (appwir } if len(removed) > 0 { b.collectionChanged(id, false) - b.workstreamCollectionChanged(id, workstream.PartJobs) } return out, nil } @@ -327,7 +308,7 @@ func (b *appBackend) OpenCollection(id string, depth, limit int, after string) ( return appwire.CollectionOpenResult{}, appCollectionError(err) } r := newCollectionResolver(b) - r.prepareWorkstreamTree(tree.Level) + r.prepareFactoryTree(tree.Level) var nodes func(level workspace.Level) []appwire.Node nodes = func(level workspace.Level) []appwire.Node { out := make([]appwire.Node, 0, len(level.Members)) @@ -379,7 +360,7 @@ func (b *appBackend) ExpandCollection(id string, depth, limit int) (appwire.Coll Missing: e.Deleted, Unread: e.Unread, Collections: e.Collections, More: e.More, } r := newCollectionResolver(b) - r.prepareWorkstreamRefs(e.Refs) + r.prepareFactoryRefs(e.Refs) for _, m := range e.Refs { target := r.target(m.Ref) switch target.State { @@ -421,7 +402,6 @@ func (b *appBackend) RelinkCollection(id string, from, to appwire.Ref) (appwire. // Relinking a member to itself changed nothing, so it says nothing. if from != to { b.collectionChanged(id, false) - b.workstreamCollectionChanged(id, workstream.PartJobs) } return appwire.CollectionRelinkResult{Member: appWireMember(m), Already: already}, nil } @@ -542,7 +522,7 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { switch ref.Kind { case workspace.FactoryJobKind: if _, ok := r.factoryTargets[ref]; !ok { - r.prepareWorkstreamRefs([]workspace.Member{{Ref: ref}}) + r.prepareFactoryRefs([]workspace.Member{{Ref: ref}}) } return r.factoryTargets[ref] case workspace.ConversationKind: diff --git a/cmd/codeaf/appcollections_factory.go b/cmd/codeaf/appcollections_factory.go new file mode 100644 index 0000000000..d5927afd69 --- /dev/null +++ b/cmd/codeaf/appcollections_factory.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "errors" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const ( + factoryReadTimeout = 3 * time.Second + factoryQueueFresh = 10 * time.Second + factoryQueueCacheMax = 32 + factoryMissingJobsMax = 20 + factoryReadParallel = 8 +) + +// collectionFactory belongs to one backend so cached queue reads cannot cross profiles. +type collectionFactory struct { + queue func(context.Context, string) (api.Queue, error) + job func(context.Context, string, int) (api.Job, error) + cacheMu sync.Mutex + cache map[string]factoryQueueRead + flights map[string]chan struct{} +} + +type factoryJob struct { + Factory string + Number int +} + +type factoryQueueRead struct { + at time.Time + jobs map[int]api.Job + err error +} + +// factoryQueue shares successful and failed reads briefly, including an +// in-flight read, so concurrent list/get calls cannot multiply Factory work. +func (b *appBackend) factoryQueue(ctx context.Context, factory string) (entry factoryQueueRead) { + state := &b.collectionFactory + entry, flight, readQueue := state.factoryQueueStart(factory, b.now()) + if flight == nil { + return entry + } + if !readQueue { + select { + case <-flight: + return b.factoryQueue(ctx, factory) + case <-ctx.Done(): + return factoryQueueRead{err: ctx.Err()} + } + } + // Complete ownership even when the guarded reader panics. Only a completed + // read replaces the unavailable entry, so a fault cannot publish success. + entry = factoryQueueRead{err: errors.New("the Factory queue read did not complete")} + defer func() { + entry.at = b.now() + state.factoryQueueFinish(factory, flight, entry) + }() + read := state.queue + if read == nil { + read = b.FactoryQueue + } + queue, err := read(ctx, factory) + jobs := map[int]api.Job{} + for _, j := range queue.Jobs { + jobs[j.ID] = j + } + entry = factoryQueueRead{err: err, jobs: jobs} + return entry +} + +func (state *collectionFactory) factoryQueueStart(factory string, now time.Time) (factoryQueueRead, chan struct{}, bool) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if entry, ok := state.cache[factory]; ok && now.Sub(entry.at) < factoryQueueFresh { + return entry, nil, false + } + if flight := state.flights[factory]; flight != nil { + return factoryQueueRead{}, flight, false + } + if state.flights == nil { + state.flights = map[string]chan struct{}{} + } + flight := make(chan struct{}) + state.flights[factory] = flight + return factoryQueueRead{}, flight, true +} + +func (state *collectionFactory) factoryQueueFinish(factory string, flight chan struct{}, entry factoryQueueRead) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if state.cache == nil { + state.cache = map[string]factoryQueueRead{} + } + if len(state.cache) >= factoryQueueCacheMax { + oldest := "" + var at time.Time + for id, v := range state.cache { + if oldest == "" || v.at.Before(at) { + oldest, at = id, v.at + } + } + delete(state.cache, oldest) + } + state.cache[factory] = entry + delete(state.flights, factory) + close(flight) +} + +type factoryJobReading struct { + job api.Job + err error + notFound bool +} + +// factoryJobReads gives each factory one concurrent queue read. Missing +// jobs share a global fallback allowance and the same overall deadline. +func (b *appBackend) factoryJobReads(jobs []factoryJob) map[factoryJob]factoryJobReading { + out := map[factoryJob]factoryJobReading{} + grouped := map[string][]factoryJob{} + for _, j := range jobs { + if _, seen := out[j]; !seen { + out[j] = factoryJobReading{err: context.DeadlineExceeded} + grouped[j.Factory] = append(grouped[j.Factory], j) + } + } + if len(grouped) == 0 { + return out + } + ctx, cancel := context.WithTimeout(context.Background(), factoryReadTimeout) + defer cancel() + var wg sync.WaitGroup + var mu sync.Mutex + missing := 0 + factoryMissingAllowed := func() bool { + mu.Lock() + defer mu.Unlock() + if missing >= factoryMissingJobsMax { + return false + } + missing++ + return true + } + factoryRememberRead := func(j factoryJob, read factoryJobReading) { + mu.Lock() + defer mu.Unlock() + out[j] = read + } + slots := make(chan struct{}, factoryReadParallel) + for factory, members := range grouped { + wg.Add(1) + guard.Go("app/collection-factory", func() { + defer wg.Done() + select { + case slots <- struct{}{}: + defer func() { <-slots }() + case <-ctx.Done(): + return + } + queue := b.factoryQueue(ctx, factory) + for _, j := range members { + job, ok := queue.jobs[j.Number] + err := queue.err + if err == nil && !ok { + allowed := factoryMissingAllowed() + switch { + case !allowed: + err = errors.New("the missing-job read allowance was reached") + case ctx.Err() != nil: + err = ctx.Err() + default: + read := b.collectionFactory.job + if read == nil { + read = b.FactoryJob + } + job, err = read(ctx, j.Factory, j.Number) + if err == nil && job.ID != j.Number { + err = errors.New("the Factory returned another job") + } + } + } + var wire *appwire.Error + notFound := queue.err == nil && errors.As(err, &wire) && wire.Code == appwire.CodeNotFound + factoryRememberRead(j, factoryJobReading{job: job, err: err, notFound: notFound}) + } + }) + } + wg.Wait() + return out +} + +// Collection resolution uses the same bounded batch reader, with each job +// asked once even when it appears through several nested collections. +func (r *collectionResolver) prepareFactoryRefs(members []workspace.Member) { + jobs := []factoryJob{} + refs := map[factoryJob]workspace.Ref{} + for _, member := range members { + if member.Kind != workspace.FactoryJobKind { + continue + } + number, _ := strconv.Atoi(member.ID) + job := factoryJob{Factory: member.Factory, Number: number} + jobs = append(jobs, job) + refs[job] = member.Ref + } + r.factoryTargets = map[workspace.Ref]appwire.Target{} + for job, read := range r.b.factoryJobReads(jobs) { + var target appwire.Target + if read.err != nil { + if read.notFound { + target = appwire.Target{State: appwire.TargetDeleted} + } else { + target = appUnknown("the Factory", read.err) + target.Status = "unknown" + } + } else { + target = appwire.Target{State: appwire.TargetLive, Title: read.job.Title, Status: read.job.State, Repo: read.job.Repo} + } + r.factoryTargets[refs[job]] = target + } +} + +func (r *collectionResolver) prepareFactoryTree(level workspace.Level) { + members := []workspace.Member{} + var collect func(workspace.Level) + collect = func(level workspace.Level) { + for _, node := range level.Members { + if node.Kind == workspace.FactoryJobKind { + members = append(members, node.Member) + } + if node.Children != nil { + collect(*node.Children) + } + } + } + collect(level) + r.prepareFactoryRefs(members) +} diff --git a/cmd/codeaf/appcollections_factory_test.go b/cmd/codeaf/appcollections_factory_test.go new file mode 100644 index 0000000000..0530e2c7e7 --- /dev/null +++ b/cmd/codeaf/appcollections_factory_test.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" +) + +func collectionFactoryRef(factory string, job int) appwire.Member { + return appwire.Member{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: fmt.Sprint(job), Factory: factory}} +} + +func collectionFactoryCreate(t *testing.T, b *appBackend, name string, refs ...appwire.Member) string { + t.Helper() + c, err := b.CreateCollection(name) + if err != nil { + t.Fatal(err) + } + if len(refs) > 0 { + if _, err := b.AddToCollection(c.ID, refs); err != nil { + t.Fatal(err) + } + } + return c.ID +} + +// Contract (b), G1: the moved batch reader resolves nested refs once per job, +// reads distinct factories concurrently, and shares cache entries with expand. +func TestCollectionFactoryBatchCacheAndNestedTargets(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + nested := collectionFactoryCreate(t, b, "Nested", collectionFactoryRef("f", 7), collectionFactoryRef("g", 9)) + root := collectionFactoryCreate(t, b, "Root", collectionFactoryRef("f", 7), appwire.Member{Ref: appwire.Ref{Kind: appwire.RefCollection, ID: nested}}) + entered := make(chan string, 2) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var mu sync.Mutex + calls := map[string]int{} + b.collectionFactory.queue = func(ctx context.Context, factory string) (api.Queue, error) { + func() { mu.Lock(); defer mu.Unlock(); calls[factory]++ }() + entered <- factory + select { + case <-release: + case <-ctx.Done(): + return api.Queue{}, ctx.Err() + } + id := 7 + if factory == "g" { + id = 9 + } + return api.Queue{Jobs: []api.Job{{ID: id, Num: 1107, Title: "Recorded title", State: "backlog", Repo: "acme/api"}}}, nil + } + done := make(chan error, 1) + guard.Go("app/collection-factory-open-test", func() { + open, err := b.OpenCollection(root, 3, 200, "") + if err == nil { + var visit func([]appwire.Node) + found := 0 + visit = func(nodes []appwire.Node) { + for _, node := range nodes { + if node.Kind == appwire.RefFactoryJob { + found++ + if node.Target.State != appwire.TargetLive || node.Target.Title != "Recorded title" || node.Target.Status != "backlog" || node.Target.Repo != "acme/api" { + t.Errorf("target: %+v", node.Target) + } + } + if node.Children != nil { + visit(node.Children.Members) + } + } + } + visit(open.Members) + if found != 3 { + t.Errorf("resolved %d job refs, want 3", found) + } + } + done <- err + }) + for range 2 { + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("factories were not read concurrently") + } + } + releaseOnce.Do(func() { close(release) }) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("open did not finish") + } + if _, err := b.ExpandCollection(root, 3, 200); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if calls["f"] != 1 || calls["g"] != 1 { + t.Fatalf("queue calls: %v", calls) + } +} + +// Contract (b), G1: missing-job reads have a shared deadline and global bound; +// duplicate refs are asked once and unavailable queues never trigger fallbacks. +func TestCollectionFactoryMissingJobReadsAreBounded(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + refs := []appwire.Member{} + for i := 1; i <= 25; i++ { + refs = append(refs, collectionFactoryRef("f", i)) + } + id := collectionFactoryCreate(t, b, "Missing", refs...) + queueReads, jobReads := 0, 0 + b.collectionFactory.queue = func(ctx context.Context, _ string) (api.Queue, error) { + queueReads++ + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > factoryReadTimeout { + t.Error("queue read has no bounded deadline") + } + return api.Queue{}, nil + } + b.collectionFactory.job = func(ctx context.Context, _ string, job int) (api.Job, error) { + jobReads++ + if _, ok := ctx.Deadline(); !ok { + t.Error("job read has no deadline") + } + return api.Job{ID: job, Num: 999, State: "backlog"}, nil + } + open, err := b.OpenCollection(id, 1, 200, "") + live, unknown := 0, 0 + for _, node := range open.Members { + switch node.Target.State { + case appwire.TargetLive: + live++ + case appwire.TargetUnknown: + unknown++ + } + } + if err != nil || queueReads != 1 || jobReads != 20 || live != 20 || unknown != 5 { + t.Fatalf("bounded fallback: %+v, %v; queue %d jobs %d", open, err, queueReads, jobReads) + } + b.collectionFactory.cache = nil + jobReads = 0 + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, errors.New("unreachable") } + open, err = b.OpenCollection(id, 1, 200, "") + if err != nil || jobReads != 0 { + t.Fatalf("unavailable queue triggered fallbacks: %v, %d", err, jobReads) + } + for _, node := range open.Members { + if node.Target.State != appwire.TargetUnknown || node.Target.Status != "unknown" { + t.Fatalf("unavailable target: %+v", node.Target) + } + } + b.collectionFactory.cache = nil + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, nil } + jobReads = 0 + reads := b.factoryJobReads([]factoryJob{{Factory: "f", Number: 7}, {Factory: "f", Number: 7}}) + if len(reads) != 1 || jobReads != 1 { + t.Fatalf("duplicate job read %d times: %v", jobReads, reads) + } +} + +// Contract (b), G1: a missing job is deleted, a queue error or a wrong returned +// job is unknown, and a short cache shares both successes and failures. +func TestCollectionFactoryDeletedUnknownAndCacheExpiry(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + id := collectionFactoryCreate(t, b, "Targets", collectionFactoryRef("f", 7)) + clock := time.Now() + b.now = func() time.Time { return clock } + calls := 0 + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { calls++; return api.Queue{}, nil } + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { + return api.Job{}, appwire.Errorf(appwire.CodeNotFound, "gone") + } + assertTarget := func(state string) { + t.Helper() + open, err := b.OpenCollection(id, 1, 200, "") + if err != nil || len(open.Members) != 1 || open.Members[0].Target.State != state { + t.Fatalf("target: %+v, %v", open, err) + } + } + assertTarget(appwire.TargetDeleted) + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { return api.Job{ID: 8}, nil } + assertTarget(appwire.TargetUnknown) + if calls != 1 { + t.Fatalf("queue was not shared: %d", calls) + } + clock = clock.Add(factoryQueueFresh) + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { + calls++ + return api.Queue{}, errors.New("unreachable") + } + assertTarget(appwire.TargetUnknown) + assertTarget(appwire.TargetUnknown) + if calls != 2 { + t.Fatalf("failed queue cache: %d", calls) + } + clock = clock.Add(factoryQueueFresh) + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { + calls++ + return api.Queue{Jobs: []api.Job{{ID: 7, Title: "Back", State: "running"}}}, nil + } + assertTarget(appwire.TargetLive) + if calls != 3 { + t.Fatalf("queue did not retry after expiry: %d", calls) + } +} + +// factoryQueueWaitContext signals when a coalesced reader selects on Done, +// so the panic cannot race ahead of the waiter entering the flight branch. +type factoryQueueWaitContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *factoryQueueWaitContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +// Contract (b), G1: the moved guarded reader releases its flight on panic, +// caches the unavailable result and permits a successful retry after expiry. +func TestCollectionFactoryPanicFinishesFlight(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + id := collectionFactoryCreate(t, b, "Panic", collectionFactoryRef("f", 7)) + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + b.now = func() time.Time { return time.UnixMilli(clock.Load()) } + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var calls atomic.Int32 + b.collectionFactory.queue = func(ctx context.Context, _ string) (api.Queue, error) { + if calls.Add(1) == 1 { + close(entered) + select { + case <-release: + panic("queue reader fault") + case <-ctx.Done(): + return api.Queue{}, ctx.Err() + } + } + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil + } + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { + t.Error("failed queue triggered fallback") + return api.Job{}, errors.New("unexpected fallback") + } + owner := make(chan appwire.CollectionOpenResult, 1) + guard.Go("app/collection-factory-panic-owner-test", func() { + open, err := b.OpenCollection(id, 1, 200, "") + if err != nil { + t.Error(err) + } + owner <- open + }) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("reader did not enter") + } + _, flight, owns := b.collectionFactory.factoryQueueStart("f", b.now()) + if flight == nil || owns { + t.Fatal("reader did not own a shared flight") + } + ctx, cancel := context.WithTimeout(context.Background(), factoryReadTimeout) + defer cancel() + waitCtx := &factoryQueueWaitContext{Context: ctx, waiting: make(chan struct{})} + waiter := make(chan factoryQueueRead, 1) + guard.Go("app/collection-factory-panic-waiter-test", func() { waiter <- b.factoryQueue(waitCtx, "f") }) + select { + case <-waitCtx.waiting: + case <-time.After(time.Second): + t.Fatal("waiter did not enter flight") + } + releaseOnce.Do(func() { close(release) }) + select { + case open := <-owner: + if len(open.Members) != 1 || open.Members[0].Target.State != appwire.TargetUnknown { + t.Fatalf("panic target: %+v", open) + } + case <-time.After(time.Second): + t.Fatal("owner did not finish") + } + select { + case <-flight: + default: + t.Fatal("panic left flight open") + } + select { + case read := <-waiter: + if read.err == nil || errors.Is(read.err, context.DeadlineExceeded) || !read.at.Equal(b.now()) { + t.Fatalf("cached failure: %+v", read) + } + case <-time.After(time.Second): + t.Fatal("panic stranded waiter") + } + entry, flight, owns := b.collectionFactory.factoryQueueStart("f", b.now()) + if entry.err == nil || flight != nil || owns || calls.Load() != 1 { + t.Fatal("panic did not cache failure and release flight") + } + clock.Add((factoryQueueFresh - time.Millisecond).Milliseconds()) + if got := b.factoryQueue(ctx, "f"); got.err == nil || calls.Load() != 1 { + t.Fatalf("failure not cached: %+v", got) + } + clock.Add(1) + if got := b.factoryQueue(ctx, "f"); got.err != nil || got.jobs[7].ID != 7 || calls.Load() != 2 { + t.Fatalf("retry after expiry: %+v", got) + } +} diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go deleted file mode 100644 index 848292dc8a..0000000000 --- a/cmd/codeaf/appworkstreams.go +++ /dev/null @@ -1,1117 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "errors" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/factory/api" - "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/home" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -var _ appwire.Workstreamer = (*appBackend)(nil) - -const ( - workstreamFactoryTimeout = 3 * time.Second - workstreamFactoryFresh = 10 * time.Second - workstreamFactoryCacheMax = 32 - workstreamMissingJobsMax = 20 - workstreamFactoryParallel = 8 -) - -// appWorkstreams belongs to one backend, so cache entries and create retries -// cannot cross profiles. The function seams replace external owners in tests. -type appWorkstreams struct { - createMu sync.Mutex - createAgent func(appwire.CreateAsk) (appwire.Agent, error) - closeAgent func(string) error - archiveChats func([]string) error - queue func(context.Context, string) (api.Queue, error) - job func(context.Context, string, int) (api.Job, error) - spend func(string) *float64 - cacheMu sync.Mutex - cache map[string]workstreamQueueRead - flights map[string]chan struct{} - briefMu sync.Mutex - briefs map[string]session.WorkstreamBrief - wakeTurn func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) - wakeDispatch func(workstreamWakeTask) - wakeMu sync.Mutex - wakeQueue []workstreamWakeTask - wakeSignal chan struct{} - wakeCancel context.CancelFunc -} - -type workstreamQueueRead struct { - at time.Time - jobs map[int]api.Job - err error -} - -func (b *appBackend) ServesWorkstreams() bool { return true } - -func (b *appBackend) workstreamStore(create bool) (*workstream.Store, error) { - s, err := b.collectionStore(create) - if err != nil || s == nil { - return nil, err - } - return workstream.New(s, workstream.Options{Now: b.now}), nil -} - -// workstreamDo emits committed receipts, including a read's first observation. -// Unchanged and refused writes have empty receipts and do not notify watchers. -func workstreamDo[P interface{ Check() error }, R any](b *appBackend, p P, call func(*workstream.Store) (R, workstream.Change, error)) (R, error) { - var zero R - if err := p.Check(); err != nil { - return zero, err - } - s, err := b.workstreamStore(false) - if err != nil { - return zero, err - } - if s == nil { - return zero, appwire.Errorf(appwire.CodeNotFound, "workstream target not found") - } - out, change, err := call(s) - // A refresh can fail after a write commits; its receipt still belongs to watchers. - b.workstreamChanged(change) - if err != nil { - return zero, appWorkstreamError(err) - } - return out, nil -} - -func appWorkstreamError(err error) error { - var stale *workstream.VersionConflict - switch { - case errors.As(err, &stale): - return &appwire.Error{Code: appwire.CodeVersionConflict, Message: stale.Error(), Detail: appwire.WorkstreamVersionConflict{ID: stale.ID, Version: stale.Version, UpdatedAt: stale.UpdatedAt, UpdatedBy: stale.UpdatedBy}} - case errors.Is(err, workstream.ErrNotFound): - return appwire.Errorf(appwire.CodeNotFound, "%s", err) - case errors.Is(err, workstream.ErrConflict): - return appwire.Errorf(appwire.CodeConflict, "%s", err) - case errors.Is(err, workstream.ErrCycle): - return appwire.Errorf(appwire.CodeCycle, "%s", err) - case errors.Is(err, workstream.ErrTooLarge), errors.Is(err, workstream.ErrLimit): - return appwire.Errorf(appwire.CodeTooLarge, "%s", err) - case errors.Is(err, workstream.ErrNotAgentPage): - return appwire.Errorf(appwire.CodeBadParams, "%s", err) - } - return appCollectionError(err) -} - -func (b *appBackend) workstreamChanged(change workstream.Change) { - if change.ID == "" || len(change.Parts) == 0 { - return - } - parts := make([]string, 0, len(change.Parts)) - for _, p := range change.Parts { - parts = append(parts, string(p)) - } - b.emit.Emit(appwire.EventWorkstreamChanged, appwire.WorkstreamChanged{ID: change.ID, Parts: parts}) -} - -// workstreamCollectionChanged is used after collection writes that share the -// workstream's identity. Only workstream collections gain this second event. -func (b *appBackend) workstreamCollectionChanged(id string, part workstream.ChangedPart) { - s, err := b.existingStore() - if err != nil { - return - } - c, err := s.Describe(context.Background(), id) - if err == nil && c.Workstream { - parts := []workstream.ChangedPart{workstream.PartWorkstream} - if part != workstream.PartWorkstream { - parts = append(parts, part) - } - b.workstreamChanged(workstream.Change{ID: id, Parts: parts}) - } -} - -func workstreamValue[T any](p *T) T { - if p != nil { - return *p - } - var zero T - return zero -} -func workstreamOptional(s string) *string { - if s == "" { - return nil - } - return &s -} -func workstreamPositive(n int) *int { - if n == 0 { - return nil - } - return &n -} - -func appWorkstream(w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts, spend *float64) appwire.Workstream { - jobs := w.Jobs() - states := make([]string, 0, w.Total) - for _, j := range jobs { - states = append(states, facts[j].State) - } - numbers := workstream.CountNumbers(jobs, facts, spend, w.Proposed) - return appwire.Workstream{ID: w.ID, Name: w.Name, Why: w.Why, Stage: workstream.Stage(w.DoneAt, w.StartedAt, states), AgentID: w.AgentID, OriginChatID: workstreamOptional(w.OriginChatID), Autonomy: w.Autonomy, Wake: appwire.WorkstreamWake{Morning: w.Wake.Morning, RelatedIssues: w.Wake.RelatedIssues}, BudgetUSD: w.BudgetUSD, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, StartedAt: w.StartedAt, DoneAt: w.DoneAt, HowItWent: workstreamOptional(w.HowItWent), Total: numbers.Total, Merged: numbers.Merged, NeedsYou: numbers.NeedsYou, SpendUSD: numbers.SpendUSD} -} - -func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.WorkstreamListResult, error) { - if err := p.Check(); err != nil { - return appwire.WorkstreamListResult{}, err - } - s, err := b.workstreamStore(false) - out := appwire.WorkstreamListResult{Workstreams: []appwire.WorkstreamRow{}} - if err != nil || s == nil { - return out, err - } - rows, err := s.List(context.Background()) - if err != nil { - return out, appWorkstreamError(err) - } - jobs := []workstream.Job{} - for _, w := range rows { - b.workstreamRememberBrief(w) - jobs = append(jobs, w.Jobs()...) - } - facts := b.workstreamFacts(jobs) - for _, w := range rows { - var c workstream.Change - w, c, err = b.workstreamObserved(s, w, facts, workstream.Change{}) - b.workstreamChanged(c) - if err != nil { - return appwire.WorkstreamListResult{}, err - } - b.workstreamRememberBrief(w) - v := appWorkstream(w, facts, b.workstreamSpend(w.AgentID)) - out.Workstreams = append(out.Workstreams, appwire.WorkstreamRow{ID: v.ID, Name: v.Name, Stage: v.Stage, Total: v.Total, Merged: v.Merged, NeedsYou: v.NeedsYou, SpendUSD: v.SpendUSD, BudgetUSD: v.BudgetUSD, UpdatedAt: v.UpdatedAt, AgentID: v.AgentID, OriginChatID: v.OriginChatID}) - } - return out, nil -} - -func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.WorkstreamGetResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGetResult, workstream.Change, error) { - w, err := s.Get(context.Background(), p.ID) - if err != nil { - return appwire.WorkstreamGetResult{}, workstream.Change{}, err - } - b.workstreamRememberBrief(w) - facts := b.workstreamFacts(w.Jobs()) - w, c, err := b.workstreamObserved(s, w, facts, workstream.Change{}) - return appwire.WorkstreamGetResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err - }) -} - -// workstreamAgentAsk is the single agent-creation hook for workstreams. Empty -// text is a bare open in prepareCreate and cannot start a model turn. -func workstreamAgentAsk(p appwire.WorkstreamCreateParams) appwire.CreateAsk { - // Engine assembly attaches the prompt and tools through appWorkstreamAgentDoor. - return appwire.CreateAsk{Model: workstreamValue(p.Model), CreatedBy: workstreamCreatedBy} -} - -// workstreamPreflight checks store-owned refusals without writing or migrating. -// The final Create transaction repeats ownership and naming checks for peers. -func workstreamPreflight(s *workspace.Store, p appwire.WorkstreamCreateParams) error { - ctx := context.Background() - return s.Read(ctx, func(tx *sql.Tx, version int) error { - if version == 0 { - for _, r := range p.Refs { - if r.Kind == appwire.RefCollection { - return workspace.ErrNotFound - } - } - return nil - } - rows, err := tx.QueryContext(ctx, "SELECT name FROM collections") - if err != nil { - return err - } - taken := false - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - rows.Close() - return err - } - taken = taken || strings.EqualFold(name, strings.TrimSpace(p.Name)) - } - err = rows.Err() - rows.Close() - if err != nil { - return err - } - if taken { - return workspace.ErrNameTaken - } - for _, r := range p.Refs { - if r.Kind == appwire.RefCollection { - var exists bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM collections WHERE id=?)", r.ID).Scan(&exists); err != nil { - return err - } - if !exists { - return workspace.ErrNotFound - } - } - if version >= 3 && r.Kind == appwire.RefFactoryJob { - var held workspace.WorkstreamTaken - held.Factory = workstreamValue(r.Factory) - held.Job, _ = strconv.Atoi(r.ID) - err := tx.QueryRowContext(ctx, `SELECT j.workstream_id,c.name FROM workstream_jobs j JOIN collections c ON c.id=j.workstream_id WHERE j.factory=? AND j.job=?`, held.Factory, held.Job).Scan(&held.WorkstreamID, &held.WorkstreamName) - if err == nil { - return &held - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - } - } - return nil - }) -} - -func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire.WorkstreamCreateResult, error) { - var zero appwire.WorkstreamCreateResult - if err := p.Check(); err != nil { - return zero, err - } - b.workstreams.createMu.Lock() - defer b.workstreams.createMu.Unlock() - // A retry is recognized before origin lookup or agent creation. The domain - // checks client_id again inside its transaction to settle competing peers. - existing, err := b.workstreamStore(false) - if err != nil { - return zero, err - } - if existing != nil && workstreamValue(p.ClientID) != "" { - w, err := existing.ByClientID(context.Background(), *p.ClientID) - if err == nil { - b.workstreamRememberBrief(w) - return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil - } - if !errors.Is(err, workstream.ErrNotFound) { - return zero, appWorkstreamError(err) - } - } - if err := b.workstreamCreatePreflight(p); err != nil { - return zero, err - } - create := b.workstreams.createAgent - if create == nil { - create = b.CreateAgent - } - agent, err := create(workstreamAgentAsk(p)) - if err != nil { - return zero, err - } - w, change, err := b.workstreamCreateWithAgent(p, agent) - if err != nil { - return zero, appWorkstreamError(err) - } - b.workstreamRememberBrief(w) - b.workstreamChanged(change) - if change.ID != "" { - b.collectionChanged(w.ID, false) - } - return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil -} - -func (b *appBackend) workstreamCreatePreflight(p appwire.WorkstreamCreateParams) error { - // Source paths can disappear after creation, so check them only for a new client. - sources := make([]workstream.SourceAdd, 0, len(p.Sources)) - for _, source := range p.Sources { - sources = append(sources, source.SourceAdd()) - } - if err := workstream.ValidateCreateSources(sources); err != nil { - return appWorkstreamError(err) - } - if p.OriginChatID != nil { - if _, err := b.find(*p.OriginChatID); err != nil { - return err - } - } - store, err := b.collectionStore(false) - if err != nil { - return err - } - if store != nil { - return appWorkstreamError(workstreamPreflight(store, p)) - } - for _, ref := range p.Refs { - if ref.Kind == appwire.RefCollection { - return appCollectionError(workspace.ErrNotFound) - } - } - return nil -} - -func (b *appBackend) workstreamCreateWithAgent(p appwire.WorkstreamCreateParams, agent appwire.Agent) (workstream.Workstream, workstream.Change, error) { - refs := make([]workspace.Ref, 0, len(p.Refs)) - for _, r := range p.Refs { - refs = append(refs, workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: workstreamValue(r.SessionID), Factory: workstreamValue(r.Factory)}) - } - var w workstream.Workstream - var change workstream.Change - store, err := b.collectionStore(true) - if err == nil { - s := workstream.New(store, workstream.Options{Now: b.now}) - sources := make([]workstream.SourceAdd, 0, len(p.Sources)) - for _, source := range p.Sources { - sources = append(sources, source.SourceAdd()) - } - w, change, err = s.Create(context.Background(), workstream.Create{Name: strings.TrimSpace(p.Name), Why: workstreamValue(p.Why), OriginChatID: workstreamValue(p.OriginChatID), Refs: refs, AgentID: agent.ID, ClientID: workstreamValue(p.ClientID), Sources: sources}) - } - if err != nil || w.AgentID != agent.ID { - if cleanup := b.workstreamCloseUnusedAgent(agent.ID, err); cleanup != nil { - return workstream.Workstream{}, workstream.Change{}, cleanup - } - } - return w, change, err -} - -func (b *appBackend) workstreamCloseUnusedAgent(id string, cause error) error { - closeAgent := b.workstreams.closeAgent - if closeAgent == nil { - closeAgent = func(id string) error { return b.MoveToClosed([]string{id}) } - } - if cleanup := closeAgent(id); cleanup != nil { - var wire *appwire.Error - if cause != nil && errors.As(appWorkstreamError(cause), &wire) { - copy := *wire - copy.Message += "; the unused workstream agent could not be closed" - return © - } - return appwire.Errorf(appwire.CodeEngine, "the unused workstream agent could not be closed: %s", appUnaskedReason("the conversation", cleanup)) - } - return nil -} - -func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamUpdateResult, workstream.Change, error) { - patch := workstream.Patch{Name: p.Patch.Name, Why: p.Patch.Why, Autonomy: p.Patch.Autonomy, BudgetUSD: p.Patch.BudgetUSD} - if p.Patch.Wake != nil { - patch.Wake = &workstream.Wake{Morning: p.Patch.Wake.Morning, RelatedIssues: p.Patch.Wake.RelatedIssues} - } - w, c, err := s.Update(context.Background(), p.ID, patch) - if err != nil { - return appwire.WorkstreamUpdateResult{}, c, err - } - b.workstreamRememberBrief(w) - if c.ID != "" && p.Patch.Name != nil { - b.collectionChanged(w.ID, false) - } - facts := b.workstreamFacts(w.Jobs()) - w, c, err = b.workstreamObserved(s, w, facts, c) - return appwire.WorkstreamUpdateResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err - }) -} - -func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (appwire.WorkstreamMarkDoneResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamMarkDoneResult, workstream.Change, error) { - w, c, err := s.MarkDone(context.Background(), p.ID, workstreamValue(p.HowItWent)) - if err != nil { - return appwire.WorkstreamMarkDoneResult{}, c, err - } - b.workstreamRememberBrief(w) - facts := b.workstreamFacts(w.Jobs()) - w, c, err = b.workstreamObserved(s, w, facts, c) - // The workstream is already durable even if archiving a chat fails. Publish - // that receipt first; retrying mark_done can finish the archive without a - // second workstream mutation or notification. - b.workstreamChanged(c) - if err != nil { - return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err - } - if workstreamValue(p.ArchiveChats) { - ids := []string{} - for _, r := range w.Members { - if r.Kind == workspace.ConversationKind && r.ID != w.AgentID { - ids = append(ids, r.ID) - } - } - archive := b.workstreams.archiveChats - if archive == nil { - archive = b.Archive - } - if len(ids) > 0 { - err = archive(ids) - } - } - if err != nil { - return appwire.WorkstreamMarkDoneResult{}, workstream.Change{}, err - } - return appwire.WorkstreamMarkDoneResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, workstream.Change{}, nil - }) -} - -func (b *appBackend) WorkstreamReopen(p appwire.WorkstreamReopenParams) (appwire.WorkstreamReopenResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamReopenResult, workstream.Change, error) { - w, c, err := s.Reopen(context.Background(), p.ID) - if err != nil { - return appwire.WorkstreamReopenResult{}, c, err - } - b.workstreamRememberBrief(w) - facts := b.workstreamFacts(w.Jobs()) - w, c, err = b.workstreamObserved(s, w, facts, c) - return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err - }) -} - -func appWorkstreamPlan(p workstream.Plan) appwire.WorkstreamPlan { - out := appwire.WorkstreamPlan{WorkstreamID: p.WorkstreamID, Version: p.Version, UpdatedAt: p.UpdatedAt, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}} - for _, m := range p.Milestones { - out.Milestones = append(out.Milestones, appwire.WorkstreamMilestone{ID: m.ID, Title: m.Title}) - } - for _, w := range p.Waves { - wave := appwire.WorkstreamWave{N: w.N, Title: workstreamOptional(w.Title), MilestoneID: workstreamOptional(w.MilestoneID), AutoStart: w.AutoStart, Items: []appwire.WorkstreamPlanItem{}} - for _, i := range w.Items { - wave.Items = append(wave.Items, appwire.WorkstreamPlanItem{Kind: i.Kind, Factory: workstreamOptional(i.Factory), Job: workstreamPositive(i.Job), DraftID: workstreamOptional(i.DraftID), Title: workstreamOptional(i.Title), Repo: workstreamOptional(i.Repo), Text: workstreamOptional(i.Text), Size: workstreamOptional(i.Size), BudgetUSD: workstreamDraftBudget(i.BudgetUSD), Source: appWorkstreamSource(i.Source)}) - } - out.Waves = append(out.Waves, wave) - } - return out -} -func (b *appBackend) WorkstreamPlanGet(p appwire.WorkstreamPlanGetParams) (appwire.WorkstreamPlanGetResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPlanGetResult, workstream.Change, error) { - v, err := s.Plan(context.Background(), p.ID) - return appwire.WorkstreamPlanGetResult{Plan: appWorkstreamPlan(v)}, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamPlanSet(p appwire.WorkstreamPlanSetParams) (appwire.WorkstreamPlanSetResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPlanSetResult, workstream.Change, error) { - milestones := make([]workstream.Milestone, 0, len(p.Milestones)) - waves := make([]workstream.Wave, 0, len(p.Waves)) - for _, m := range p.Milestones { - milestones = append(milestones, workstream.Milestone{ID: m.ID, Title: m.Title}) - } - for _, w := range p.Waves { - wave := workstream.Wave{N: w.N, Title: workstreamValue(w.Title), MilestoneID: workstreamValue(w.MilestoneID), AutoStart: w.AutoStart, Items: []workstream.PlanItem{}} - for _, i := range w.Items { - wave.Items = append(wave.Items, workstream.PlanItem{Kind: i.Kind, Factory: workstreamValue(i.Factory), Job: workstreamValue(i.Job), DraftID: workstreamValue(i.DraftID), Title: workstreamValue(i.Title), Repo: workstreamValue(i.Repo), Text: workstreamValue(i.Text), Size: workstreamValue(i.Size), BudgetUSD: workstreamValue(i.BudgetUSD), Source: workstreamSource(i.Source)}) - } - waves = append(waves, wave) - } - v, c, err := s.SetPlan(context.Background(), p.ID, p.BaseVersion, milestones, waves, workstream.Attribution{}) - return appwire.WorkstreamPlanSetResult{Plan: appWorkstreamPlan(v)}, c, err - }) -} - -func workstreamDraftBudget(budget float64) *float64 { - if budget == 0 { - return nil - } - return &budget -} - -func appWorkstreamSource(p *workstream.Source) *appwire.WorkstreamSource { - if p == nil { - return nil - } - return &appwire.WorkstreamSource{Kind: p.Kind, ID: p.ID, ChatID: workstreamOptional(p.ChatID), Factory: workstreamOptional(p.Factory), Label: workstreamOptional(p.Label)} -} - -func appWorkstreamSuggestion(s workstream.Suggestion) appwire.WorkstreamSuggestion { - out := appwire.WorkstreamSuggestion{ID: s.ID, WorkstreamID: s.WorkstreamID, PageID: s.PageID, BaseVersion: s.BaseVersion, Why: s.Why, By: s.By, Source: appWorkstreamSource(s.Source), CreatedAt: s.CreatedAt, Edits: []appwire.WorkstreamSuggestionEdit{}} - for _, e := range s.Edits { - out.Edits = append(out.Edits, appwire.WorkstreamSuggestionEdit{ID: e.ID, Block: e.Block, Offset: e.Offset, Delete: e.Delete, Insert: e.Insert, BlockText: e.BlockText, Why: e.Why, State: e.State, Applies: e.Applies, DecidedAt: e.DecidedAt}) - } - return out -} - -func (b *appBackend) WorkstreamSuggestionList(p appwire.WorkstreamSuggestionListParams) (appwire.WorkstreamSuggestionListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionListResult, workstream.Change, error) { - out := appwire.WorkstreamSuggestionListResult{Suggestions: []appwire.WorkstreamSuggestion{}} - suggestions, err := s.Suggestions(context.Background(), p.PageID) - for _, suggestion := range suggestions { - out.Suggestions = append(out.Suggestions, appWorkstreamSuggestion(suggestion)) - } - return out, workstream.Change{}, err - }) -} - -func (b *appBackend) WorkstreamSuggestionKeep(p appwire.WorkstreamSuggestionKeepParams) (appwire.WorkstreamSuggestionKeepResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionKeepResult, workstream.Change, error) { - page, suggestion, change, err := s.KeepSuggestion(context.Background(), p.ID, workstreamValue(p.EditID)) - if err != nil { - var stale *workstream.VersionConflict - if errors.As(err, &stale) { - return appwire.WorkstreamSuggestionKeepResult{}, change, &appwire.Error{Code: appwire.CodeVersionConflict, Message: "the page changed and this edit no longer applies", Detail: appwire.WorkstreamVersionConflict{ID: stale.ID, Version: stale.Version, UpdatedAt: stale.UpdatedAt, UpdatedBy: stale.UpdatedBy}} - } - } - return appwire.WorkstreamSuggestionKeepResult{Page: appWorkstreamPage(page), Suggestion: appWorkstreamSuggestion(suggestion)}, change, err - }) -} - -func (b *appBackend) WorkstreamSuggestionDrop(p appwire.WorkstreamSuggestionDropParams) (appwire.WorkstreamSuggestionDropResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSuggestionDropResult, workstream.Change, error) { - suggestion, change, err := s.DropSuggestion(context.Background(), p.ID, workstreamValue(p.EditID)) - return appwire.WorkstreamSuggestionDropResult{Suggestion: appWorkstreamSuggestion(suggestion)}, change, err - }) -} -func workstreamSource(p *appwire.WorkstreamSource) *workstream.Source { - if p == nil { - return nil - } - return &workstream.Source{Kind: p.Kind, ID: p.ID, ChatID: workstreamValue(p.ChatID), Factory: workstreamValue(p.Factory), Label: workstreamValue(p.Label)} -} -func appWorkstreamPageNode(p workstream.PageNode) appwire.WorkstreamPageNode { - return appwire.WorkstreamPageNode{ID: p.ID, WorkstreamID: p.WorkstreamID, ParentID: workstreamOptional(p.ParentID), Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs, Position: p.Position, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, UpdatedFrom: appWorkstreamSource(p.UpdatedFrom), DeletedAt: p.DeletedAt} -} -func appWorkstreamPage(p workstream.Page) appwire.WorkstreamPage { - n := appWorkstreamPageNode(p.PageNode) - return appwire.WorkstreamPage{ID: n.ID, WorkstreamID: n.WorkstreamID, ParentID: n.ParentID, Title: n.Title, Owner: n.Owner, ReadByJobs: n.ReadByJobs, Position: n.Position, Version: n.Version, UpdatedAt: n.UpdatedAt, UpdatedBy: n.UpdatedBy, UpdatedFrom: n.UpdatedFrom, DeletedAt: n.DeletedAt, Doc: p.Doc, Markdown: p.Markdown} -} -func (b *appBackend) WorkstreamPageList(p appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageListResult, workstream.Change, error) { - pages, err := s.Pages(context.Background(), p.WorkstreamID) - out := appwire.WorkstreamPageListResult{Pages: []appwire.WorkstreamPageNode{}} - for _, page := range pages { - out.Pages = append(out.Pages, appWorkstreamPageNode(page)) - } - if err == nil && workstreamValue(p.IncludeDeleted) { - var deleted []workstream.PageNode - deleted, err = s.DeletedPages(context.Background(), p.WorkstreamID) - out.Deleted = []appwire.WorkstreamPageNode{} - for _, page := range deleted { - out.Deleted = append(out.Deleted, appWorkstreamPageNode(page)) - } - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamPageGet(p appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageGetResult, workstream.Change, error) { - var v workstream.Page - var err error - if p.Version != nil { - v, err = s.PageAtVersion(context.Background(), p.ID, *p.Version, workstreamValue(p.Markdown)) - } else { - v, err = s.Page(context.Background(), p.ID, workstreamValue(p.Markdown)) - } - return appwire.WorkstreamPageGetResult{Page: appWorkstreamPage(v)}, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamPageSave(p appwire.WorkstreamPageSaveParams) (appwire.WorkstreamPageSaveResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageSaveResult, workstream.Change, error) { - v, c, err := s.SavePage(context.Background(), p.ID, p.BaseVersion, p.Doc, workstream.Attribution{From: workstreamSource(p.Source)}) - return appwire.WorkstreamPageSaveResult{Page: appWorkstreamPage(v)}, c, err - }) -} -func (b *appBackend) WorkstreamPageCreate(p appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageCreateResult, workstream.Change, error) { - read := workstream.ReadByJobsDefault(p.Title) - if p.ReadByJobs != nil { - read = *p.ReadByJobs - } - v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: read, Doc: p.Doc, Attribution: workstream.Attribution{From: workstreamSource(p.Source)}}) - return appwire.WorkstreamPageCreateResult{Page: appWorkstreamPage(v)}, c, err - }) -} -func (b *appBackend) WorkstreamPageUpdate(p appwire.WorkstreamPageUpdateParams) (appwire.WorkstreamPageUpdateResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageUpdateResult, workstream.Change, error) { - v, c, err := s.UpdatePage(context.Background(), p.ID, workstream.PagePatch{Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs}) - return appwire.WorkstreamPageUpdateResult{Page: appWorkstreamPageNode(v)}, c, err - }) -} -func (b *appBackend) WorkstreamPageMove(p appwire.WorkstreamPageMoveParams) (appwire.WorkstreamPageMoveResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageMoveResult, workstream.Change, error) { - v, c, err := s.MovePage(context.Background(), p.ID, workstreamValue(p.ParentID), workstreamValue(p.AfterID)) - return appwire.WorkstreamPageMoveResult{Page: appWorkstreamPageNode(v)}, c, err - }) -} -func (b *appBackend) WorkstreamPageDelete(p appwire.WorkstreamPageDeleteParams) (appwire.WorkstreamPageDeleteResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageDeleteResult, workstream.Change, error) { - v, c, err := s.DeletePage(context.Background(), p.ID) - return appwire.WorkstreamPageDeleteResult{Deleted: v}, c, err - }) -} -func (b *appBackend) WorkstreamPageHistory(p appwire.WorkstreamPageHistoryParams) (appwire.WorkstreamPageHistoryResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageHistoryResult, workstream.Change, error) { - limit := 50 - if p.Limit != nil { - limit = *p.Limit - } - versions, err := s.PageHistory(context.Background(), p.ID, limit) - out := appwire.WorkstreamPageHistoryResult{Versions: []appwire.WorkstreamPageVersion{}} - for _, v := range versions { - out.Versions = append(out.Versions, appwire.WorkstreamPageVersion{Version: v.Version, SavedAt: v.SavedAt, SavedBy: v.SavedBy, From: appWorkstreamSource(v.From), Title: v.Title, Bytes: v.Bytes}) - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamPageRestore(p appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageRestoreResult, workstream.Change, error) { - var v workstream.Page - var c workstream.Change - var err error - if p.Version == nil { - v, c, err = s.UndeletePage(context.Background(), p.ID) - } else { - v, c, err = s.RestorePage(context.Background(), p.ID, *p.Version, *p.BaseVersion, workstream.Attribution{}) - } - return appwire.WorkstreamPageRestoreResult{Page: appWorkstreamPage(v)}, c, err - }) -} - -func appWorkstreamRecord(r workstream.Record) appwire.WorkstreamRecord { - return appwire.WorkstreamRecord{ID: r.ID, WorkstreamID: r.WorkstreamID, Kind: r.Kind, Text: r.Text, State: r.State, By: r.By, Source: appWorkstreamSource(r.Source), CreatedAt: r.CreatedAt, DecidedAt: r.DecidedAt, Supersedes: workstreamOptional(r.Supersedes), SupersededBy: workstreamOptional(r.SupersededBy), GoAhead: appWorkstreamGoAheadProposal(r.GoAhead), GoAheadID: workstreamOptional(r.GoAheadID)} -} - -func appWorkstreamGoAheadProposal(p *workstream.GoAheadProposal) *appwire.WorkstreamGoAheadProposal { - if p == nil { - return nil - } - return &appwire.WorkstreamGoAheadProposal{Scope: p.Scope, Factory: workstreamOptional(p.Factory), Job: workstreamPositive(p.Job), Wave: workstreamPositive(p.Wave), MilestoneID: workstreamOptional(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt} -} - -func workstreamGoAheadProposal(p *appwire.WorkstreamGoAheadProposal) *workstream.GoAheadProposal { - if p == nil { - return nil - } - return &workstream.GoAheadProposal{Scope: p.Scope, Factory: workstreamValue(p.Factory), Job: workstreamValue(p.Job), Wave: workstreamValue(p.Wave), MilestoneID: workstreamValue(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt} -} -func (b *appBackend) WorkstreamRecordList(p appwire.WorkstreamRecordListParams) (appwire.WorkstreamRecordListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordListResult, workstream.Change, error) { - records, err := s.Records(context.Background(), p.WorkstreamID, workstreamValue(p.Kind), workstreamValue(p.State), workstreamValue(p.IncludeGoAheads)) - out := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} - for _, r := range records { - out.Records = append(out.Records, appWorkstreamRecord(r)) - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamRecordPropose(p appwire.WorkstreamRecordProposeParams) (appwire.WorkstreamRecordProposeResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordProposeResult, workstream.Change, error) { - v, c, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: p.WorkstreamID, Kind: p.Kind, Text: p.Text, Source: workstreamSource(p.Source), Kept: workstreamValue(p.Kept), GoAhead: workstreamGoAheadProposal(p.GoAhead)}) - return appwire.WorkstreamRecordProposeResult{Record: appWorkstreamRecord(v)}, c, err - }) -} -func (b *appBackend) WorkstreamRecordKeep(p appwire.WorkstreamRecordKeepParams) (appwire.WorkstreamRecordKeepResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordKeepResult, workstream.Change, error) { - v, c, err := s.KeepRecord(context.Background(), p.ID) - return appwire.WorkstreamRecordKeepResult{Record: appWorkstreamRecord(v)}, c, err - }) -} -func (b *appBackend) WorkstreamRecordDrop(p appwire.WorkstreamRecordDropParams) (appwire.WorkstreamRecordDropResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordDropResult, workstream.Change, error) { - v, c, err := s.DropRecord(context.Background(), p.ID) - return appwire.WorkstreamRecordDropResult{Record: appWorkstreamRecord(v)}, c, err - }) -} -func (b *appBackend) WorkstreamRecordSupersede(p appwire.WorkstreamRecordSupersedeParams) (appwire.WorkstreamRecordSupersedeResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamRecordSupersedeResult, workstream.Change, error) { - v, old, c, err := s.SupersedeRecord(context.Background(), p.ID, workstream.RecordReplacement{Kind: workstreamValue(p.Kind), Text: p.Text, Source: workstreamSource(p.Source)}) - return appwire.WorkstreamRecordSupersedeResult{Record: appWorkstreamRecord(v), Superseded: appWorkstreamRecord(old)}, c, err - }) -} -func appWorkstreamGoAhead(g workstream.GoAhead) appwire.WorkstreamGoAhead { - return appwire.WorkstreamGoAhead{ID: g.ID, WorkstreamID: g.WorkstreamID, Scope: g.Scope, Factory: workstreamOptional(g.Factory), Job: workstreamPositive(g.Job), Wave: workstreamPositive(g.Wave), MilestoneID: workstreamOptional(g.MilestoneID), CapUSD: g.CapUSD, EndsAt: g.EndsAt, State: g.State, EndReason: workstreamOptional(g.EndReason), GrantedAt: g.GrantedAt, EndedAt: g.EndedAt} -} -func (b *appBackend) WorkstreamGoAheadList(p appwire.WorkstreamGoAheadListParams) (appwire.WorkstreamGoAheadListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadListResult, workstream.Change, error) { - grants, err := s.GoAheads(context.Background(), p.WorkstreamID, workstreamValue(p.Ended)) - out := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} - for _, g := range grants { - out.GoAheads = append(out.GoAheads, appWorkstreamGoAhead(g)) - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamGoAheadGrant(p appwire.WorkstreamGoAheadGrantParams) (appwire.WorkstreamGoAheadGrantResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadGrantResult, workstream.Change, error) { - v, c, err := s.GrantGoAhead(context.Background(), workstream.GoAheadGrant{WorkstreamID: p.WorkstreamID, Scope: p.Scope, Factory: workstreamValue(p.Factory), Job: workstreamValue(p.Job), Wave: workstreamValue(p.Wave), MilestoneID: workstreamValue(p.MilestoneID), CapUSD: p.CapUSD, EndsAt: p.EndsAt}) - return appwire.WorkstreamGoAheadGrantResult{GoAhead: appWorkstreamGoAhead(v)}, c, err - }) -} -func (b *appBackend) WorkstreamGoAheadRevoke(p appwire.WorkstreamGoAheadRevokeParams) (appwire.WorkstreamGoAheadRevokeResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamGoAheadRevokeResult, workstream.Change, error) { - v, c, err := s.RevokeGoAhead(context.Background(), p.ID) - return appwire.WorkstreamGoAheadRevokeResult{GoAhead: appWorkstreamGoAhead(v)}, c, err - }) -} - -// workstreamQueue shares successful and failed reads briefly, including an -// in-flight read, so concurrent list/get calls cannot multiply Factory work. -func (b *appBackend) workstreamQueue(ctx context.Context, factory string) (entry workstreamQueueRead) { - state := &b.workstreams - entry, flight, readQueue := state.workstreamQueueStart(factory, b.now()) - if flight == nil { - return entry - } - if !readQueue { - select { - case <-flight: - return b.workstreamQueue(ctx, factory) - case <-ctx.Done(): - return workstreamQueueRead{err: ctx.Err()} - } - } - // Complete ownership even when the guarded reader panics. Only a completed - // read replaces the unavailable entry, so a fault cannot publish success. - entry = workstreamQueueRead{err: errors.New("the Factory queue read did not complete")} - defer func() { - entry.at = b.now() - state.workstreamQueueFinish(factory, flight, entry) - }() - read := state.queue - if read == nil { - read = b.FactoryQueue - } - queue, err := read(ctx, factory) - jobs := map[int]api.Job{} - for _, j := range queue.Jobs { - jobs[j.ID] = j - } - entry = workstreamQueueRead{err: err, jobs: jobs} - return entry -} - -func (state *appWorkstreams) workstreamQueueStart(factory string, now time.Time) (workstreamQueueRead, chan struct{}, bool) { - state.cacheMu.Lock() - defer state.cacheMu.Unlock() - if entry, ok := state.cache[factory]; ok && now.Sub(entry.at) < workstreamFactoryFresh { - return entry, nil, false - } - if flight := state.flights[factory]; flight != nil { - return workstreamQueueRead{}, flight, false - } - if state.flights == nil { - state.flights = map[string]chan struct{}{} - } - flight := make(chan struct{}) - state.flights[factory] = flight - return workstreamQueueRead{}, flight, true -} - -func (state *appWorkstreams) workstreamQueueFinish(factory string, flight chan struct{}, entry workstreamQueueRead) { - state.cacheMu.Lock() - defer state.cacheMu.Unlock() - if state.cache == nil { - state.cache = map[string]workstreamQueueRead{} - } - if len(state.cache) >= workstreamFactoryCacheMax { - oldest := "" - var at time.Time - for id, v := range state.cache { - if oldest == "" || v.at.Before(at) { - oldest, at = id, v.at - } - } - delete(state.cache, oldest) - } - state.cache[factory] = entry - delete(state.flights, factory) - close(flight) -} - -type workstreamJobRead struct { - job api.Job - err error - notFound bool -} - -// workstreamJobReads gives each factory one concurrent queue read. Missing -// jobs share a global fallback allowance and the same overall deadline. -func (b *appBackend) workstreamJobReads(jobs []workstream.Job) map[workstream.Job]workstreamJobRead { - out := map[workstream.Job]workstreamJobRead{} - grouped := map[string][]workstream.Job{} - for _, j := range jobs { - if _, seen := out[j]; !seen { - out[j] = workstreamJobRead{err: context.DeadlineExceeded} - grouped[j.Factory] = append(grouped[j.Factory], j) - } - } - if len(grouped) == 0 { - return out - } - ctx, cancel := context.WithTimeout(context.Background(), workstreamFactoryTimeout) - defer cancel() - var wg sync.WaitGroup - var mu sync.Mutex - missing := 0 - workstreamMissingAllowed := func() bool { - mu.Lock() - defer mu.Unlock() - if missing >= workstreamMissingJobsMax { - return false - } - missing++ - return true - } - workstreamRememberRead := func(j workstream.Job, read workstreamJobRead) { - mu.Lock() - defer mu.Unlock() - out[j] = read - } - slots := make(chan struct{}, workstreamFactoryParallel) - for factory, members := range grouped { - wg.Add(1) - guard.Go("app/workstream-factory", func() { - defer wg.Done() - select { - case slots <- struct{}{}: - defer func() { <-slots }() - case <-ctx.Done(): - return - } - queue := b.workstreamQueue(ctx, factory) - for _, j := range members { - job, ok := queue.jobs[j.Number] - err := queue.err - if err == nil && !ok { - allowed := workstreamMissingAllowed() - switch { - case !allowed: - err = errors.New("the missing-job read allowance was reached") - case ctx.Err() != nil: - err = ctx.Err() - default: - read := b.workstreams.job - if read == nil { - read = b.FactoryJob - } - job, err = read(ctx, j.Factory, j.Number) - if err == nil && job.ID != j.Number { - err = errors.New("the Factory returned another job") - } - } - } - var wire *appwire.Error - notFound := queue.err == nil && errors.As(err, &wire) && wire.Code == appwire.CodeNotFound - workstreamRememberRead(j, workstreamJobRead{job: job, err: err, notFound: notFound}) - } - }) - } - wg.Wait() - return out -} - -func (b *appBackend) workstreamFacts(jobs []workstream.Job) map[workstream.Job]workstream.JobFacts { - out := map[workstream.Job]workstream.JobFacts{} - for j, read := range b.workstreamJobReads(jobs) { - if read.err != nil { - continue - } - job := read.job - merged := job.PR != nil && job.PR.MergedAt != nil && !job.PR.MergedAt.IsZero() - // A job's money is all of its runs: Cost is only the current run once - // it has run again, LifetimeCost keeps the earlier ones, and an older - // factory that does not send LifetimeCost leaves Cost as the whole. - cost := max(job.Cost, job.LifetimeCost) - out[j] = workstream.JobFacts{State: job.State, Merged: &merged, CostUSD: &cost} - } - return out -} - -// Collection resolution uses the same bounded batch reader, with each job -// asked once even when it appears through several nested collections. -func (r *collectionResolver) prepareWorkstreamRefs(members []workspace.Member) { - jobs := []workstream.Job{} - refs := map[workstream.Job]workspace.Ref{} - for _, member := range members { - if member.Kind != workspace.FactoryJobKind { - continue - } - number, _ := strconv.Atoi(member.ID) - job := workstream.Job{Factory: member.Factory, Number: number} - jobs = append(jobs, job) - refs[job] = member.Ref - } - r.factoryTargets = map[workspace.Ref]appwire.Target{} - for job, read := range r.b.workstreamJobReads(jobs) { - var target appwire.Target - if read.err != nil { - if read.notFound { - target = appwire.Target{State: appwire.TargetDeleted} - } else { - target = appUnknown("the Factory", read.err) - target.Status = "unknown" - } - } else { - target = appwire.Target{State: appwire.TargetLive, Title: read.job.Title, Status: read.job.State, Repo: read.job.Repo} - } - r.factoryTargets[refs[job]] = target - } -} - -func (r *collectionResolver) prepareWorkstreamTree(level workspace.Level) { - members := []workspace.Member{} - var collect func(workspace.Level) - collect = func(level workspace.Level) { - for _, node := range level.Members { - if node.Kind == workspace.FactoryJobKind { - members = append(members, node.Member) - } - if node.Children != nil { - collect(*node.Children) - } - } - } - collect(level) - r.prepareWorkstreamRefs(members) -} - -// workstreamObserved adds the first observed start to the caller's -// receipt, so mutations announce their write and observation in one event. -func (b *appBackend) workstreamObserved(s *workstream.Store, w workstream.Workstream, facts map[workstream.Job]workstream.JobFacts, c workstream.Change) (workstream.Workstream, workstream.Change, error) { - if !w.StartedAt.IsZero() { - return w, c, nil - } - states := map[workstream.Job]string{} - active := false - for _, j := range w.Jobs() { - states[j] = facts[j].State - switch states[j] { - case "queued", "running", "needs_you", "done": - active = true - } - } - if !active { - return w, c, nil - } - observed, err := s.ObserveJobStates(context.Background(), w.ID, states) - if err != nil { - return w, c, appWorkstreamError(err) - } - if c.ID == "" { - c = observed - } else { - for _, part := range observed.Parts { - if !slices.Contains(c.Parts, part) { - c.Parts = append(c.Parts, part) - } - } - } - // Another reader may have won the write, so reread even with an empty receipt. - w, err = s.Get(context.Background(), w.ID) - return w, c, appWorkstreamError(err) -} - -func (b *appBackend) workstreamSpend(id string) *float64 { - spend := b.workstreamAgentSpend(id) - if spend == nil { - return nil - } - s, err := b.workstreamStore(false) - if err != nil { - return nil - } - if s == nil { - return spend - } - wakes, err := s.WakeSpendByAgent(context.Background(), id) - if err != nil { - return nil - } - total := *spend + wakes - return &total -} - -func (b *appBackend) workstreamAgentSpend(id string) *float64 { - if id == "" { - return nil - } - if read := b.workstreams.spend; read != nil { - return read(id) - } - a, err := b.find(id) - if err != nil { - row, ok := workstreamAgentRow(id) - if !ok { - return nil - } - b.remember(row) - a, err = b.find(id) - if err != nil { - return nil - } - } - var transcript string - b.locked(func() { transcript = a.transcript }) - if transcript == "" { - return nil - } - if row, ok := session.ReadRows([]string{transcript})[transcript]; ok { - if meta, err := session.LoadMeta(row.Dir); err != nil || meta.ID == "" { - return nil - } - return &row.Spend - } - // The ordinary chat listing deliberately omits bare conversations. Their - // freshly recorded metadata still establishes known zero spend here. - meta, err := session.LoadMeta(session.DirOf(transcript)) - if err == nil && meta.ID != "" && meta.LastUserAt.IsZero() && meta.SpentUSD == 0 { - zero := 0.0 - return &zero - } - return nil -} - -// workstreamKeepsConversation protects a deliberately bare agent from both -// app and terminal blank-chat cleanup. Its durable owner is the SQLite row; -// an unreadable owner is a reason to retain a conversation, never to delete it. -func workstreamKeepsConversation(dir string) bool { - path := home.Join("v3", "collections.db") - if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - return false - } else if err != nil { - return true - } - s, err := workspace.Open(path) - if err != nil { - return true - } - defer s.Close() - kept := false - err = s.Read(context.Background(), func(tx *sql.Tx, version int) error { - if version < 3 { - return nil - } - return tx.QueryRow("SELECT EXISTS(SELECT 1 FROM workstreams WHERE agent_id=?)", filepath.Base(filepath.Clean(dir))).Scan(&kept) - }) - return kept || err != nil -} - -// workstreamAgentRow restores a bare agent omitted by the ordinary chat -// listing. Workstream reads already obtained its id from the durable owner; -// remembering it lets agents.open use the existing opener after a restart. -func workstreamAgentRow(id string) (session.SessionRow, bool) { - if id == "" || id != filepath.Base(id) || strings.HasPrefix(id, ".") { - return session.SessionRow{}, false - } - root := session.PlacesRoot() - buckets, err := os.ReadDir(root) - if err != nil { - return session.SessionRow{}, false - } - for _, bucket := range buckets { - if !bucket.IsDir() { - continue - } - dir := filepath.Join(root, bucket.Name(), id) - meta, err := session.LoadMeta(dir) - if err != nil || meta.ID != id || !meta.LastUserAt.IsZero() { - continue - } - transcript := (session.Place{Dir: dir}).Transcript() - if _, err := os.Stat(transcript); err != nil { - continue - } - return session.SessionRow{ID: id, Dir: dir, Transcript: transcript, Workspace: meta.Workspace, Owned: meta.Owned, Title: meta.Title, Model: meta.Model, Created: meta.Created, At: meta.Created, Spend: meta.SpentUSD, Archived: meta.Archived}, true - } - return session.SessionRow{}, false -} diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go deleted file mode 100644 index 5fc7e6c842..0000000000 --- a/cmd/codeaf/appworkstreams_agent.go +++ /dev/null @@ -1,374 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "net/url" - "strconv" - "sync/atomic" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -const workstreamCreatedBy = "workstream" - -var appWorkstreamAgents atomic.Pointer[appBackend] - -type workstreamAgentDoor struct { - backend *appBackend - agentID string -} - -var _ session.WorkstreamDoor = (*workstreamAgentDoor)(nil) - -func appWorkstreamAgentDoor(sessionFile string) session.WorkstreamDoor { - if b := appWorkstreamAgents.Load(); b != nil { - return b.workstreamAgentDoor(sessionFile) - } - return nil -} - -func (b *appBackend) workstreamAgentDoor(sessionFile string) session.WorkstreamDoor { - id := appAgentID(sessionFile) - if b.createdBy(id) != workstreamCreatedBy { - return nil - } - return &workstreamAgentDoor{backend: b, agentID: id} -} - -func (d *workstreamAgentDoor) workstreamOwner(ctx context.Context, writing bool) (*workstream.Store, workstream.Workstream, error) { - s, err := d.backend.workstreamStore(false) - if err != nil { - return nil, workstream.Workstream{}, err - } - if s == nil { - return nil, workstream.Workstream{}, session.ErrWorkstreamMissing - } - w, err := s.ByAgent(ctx, d.agentID) - if err == nil { - d.backend.workstreamRememberBrief(w) - } - if errors.Is(err, workstream.ErrNotFound) { - err = session.ErrWorkstreamMissing - } - if err == nil && writing && !w.DoneAt.IsZero() { - err = session.ErrWorkstreamDone - } - return s, w, err -} - -func (b *appBackend) workstreamRememberBrief(w workstream.Workstream) { - state := &b.workstreams - state.briefMu.Lock() - defer state.briefMu.Unlock() - if state.briefs == nil { - state.briefs = make(map[string]session.WorkstreamBrief) - } - state.briefs[w.AgentID] = session.WorkstreamBrief{Name: w.Name, Why: w.Why} -} - -func (d *workstreamAgentDoor) Brief() (session.WorkstreamBrief, bool) { - state := &d.backend.workstreams - // Prompt rendering holds the session lock, so even cache contention falls back. - if !state.briefMu.TryLock() { - return session.WorkstreamBrief{}, false - } - defer state.briefMu.Unlock() - brief, ok := state.briefs[d.agentID] - return brief, ok -} - -func (d *workstreamAgentDoor) Read(ctx context.Context, ask session.WorkstreamReadAsk) (json.RawMessage, error) { - s, w, err := d.workstreamOwner(ctx, false) - if err != nil { - return nil, err - } - switch ask.What { - case "workstream": - out, err := d.backend.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil { - return nil, err - } - return json.Marshal(out.Workstream) - case "pages": - return workstreamAgentReadPages(ctx, s, w.ID) - case "page": - return workstreamAgentReadPage(ctx, s, w.ID, ask.PageID) - case "records": - return workstreamAgentReadRecords(ctx, s, w.ID, ask) - case "plan": - p, err := s.Plan(ctx, w.ID) - if err != nil { - return nil, err - } - return json.Marshal(appwire.WorkstreamPlanGetResult{Plan: appWorkstreamPlan(p)}) - case "goaheads": - return workstreamAgentReadGoAheads(ctx, s, w.ID) - case "sources": - return workstreamAgentReadSources(ctx, s, w.ID) - case "source": - return workstreamAgentReadSource(ctx, s, w.ID, ask.SourceID) - } - return nil, errors.New("what must name a workstream part") -} - -func workstreamAgentReadPage(ctx context.Context, s *workstream.Store, id, pageID string) (json.RawMessage, error) { - p, err := workstreamAgentPage(ctx, s, id, pageID, true) - if err != nil { - return nil, err - } - suggestions, err := s.Suggestions(ctx, p.ID) - if err != nil { - return nil, err - } - open := make([]appwire.WorkstreamSuggestion, 0, len(suggestions)) - for _, suggestion := range suggestions { - open = append(open, appWorkstreamSuggestion(suggestion)) - } - return json.Marshal(struct { - appwire.WorkstreamPageNode - Markdown *string `json:"markdown"` - Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` - }{appWorkstreamPageNode(p.PageNode), p.Markdown, open}) -} - -func workstreamAgentReadPages(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { - pages, err := s.Pages(ctx, id) - if err != nil { - return nil, err - } - out := appwire.WorkstreamPageListResult{Pages: []appwire.WorkstreamPageNode{}} - for _, p := range pages { - out.Pages = append(out.Pages, appWorkstreamPageNode(p)) - } - return json.Marshal(out) -} - -func workstreamAgentReadRecords(ctx context.Context, s *workstream.Store, id string, ask session.WorkstreamReadAsk) (json.RawMessage, error) { - records, err := s.Records(ctx, id, ask.Kind, ask.State, true) - if err != nil { - return nil, err - } - out := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} - for _, r := range records { - out.Records = append(out.Records, appWorkstreamRecord(r)) - } - return json.Marshal(out) -} - -func workstreamAgentReadGoAheads(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { - grants, err := s.GoAheads(ctx, id, true) - if err != nil { - return nil, err - } - out := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} - for _, g := range grants { - out.GoAheads = append(out.GoAheads, appWorkstreamGoAhead(g)) - } - return json.Marshal(out) -} - -func workstreamAgentPage(ctx context.Context, s *workstream.Store, id, pageID string, markdown bool) (workstream.Page, error) { - p, err := s.Page(ctx, pageID, markdown) - if err == nil && p.WorkstreamID != id { - err = workstream.ErrNotFound - } - return p, err -} - -func (d *workstreamAgentDoor) Propose(ctx context.Context, p session.WorkstreamProposal) (session.WorkstreamProposed, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return session.WorkstreamProposed{}, err - } - var proposal *workstream.GoAheadProposal - if p.GoAhead != nil { - proposal, err = d.workstreamGoAheadProposal(*p.GoAhead) - if err != nil { - return session.WorkstreamProposed{}, err - } - p.Kind, p.Text = "goahead", p.GoAhead.Why - } - source, err := d.workstreamSource(ctx, s, w, p.Source) - if err != nil { - return session.WorkstreamProposed{}, err - } - r, change, err := s.ProposeRecord(ctx, workstream.RecordPropose{WorkstreamID: w.ID, Kind: p.Kind, Text: p.Text, Source: source, By: "agent", Kept: false, GoAhead: proposal}) - d.backend.workstreamChanged(change) - return session.WorkstreamProposed{ID: r.ID}, workstreamAgentError(err) -} - -// Source resolves a draft call once, so its conflict retry keeps that attribution. -func (d *workstreamAgentDoor) Source(ctx context.Context, asked *workstream.Source) (*workstream.Source, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return nil, err - } - return d.workstreamSource(ctx, s, w, asked) -} - -func (d *workstreamAgentDoor) workstreamSource(ctx context.Context, s *workstream.Store, w workstream.Workstream, asked *workstream.Source) (*workstream.Source, error) { - if asked == nil { - return &workstream.Source{Kind: "chat", ID: d.agentID}, nil - } - if err := appWorkstreamSource(asked).Check(); err != nil { - return nil, err - } - source := *asked - if source.ChatID != "" || source.Kind != "factory_job" && source.Factory != "" { - return nil, errors.New("The source has fields for another kind.") - } - switch source.Kind { - case "factory_job": - n, err := strconv.Atoi(source.ID) - if err != nil || strconv.Itoa(n) != source.ID || !workstreamAgentMember(w, source.Factory, n) { - return nil, errors.New("The source job must be a member of this workstream, with its factory and decimal job number.") - } - case "page": - if _, err := workstreamAgentPage(ctx, s, w.ID, source.ID, false); err != nil { - return nil, err - } - case "url": - if err := workstreamAgentSourceURL(source.ID); err != nil { - return nil, err - } - default: - return nil, errors.New("source.kind must be factory_job, page or url") - } - return &source, nil -} - -func workstreamAgentSourceURL(id string) error { - u, err := url.Parse(id) - if err != nil || u.Host == "" || u.Scheme != "http" && u.Scheme != "https" { - return errors.New("A URL source must be an http(s):// URL.") - } - return nil -} - -func workstreamAgentMember(w workstream.Workstream, factory string, number int) bool { - if factory == "" || number <= 0 { - return false - } - for _, job := range w.Jobs() { - if job.Factory == factory && job.Number == number { - return true - } - } - return false -} - -func (d *workstreamAgentDoor) Plan(ctx context.Context) (session.WorkstreamPlanState, error) { - s, w, err := d.workstreamOwner(ctx, false) - if err != nil { - return session.WorkstreamPlanState{}, err - } - p, err := s.Plan(ctx, w.ID) - return workstreamAgentPlan(p), err -} - -func workstreamAgentPlan(p workstream.Plan) session.WorkstreamPlanState { - return session.WorkstreamPlanState{Version: p.Version, Milestones: p.Milestones, Waves: p.Waves} -} - -func (d *workstreamAgentDoor) SetPlan(ctx context.Context, base int, p session.WorkstreamPlanState) (session.WorkstreamPlanState, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return session.WorkstreamPlanState{}, err - } - saved, change, err := s.SetPlan(ctx, w.ID, base, p.Milestones, p.Waves, workstream.Attribution{By: "agent"}) - d.backend.workstreamChanged(change) - return workstreamAgentPlan(saved), workstreamAgentError(err) -} - -func (d *workstreamAgentDoor) workstreamGoAheadProposal(ask session.WorkstreamGoAheadAsk) (*workstream.GoAheadProposal, error) { - if err := session.WorkstreamValidateGoAhead(ask, d.backend.now()); err != nil { - return nil, err - } - end, _ := time.Parse("2006-01-02", ask.EndsOn) - return &workstream.GoAheadProposal{Scope: ask.Scope, Factory: ask.Factory, Job: ask.Job, Wave: ask.Wave, MilestoneID: ask.MilestoneID, CapUSD: ask.CapUSD, EndsAt: end.Add(24*time.Hour - time.Second)}, nil -} - -func (d *workstreamAgentDoor) WritePage(ctx context.Context, ask session.WorkstreamPageWrite) (session.WorkstreamPageWritten, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return session.WorkstreamPageWritten{}, err - } - source, err := d.workstreamSource(ctx, s, w, ask.Source) - if err != nil { - return session.WorkstreamPageWritten{}, err - } - source.Label = ask.UpdatedFrom - doc := pagedoc.FromMarkdown(ask.Markdown) - if len(doc) > workstream.DocMax { - return session.WorkstreamPageWritten{}, session.ErrWorkstreamTooLarge - } - p, change, err := workstreamAgentWritePage(ctx, s, w.ID, ask, doc, workstream.Attribution{By: "agent", From: source}) - d.backend.workstreamChanged(change) - return session.WorkstreamPageWritten{ID: p.ID, Title: p.Title, Version: p.Version}, workstreamAgentError(err) -} - -func (d *workstreamAgentDoor) SuggestEdits(ctx context.Context, ask session.WorkstreamSuggestionAsk) (session.WorkstreamSuggested, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return session.WorkstreamSuggested{}, err - } - page, err := workstreamAgentPage(ctx, s, w.ID, ask.PageID, false) - if err != nil { - return session.WorkstreamSuggested{}, err - } - suggestion, change, err := s.CreateSuggestion(ctx, workstream.SuggestionCreate{WorkstreamID: w.ID, PageID: ask.PageID, BaseVersion: ask.BaseVersion, Why: ask.Why, Edits: ask.Edits, Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: d.agentID}}}) - d.backend.workstreamChanged(change) - if errors.Is(err, workstream.ErrTooLarge) { - return session.WorkstreamSuggested{}, errors.New("This workstream holds too many suggestions; nothing was suggested.") - } - return session.WorkstreamSuggested{ID: suggestion.ID, Title: page.Title, Count: len(suggestion.Edits)}, workstreamAgentError(err) -} - -func workstreamAgentWritePage(ctx context.Context, s *workstream.Store, id string, ask session.WorkstreamPageWrite, doc json.RawMessage, who workstream.Attribution) (workstream.Page, workstream.Change, error) { - if ask.PageID == "" { - return s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: id, ParentID: ask.ParentID, Title: ask.Title, Owner: "agent", ReadByJobs: workstream.ReadByJobsDefault(ask.Title), Doc: doc, Attribution: who}) - } - p, err := workstreamAgentPage(ctx, s, id, ask.PageID, false) - if err != nil { - return workstream.Page{}, workstream.Change{}, err - } - return s.SavePage(ctx, p.ID, p.Version, doc, who) -} - -func workstreamAgentError(err error) error { - var stale *workstream.VersionConflict - switch { - case errors.As(err, &stale): - return session.ErrWorkstreamVersion - case errors.Is(err, workstream.ErrNotAgentPage): - return session.ErrWorkstreamNotAgentPage - case errors.Is(err, workstream.ErrTooLarge): - return session.ErrWorkstreamTooLarge - case errors.Is(err, workstream.ErrDone): - return session.ErrWorkstreamDone - case errors.Is(err, workstream.ErrConflict): - return session.ErrWorkstreamDone - } - return err -} - -func (b *appBackend) workstreamAgentTurnSettled(id string) { - if b.createdBy(id) != workstreamCreatedBy { - return - } - s, err := b.workstreamStore(false) - if err != nil || s == nil { - return - } - w, err := s.ByAgent(context.Background(), id) - if err == nil { - b.workstreamRememberBrief(w) - b.workstreamChanged(workstream.Change{ID: w.ID, Parts: []workstream.ChangedPart{workstream.PartWorkstream}}) - } -} diff --git a/cmd/codeaf/appworkstreams_agent_brief_test.go b/cmd/codeaf/appworkstreams_agent_brief_test.go deleted file mode 100644 index 929411bdbf..0000000000 --- a/cmd/codeaf/appworkstreams_agent_brief_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "context" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func TestWorkstreamBriefNeverWaitsForTheStore(t *testing.T) { - b := &appBackend{} - door := &workstreamAgentDoor{backend: b, agentID: "unheld"} - b.collectionsMu.Lock() - defer b.collectionsMu.Unlock() - finished := make(chan bool, 1) - guard.Go("app/workstream-brief-test", func() { - brief, ok := door.Brief() - finished <- !ok && brief == (session.WorkstreamBrief{}) - }) - select { - case fallback := <-finished: - if !fallback { - t.Fatal("a missing snapshot must use the prompt fallback") - } - case <-time.After(3 * time.Second): - t.Fatal("the brief waited for the store") - } -} - -func TestWorkstreamBriefNeverWaitsForItsCache(t *testing.T) { - b := &appBackend{} - door := &workstreamAgentDoor{backend: b, agentID: "held"} - b.workstreamRememberBrief(workstream.Workstream{AgentID: "held", Name: "Cached", Why: "A reason"}) - b.workstreams.briefMu.Lock() - defer b.workstreams.briefMu.Unlock() - finished := make(chan bool, 1) - guard.Go("app/workstream-brief-cache-test", func() { - _, ok := door.Brief() - finished <- ok - }) - select { - case ok := <-finished: - if ok { - t.Fatal("a busy snapshot must use the fallback") - } - case <-time.After(3 * time.Second): - t.Fatal("the brief waited for its cache") - } -} - -func TestWorkstreamBriefTracksCreateAndRename(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - created, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Original", Why: workstreamPtr("Explain usage")}) - if err != nil { - t.Fatal(err) - } - door := &workstreamAgentDoor{backend: b, agentID: created.Workstream.AgentID} - workstreamCheckBrief(t, door, "Original", "Explain usage") - _, err = b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: created.Workstream.ID, Patch: appwire.WorkstreamPatch{Name: workstreamPtr("Renamed"), Why: workstreamPtr("Review usage")}}) - if err != nil { - t.Fatal(err) - } - workstreamCheckBrief(t, door, "Renamed", "Review usage") -} - -func TestWorkstreamBriefRefreshesWhereTheAppResolvesItsOwner(t *testing.T) { - cases := []struct { - name string - read func(*appBackend, string, *workstreamAgentDoor) error - }{ - {"get", func(b *appBackend, id string, _ *workstreamAgentDoor) error { - _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: id}) - return err - }}, - {"list", func(b *appBackend, _ string, _ *workstreamAgentDoor) error { - _, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - return err - }}, - {"done", func(b *appBackend, id string, _ *workstreamAgentDoor) error { - _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: id}) - return err - }}, - {"reopen", func(b *appBackend, id string, _ *workstreamAgentDoor) error { - _, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: id}) - return err - }}, - {"door", func(_ *appBackend, _ string, d *workstreamAgentDoor) error { - _, err := d.Plan(context.Background()) - return err - }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - b, _, w, _ := workstreamAgentFixture(t) - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - _, _, err = s.Update(context.Background(), w.ID, workstream.Patch{Name: workstreamPtr("Refreshed"), Why: workstreamPtr("Fresh reason")}) - if err != nil { - t.Fatal(err) - } - door := &workstreamAgentDoor{backend: b, agentID: w.AgentID} - if err := tc.read(b, w.ID, door); err != nil { - t.Fatal(err) - } - workstreamCheckBrief(t, door, "Refreshed", "Fresh reason") - }) - } -} - -func workstreamCheckBrief(t *testing.T, door *workstreamAgentDoor, name, why string) { - t.Helper() - brief, ok := door.Brief() - if !ok || brief.Name != name || brief.Why != why { - t.Fatalf("brief: %+v %v; want %q / %q", brief, ok, name, why) - } -} diff --git a/cmd/codeaf/appworkstreams_agent_done_test.go b/cmd/codeaf/appworkstreams_agent_done_test.go deleted file mode 100644 index 51512fbe34..0000000000 --- a/cmd/codeaf/appworkstreams_agent_done_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func TestWorkstreamDoneCommittedAfterDoorResolutionRefusesTheWrite(t *testing.T) { - for _, operation := range []string{"record", "create", "save"} { - t.Run(operation, func(t *testing.T) { - b, rec, _, door := workstreamAgentFixture(t) - ctx := context.Background() - s, w, err := door.(*workstreamAgentDoor).workstreamOwner(ctx, true) - if err != nil { - t.Fatal(err) - } - p, _, err := s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: w.ID, Title: "Existing", Owner: "agent"}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MarkDone(ctx, w.ID, "Finished after resolution"); err != nil { - t.Fatal(err) - } - before, err := s.Get(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - change, err := workstreamWriteAfterResolution(ctx, s, w.ID, p.ID, operation) - b.workstreamChanged(change) - if !errors.Is(workstreamAgentError(err), session.ErrWorkstreamDone) || len(workstreamEvents(rec)) != events { - t.Fatalf("done after resolution: %+v %v", change, err) - } - after, err := s.Get(ctx, w.ID) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("refused write changed the workstream: %+v %v", after, err) - } - }) - } -} - -func workstreamWriteAfterResolution(ctx context.Context, s *workstream.Store, id, pageID, operation string) (workstream.Change, error) { - if operation == "record" { - _, c, err := s.ProposeRecord(ctx, workstream.RecordPropose{WorkstreamID: id, Kind: "decision", Text: "Late", By: "agent"}) - return c, err - } - ask := session.WorkstreamPageWrite{Title: "Late"} - if operation == "save" { - ask.PageID = pageID - } - _, c, err := workstreamAgentWritePage(ctx, s, id, ask, json.RawMessage(`{"type":"doc","content":[]}`), workstream.Attribution{By: "agent"}) - return c, err -} diff --git a/cmd/codeaf/appworkstreams_agent_sources.go b/cmd/codeaf/appworkstreams_agent_sources.go deleted file mode 100644 index a3d715deff..0000000000 --- a/cmd/codeaf/appworkstreams_agent_sources.go +++ /dev/null @@ -1,188 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "sort" - "strings" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -var errWorkstreamSourceLink = errors.New("source is a link") - -func (d *workstreamAgentDoor) ProposeSource(ctx context.Context, ask session.WorkstreamSourceProposal) (session.WorkstreamSourceProposed, error) { - s, w, err := d.workstreamOwner(ctx, true) - if err != nil { - return session.WorkstreamSourceProposed{}, err - } - item, already, change, err := s.ProposeSource(ctx, w.ID, ask.SourceAdd(), ask.Why) - if err != nil { - return session.WorkstreamSourceProposed{}, workstreamAgentError(err) - } - d.backend.workstreamChanged(change) - return session.WorkstreamSourceProposed{ID: item.ID, Already: already}, nil -} - -func workstreamAgentReadSources(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { - items, err := s.Sources(ctx, id) - if err != nil { - return nil, err - } - out := appwire.WorkstreamSourceListResult{Sources: []appwire.WorkstreamSourceItem{}} - for _, item := range items { - out.Sources = append(out.Sources, appWorkstreamSourceItem(item)) - } - return json.Marshal(out) -} - -func workstreamAgentReadSource(ctx context.Context, s *workstream.Store, id, sourceID string) (json.RawMessage, error) { - item, err := s.GetSource(ctx, sourceID) - if err != nil { - return nil, err - } - if item.WorkstreamID != id { - return nil, workstream.ErrNotFound - } - content, err := workstreamAgentSourceContent(ctx, s, item) - if err != nil { - return nil, err - } - return workstreamAgentSourceJSON(item, content) -} - -func workstreamAgentSourceContent(ctx context.Context, s *workstream.Store, item workstream.SourceItem) (string, error) { - switch item.Kind { - case workstream.SourceFile: - return workstreamAgentReadPath(item.Path, false), nil - case workstream.SourceRepo: - if filepath.IsAbs(item.Repo) { - return workstreamAgentReadPath(item.Repo, true), nil - } - case workstream.SourceKept: - return workstreamAgentReadKept(ctx, s, item) - } - return "This source is a pointer; its content is not read here.", nil -} - -func workstreamAgentReadKept(ctx context.Context, s *workstream.Store, item workstream.SourceItem) (string, error) { - switch item.Ref.Kind { - case "page": - page, err := workstreamAgentPage(ctx, s, item.WorkstreamID, item.Ref.ID, false) - if err != nil { - return "", err - } - if !page.DeletedAt.IsZero() { - return "The kept page is deleted.", nil - } - return pagedoc.Markdown(page.Doc) - case "record": - records, err := s.Records(ctx, item.WorkstreamID, "", "", true) - if err != nil { - return "", err - } - for _, record := range records { - if record.ID == item.Ref.ID { - return record.Text, nil - } - } - return "", workstream.ErrNotFound - } - return "This kept message or job is a pointer; its content is not read here.", nil -} - -func workstreamAgentReadPath(path string, folderOnly bool) string { - file, err := workstreamAgentOpenSourcePath(path) - if errors.Is(err, errWorkstreamSourceLink) { - return "The source is a link and was not read." - } - if err != nil { - return "The source is unreadable." - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return "The source is unreadable." - } - if folderOnly && !info.IsDir() { - return "The repo folder is unreadable." - } - if info.IsDir() { - return workstreamAgentReadFolder(file) - } - if !info.Mode().IsRegular() { - return "The source is not a regular text file." - } - data, err := io.ReadAll(io.LimitReader(file, session.WorkstreamSourceReadMax+1)) - if err != nil { - return "The file is unreadable." - } - cut := len(data) > session.WorkstreamSourceReadMax - if cut { - data = data[:session.WorkstreamSourceReadMax] - } - if cut { - data = workstreamAgentUTF8Tail(data) - } - if !utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0 { - return "The file is binary, not UTF-8 text." - } - if cut { - return string(data) + "\nThe file was cut." - } - return string(data) -} - -func workstreamAgentUTF8Tail(data []byte) []byte { - end := len(data) - for start := end - 1; start >= 0 && start >= end-utf8.UTFMax; start-- { - if !utf8.RuneStart(data[start]) { - continue - } - if !utf8.FullRune(data[start:]) { - return data[:start] - } - break - } - return data -} - -func workstreamAgentReadFolder(folder *os.File) string { - names, err := folder.Readdirnames(200) - if err != nil && err != io.EOF { - return "The folder is unreadable." - } - sort.Strings(names) - return strings.Join(names, "\n") -} - -func workstreamAgentSourceJSON(item workstream.SourceItem, content string) (json.RawMessage, error) { - out := struct { - Source appwire.WorkstreamSourceItem `json:"source"` - Content string `json:"content"` - }{Source: appWorkstreamSourceItem(item), Content: content} - // Bound the encoded answer too: escapes and metadata count toward the read cap. - for { - raw, err := json.Marshal(out) - if err != nil || len(raw) <= session.WorkstreamSourceReadMax { - return raw, err - } - end := len(out.Content) / 2 - for end > 0 && !utf8.RuneStart(out.Content[end]) { - end-- - } - tail := "\nThe source read was cut." - if item.Kind == workstream.SourceFile { - tail = "\nThe file was cut." - } - out.Content = out.Content[:end] + tail - } -} diff --git a/cmd/codeaf/appworkstreams_agent_sources_test.go b/cmd/codeaf/appworkstreams_agent_sources_test.go deleted file mode 100644 index 00f1ca07e3..0000000000 --- a/cmd/codeaf/appworkstreams_agent_sources_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func sessionWorkstreamSourceProposalForTest() session.WorkstreamSourceProposal { - return session.WorkstreamSourceProposal{Kind: "link", URL: workstreamPtr("https://example.com/guide"), Why: "Read the guide."} -} - -// WP-12 (g): the agent adds only a proposal, lists its exact stored fields and -// reads a text source; duplicate and invalid proposals add nothing else. -func TestWorkstreamAgentSourceProposalsAndReads(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - path := workstreamSourcesFile(t) - ask := session.WorkstreamSourceProposal{Kind: "file", Path: &path, Why: "Read recorded usage."} - before := workstreamLimitSnapshot(t, b, w.ID) - events := len(workstreamEvents(rec)) - proposal, err := door.ProposeSource(ctx, ask) - if err != nil || proposal.ID == "" || proposal.Already { - t.Fatalf("proposal: %+v %v", proposal, err) - } - again, err := door.ProposeSource(ctx, ask) - if err != nil || !again.Already || again.ID != proposal.ID || len(workstreamEvents(rec)) != events+1 { - t.Fatalf("duplicate: %+v %v", again, err) - } - listed, err := door.Read(ctx, session.WorkstreamReadAsk{What: "sources"}) - if err != nil { - t.Fatal(err) - } - var list appwire.WorkstreamSourceListResult - if err := json.Unmarshal(listed, &list); err != nil { - t.Fatal(err) - } - if len(list.Sources) != 1 { - t.Fatalf("list: %s", listed) - } - item := list.Sources[0] - if item.ID != proposal.ID || item.Kind != "file" || item.Path == nil || *item.Path != path || !item.Proposed || item.ReadByJobs || item.AddedBy != "agent" || item.Why == nil || *item.Why != ask.Why { - t.Fatalf("proposal row: %+v", item) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: proposal.ID}) - if err != nil { - t.Fatal(err) - } - var read struct { - Source appwire.WorkstreamSourceItem `json:"source"` - Content string `json:"content"` - } - if err := json.Unmarshal(raw, &read); err != nil { - t.Fatal(err) - } - if read.Content != "Recorded usage.\n" || read.Source.ID != proposal.ID { - t.Fatalf("source read: %s", raw) - } - for _, bad := range []session.WorkstreamSourceProposal{{Kind: "file", Path: workstreamPtr("relative"), Why: "Reason"}, {Kind: "link", URL: workstreamPtr("file:///etc/passwd"), Why: "Reason"}, {Kind: "link", URL: workstreamPtr("https://example.com/other"), Why: ""}} { - if _, err := door.ProposeSource(ctx, bad); err == nil { - t.Fatal("invalid proposal succeeded") - } - } - after := workstreamLimitSnapshot(t, b, w.ID) - if before.Pages != after.Pages || before.Records != after.Records || before.Versions != after.Versions || !reflect.DeepEqual(before.Row.Members, after.Row.Members) || len(workstreamEvents(rec)) != events+1 { - t.Fatal("source proposals changed unrelated state") - } - if _, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: "missing"}); err == nil { - t.Fatal("unknown source read succeeded") - } - other := workstreamCreateForTest(t, b, "Other source owner") - foreign, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: other.ID, Kind: "file", Path: &path}) - if err != nil { - t.Fatal(err) - } - if _, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: foreign.Source.ID}); err == nil { - t.Fatal("foreign source read succeeded") - } -} - -// WP-12 (g): reads use only the stored file/folder path, bound content and -// entry names, and explain binary or missing files in one line. -func TestWorkstreamAgentSourceFileReadBounds(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - path := workstreamSourcesFile(t) - file, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - data string - want string - }{ - {strings.Repeat("é", session.WorkstreamSourceReadMax), "The file was cut."}, - {strings.Repeat("\"", session.WorkstreamSourceReadMax), "The file was cut."}, - {"binary\x00bytes", "The file is binary, not UTF-8 text."}, - {string([]byte{255, 254}), "The file is binary, not UTF-8 text."}, - } { - if err := os.WriteFile(path, []byte(tc.data), 0600); err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: file.Source.ID}) - var answer struct { - Content string `json:"content"` - } - if err != nil || len(raw) > session.WorkstreamSourceReadMax || !utf8.Valid(raw) { - t.Fatalf("bounded source: %d %v", len(raw), err) - } - if err := json.Unmarshal(raw, &answer); err != nil || !strings.Contains(answer.Content, tc.want) { - t.Fatalf("content: %q %v", answer.Content, err) - } - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: file.Source.ID}) - if err != nil || !strings.Contains(string(raw), "unreadable") { - t.Fatalf("missing file: %s %v", raw, err) - } - folder := t.TempDir() - for i := 0; i < 205; i++ { - if err := os.WriteFile(filepath.Join(folder, fmt.Sprintf("entry-%03d", i)), []byte("child text must not be read"), 0600); err != nil { - t.Fatal(err) - } - } - for _, kind := range []string{"file", "repo"} { - ask := appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: kind} - if kind == "file" { - ask.Path = &folder - } else { - ask.Repo = &folder - } - made, err := b.WorkstreamSourceAdd(ask) - if err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: made.Source.ID}) - var answer struct { - Content string `json:"content"` - } - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &answer); err != nil || len(strings.Split(answer.Content, "\n")) != 200 || strings.Contains(answer.Content, "child text") { - t.Fatalf("folder names: %s %v", raw, err) - } - } -} - -// WP-12 Fix 1 Major 1 (g): final-component links never expose another file, -// whether present at admission or planted after an ordinary file was added. -func TestWorkstreamAgentMajor1ReadConfinementG(t *testing.T) { - for _, replacement := range []bool{false, true} { - t.Run(fmt.Sprintf("replacement=%t", replacement), func(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - path := workstreamSourcesFile(t) - target := filepath.Join(t.TempDir(), "other.txt") - const otherText = "Bytes from another readable file must never appear." - if err := os.WriteFile(target, []byte(otherText), 0600); err != nil { - t.Fatal(err) - } - plantLink := func() { - t.Helper() - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Symlink(target, path); err != nil { - t.Skipf("symlink unavailable: %v", err) - } - } - if !replacement { - plantLink() - } - item, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) - if err != nil { - t.Fatal(err) - } - if replacement { - plantLink() - } - raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) - if err != nil { - t.Fatal(err) - } - var answer struct { - Content string `json:"content"` - } - if err := json.Unmarshal(raw, &answer); err != nil { - t.Fatal(err) - } - if answer.Content != "The source is a link and was not read." || strings.Contains(string(raw), otherText) { - t.Fatalf("link read: %s", raw) - } - }) - } -} - -// WP-12 Fix 1 Major 1 (g): intermediate directory links remain usable, and -// folder reads return entry names without following file or directory links. -func TestWorkstreamAgentMajor1ReadConfinementGIntermediateAndEntries(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - folder := t.TempDir() - path := filepath.Join(folder, "text.txt") - if err := os.WriteFile(path, []byte("Stored file text."), 0600); err != nil { - t.Fatal(err) - } - link := filepath.Join(t.TempDir(), "folder") - if err := os.Symlink(folder, link); err != nil { - t.Skipf("symlink unavailable: %v", err) - } - for name, target := range map[string]string{"file-link": path, "folder-link": t.TempDir(), "missing-link": filepath.Join(folder, "missing")} { - if err := os.Symlink(target, filepath.Join(folder, name)); err != nil { - t.Fatal(err) - } - } - for _, tc := range []struct { - kind, path, want string - }{ - {"file", filepath.Join(link, "text.txt"), "Stored file text."}, - {"file", folder, "file-link\nfolder-link\nmissing-link\ntext.txt"}, - {"repo", folder, "file-link\nfolder-link\nmissing-link\ntext.txt"}, - } { - ask := appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: tc.kind} - if tc.kind == "repo" { - ask.Repo = &tc.path - } else { - ask.Path = &tc.path - } - item, err := b.WorkstreamSourceAdd(ask) - if err != nil { - t.Fatal(err) - } - raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) - var answer struct { - Content string `json:"content"` - } - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &answer); err != nil || answer.Content != tc.want { - t.Fatalf("%s read: %s %v", tc.kind, raw, err) - } - } -} - -// WP-12 (g): kept pages render markdown, records return text, and network, -// message and job sources stay pointers with no fetch or lookup. -func TestWorkstreamAgentKeptSourcesAndPointers(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Guide", Doc: json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Kept page words."}]}]}`)}) - if err != nil { - t.Fatal(err) - } - record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Kept record text."}) - if err != nil { - t.Fatal(err) - } - cases := []struct { - ask appwire.WorkstreamSourceAddParams - want string - }{ - {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "page", ID: page.Page.ID}}, "Kept page words."}, - {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: record.Record.ID}}, "Kept record text."}, - {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "message", ID: "unlooked-up", ChatID: workstreamPtr("chat")}}, "pointer"}, - {appwire.WorkstreamSourceAddParams{Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "factory_job", ID: "7", Factory: workstreamPtr("f")}}, "pointer"}, - {appwire.WorkstreamSourceAddParams{Kind: "repo", Repo: workstreamPtr("owner/name")}, "pointer"}, - {appwire.WorkstreamSourceAddParams{Kind: "issue", Repo: workstreamPtr("owner/name"), Number: workstreamPtr(412)}, "pointer"}, - {appwire.WorkstreamSourceAddParams{Kind: "link", URL: workstreamPtr("https://example.com")}, "pointer"}, - } - for _, tc := range cases { - tc.ask.WorkstreamID = w.ID - made, err := b.WorkstreamSourceAdd(tc.ask) - if err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "source", SourceID: made.Source.ID}) - if err != nil || !strings.Contains(string(raw), tc.want) { - t.Fatalf("kept read: %s %v", raw, err) - } - } -} - -// WP-12 (g), (h): a done state committed after resolving the agent door is -// still enforced by the store, with an empty change receipt. -func TestWorkstreamSourceProposalDoneAfterResolution(t *testing.T) { - _, _, _, door := workstreamAgentFixture(t) - ctx := context.Background() - s, w, err := door.(*workstreamAgentDoor).workstreamOwner(ctx, true) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - _, _, change, err := s.ProposeSource(ctx, w.ID, workstream.SourceAdd{Kind: "link", URL: workstreamPtr("https://example.com")}, "Read it.") - if workstreamAgentError(err) != session.ErrWorkstreamDone || len(change.Parts) != 0 { - t.Fatalf("late done: %+v %v", change, err) - } -} diff --git a/cmd/codeaf/appworkstreams_agent_test.go b/cmd/codeaf/appworkstreams_agent_test.go deleted file mode 100644 index df93039c21..0000000000 --- a/cmd/codeaf/appworkstreams_agent_test.go +++ /dev/null @@ -1,583 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/factory/api" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func workstreamAgentFixture(t *testing.T) (*appBackend, *appRecorder, appwire.Workstream, session.WorkstreamDoor) { - t.Helper() - b, rec, _, _ := workstreamBackend(t) - b.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) } - w := workstreamCreateForTest(t, b, "Agent", workstreamJobRef("7")) - if _, err := b.keepCreatedBy(w.AgentID, workstreamCreatedBy); err != nil { - t.Fatal(err) - } - file := filepath.Join(t.TempDir(), w.AgentID, "transcript.jsonl") - return b, rec, w, b.workstreamAgentDoor(file) -} - -// Contract (a) binds the capability to its own conversation, even before creation. -func TestWorkstreamAgentDoorIsScopedAndResolvesLazily(t *testing.T) { - b, _, _, person := workstreamBackend(t) - file := filepath.Join(t.TempDir(), "workstream-agent-1", "transcript.jsonl") - if b.workstreamAgentDoor(file) != nil || b.workstreamAgentDoor(filepath.Join(t.TempDir(), person.ID, "transcript.jsonl")) != nil { - t.Fatal("a person's conversation has a workstream door") - } - if _, err := b.keepCreatedBy("workstream-agent-1", workstreamCreatedBy); err != nil { - t.Fatal(err) - } - door := b.workstreamAgentDoor(file) - if door == nil { - t.Fatal("a workstream conversation needs a door before the row exists") - } - ctx := context.Background() - if brief, ok := door.Brief(); ok || brief != (session.WorkstreamBrief{}) { - t.Fatalf("early brief: %+v %v", brief, ok) - } - _, readErr := door.Read(ctx, session.WorkstreamReadAsk{What: "workstream"}) - _, proposeErr := door.Propose(ctx, session.WorkstreamProposal{Kind: "decision", Text: "Words"}) - _, planErr := door.Plan(ctx) - _, setErr := door.SetPlan(ctx, 0, session.WorkstreamPlanState{}) - _, pageErr := door.WritePage(ctx, session.WorkstreamPageWrite{Title: "Notes", UpdatedFrom: "Discussion"}) - for _, err := range []error{readErr, proposeErr, planErr, setErr, pageErr} { - if !errors.Is(err, session.ErrWorkstreamMissing) || err.Error() != "No workstream holds this conversation." { - t.Fatalf("early tool: %v", err) - } - } - w := workstreamCreateForTest(t, b, "Lazy") - brief, ok := door.Brief() - if !ok || brief.Name != w.Name { - t.Fatalf("late resolution: %+v %v", brief, ok) - } - previous := appWorkstreamAgents.Swap(nil) - t.Cleanup(func() { appWorkstreamAgents.Store(previous) }) - if appWorkstreamAgentDoor(file) != nil { - t.Fatal("an engine outside the app has no backend") - } - appWorkstreamAgents.Store(b) - if appWorkstreamAgentDoor(file) == nil { - t.Fatal("the app engine must receive the door") - } -} - -// Contract (b) records proposals by the agent and never creates grants. -func TestWorkstreamAgentRecordsStayProposed(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - for _, kind := range []string{"decision", "question"} { - if _, err := door.Propose(ctx, session.WorkstreamProposal{Kind: kind, Text: "Keep this?"}); err != nil { - t.Fatal(err) - } - } - plan := session.WorkstreamPlanState{Milestones: []workstream.Milestone{{ID: "m1", Title: "Ship"}}, Waves: []workstream.Wave{{N: 2, Items: []workstream.PlanItem{}}}} - if _, err := door.SetPlan(ctx, 0, plan); err != nil { - t.Fatal(err) - } - cases := []struct { - ask session.WorkstreamGoAheadAsk - text string - }{ - {session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 12.5, EndsOn: "2026-11-04", Why: "Review"}, "Review"}, - {session.WorkstreamGoAheadAsk{Scope: "wave", Wave: 2, CapUSD: 20, EndsOn: "2026-11-04", Why: "Read"}, "Read"}, - {session.WorkstreamGoAheadAsk{Scope: "milestone", MilestoneID: "m1", CapUSD: 30, EndsOn: "2026-11-04", Why: "Ship"}, "Ship"}, - } - s, _ := b.workstreamStore(false) - for _, tc := range cases { - p, err := door.Propose(ctx, session.WorkstreamProposal{Kind: "question", Text: "untrusted", GoAhead: &tc.ask}) - if err != nil { - t.Fatal(err) - } - rows, err := s.Records(ctx, w.ID, "goahead", "proposed") - want := &workstream.GoAheadProposal{Scope: tc.ask.Scope, Factory: tc.ask.Factory, Job: tc.ask.Job, Wave: tc.ask.Wave, MilestoneID: tc.ask.MilestoneID, CapUSD: tc.ask.CapUSD, EndsAt: time.Date(2026, 11, 4, 23, 59, 59, 0, time.UTC)} - if err != nil || len(rows) == 0 || rows[0].ID != p.ID || rows[0].Text != tc.text || !reflect.DeepEqual(rows[0].GoAhead, want) || rows[0].GoAheadID != "" { - t.Fatalf("go-ahead proposal: %+v %v", rows, err) - } - } - rows, err := s.Records(ctx, w.ID, "", "", true) - if err != nil || len(rows) != 5 { - t.Fatalf("records: %+v %v", rows, err) - } - for _, row := range rows { - if row.State != "proposed" || row.By != "agent" || row.Source == nil || row.Source.Kind != "chat" || row.Source.ID != w.AgentID || !row.CreatedAt.Equal(b.now()) { - t.Fatalf("proposal attribution: %+v", row) - } - } - grants, err := s.GoAheads(ctx, w.ID, true) - if err != nil || len(grants) != 0 { - t.Fatalf("a proposal granted permission: %+v %v", grants, err) - } - if len(workstreamEvents(rec)) != 7 { - t.Fatalf("each committed write needs one event: %+v", workstreamEvents(rec)) - } -} - -// Contract (c) leaves membership, stage and Factory seams alone when saving drafts. -func TestWorkstreamAgentDraftPlanNeverTouchesFactory(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - t.Fatal("plan write called the Factory queue") - return api.Queue{}, nil - } - b.workstreams.job = func(context.Context, string, int) (api.Job, error) { - t.Fatal("plan write called a Factory job") - return api.Job{}, nil - } - s, _ := b.workstreamStore(false) - before, _ := s.Get(ctx, w.ID) - p, err := door.Plan(ctx) - if err != nil { - t.Fatal(err) - } - p.Waves = []workstream.Wave{{N: 1, AutoStart: false, Items: []workstream.PlanItem{{Kind: "draft", DraftID: "draft_fixed", Title: "Proposed"}}}} - saved, err := door.SetPlan(ctx, p.Version, p) - if err != nil || saved.Waves[0].Items[0].Kind != "draft" { - t.Fatalf("draft plan: %+v %v", saved, err) - } - after, _ := s.Get(ctx, w.ID) - if !reflect.DeepEqual(before.Members, after.Members) || after.Stage != "shaping" { - t.Fatalf("drafts changed membership or stage: %+v", after) - } - if _, err := door.SetPlan(ctx, 0, p); !errors.Is(err, session.ErrWorkstreamVersion) { - t.Fatalf("typed conflict: %v", err) - } -} - -// Contracts (d) and (e) cover receipts, attribution, history and refusal without events. -func TestWorkstreamAgentPagesAreOwnedAndMarked(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - s, _ := b.workstreamStore(false) - nodes, _ := s.Pages(ctx, w.ID) - before, _ := s.Page(ctx, nodes[0].ID, false) - history, _ := s.PageHistory(ctx, before.ID, 50) - events := len(workstreamEvents(rec)) - _, err := door.WritePage(ctx, session.WorkstreamPageWrite{PageID: before.ID, Markdown: "Forbidden", UpdatedFrom: "Discussion"}) - after, _ := s.Page(ctx, before.ID, false) - afterHistory, _ := s.PageHistory(ctx, before.ID, 50) - if !errors.Is(err, session.ErrWorkstreamNotAgentPage) || !reflect.DeepEqual(before, after) || !reflect.DeepEqual(history, afterHistory) || len(workstreamEvents(rec)) != events { - t.Fatalf("person page refusal: %v", err) - } - ask := session.WorkstreamPageWrite{Title: "Notes", ParentID: before.ID, Markdown: "# Notes\n\n[live: jobs]\n", UpdatedFrom: "Discussion"} - created, err := door.WritePage(ctx, ask) - if err != nil || created.Version != 1 { - t.Fatalf("create: %+v %v", created, err) - } - p, _ := s.Page(ctx, created.ID, true) - if p.Owner != "agent" || p.ReadByJobs || p.ParentID != before.ID || p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(b.now()) || p.UpdatedFrom.Label != "Discussion" || p.UpdatedFrom.ID != w.AgentID || *p.Markdown != ask.Markdown { - t.Fatalf("marked page: %+v", p) - } - b.now = func() time.Time { return time.Date(2026, 11, 3, 13, 0, 0, 0, time.UTC) } - ask = session.WorkstreamPageWrite{PageID: p.ID, Markdown: "Updated", UpdatedFrom: "Review", Source: &workstream.Source{Kind: "factory_job", Factory: "f", ID: "7", Label: "Replaced"}} - updated, err := door.WritePage(ctx, ask) - if err != nil || updated.Version != 2 { - t.Fatalf("update: %+v %v", updated, err) - } - p, _ = s.Page(ctx, p.ID, false) - history, _ = s.PageHistory(ctx, p.ID, 50) - if p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(b.now()) || p.UpdatedFrom.Label != "Review" || p.UpdatedFrom.Kind != "factory_job" || history[0].SavedBy != "agent" || !history[0].SavedAt.Equal(b.now()) || !reflect.DeepEqual(p.UpdatedFrom, history[0].From) || len(workstreamEvents(rec)) != events+2 { - t.Fatalf("updated attribution: %+v %+v", p, history) - } - if wireCode(appWorkstreamError(workstream.ErrNotAgentPage)) != appwire.CodeBadParams { - t.Fatal("the wire needs a bad_params ownership refusal") - } -} - -func TestStabEm1AgentSpecAndDecisionsPagesAreReadByJobs(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - title string - want bool - }{{"Spec", true}, {" decisions ", true}, {" sPeC ", true}, {"DECISIONS", true}, {"Notes", false}} { - t.Run(tc.title, func(t *testing.T) { - made, err := door.WritePage(ctx, session.WorkstreamPageWrite{Title: tc.title, Markdown: "First", UpdatedFrom: "Discussion"}) - if err != nil { - t.Fatal(err) - } - page, err := s.Page(ctx, made.ID, false) - if err != nil || page.ReadByJobs != tc.want { - t.Fatalf("agent default: read_by_jobs=%v, want %v: %v", page.ReadByJobs, tc.want, err) - } - wire, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: tc.title}) - if err != nil || wire.Page.ReadByJobs != tc.want { - t.Fatalf("page.create default: read_by_jobs=%v, want %v: %v", wire.Page.ReadByJobs, tc.want, err) - } - // Explicit choices survive later agent writes, even for these default titles. - for _, read := range []bool{false, true} { - if _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: page.ID, ReadByJobs: &read}); err != nil { - t.Fatal(err) - } - if _, err := door.WritePage(ctx, session.WorkstreamPageWrite{PageID: page.ID, Markdown: "Updated", UpdatedFrom: "Review"}); err != nil { - t.Fatal(err) - } - page, err = s.Page(ctx, page.ID, false) - if err != nil || page.ReadByJobs != read { - t.Fatalf("agent update: read_by_jobs=%v, want %v: %v", page.ReadByJobs, read, err) - } - } - }) - } -} - -func TestStabEM4PlanWireBoundsAreAtomic(t *testing.T) { - for _, name := range []string{"draft text", "milestones", "waves", "items", "encoded JSON"} { - t.Run(name, func(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - params := appwire.WorkstreamPlanSetParams{ID: w.ID, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{{N: 1, Items: []appwire.WorkstreamPlanItem{{Kind: "draft", DraftID: workstreamPtr("d"), Title: workstreamPtr("Draft")}}}}} - switch name { - case "draft text": - params.Waves[0].Items[0].Text = workstreamPtr(strings.Repeat("x", workstream.PlanDraftTextMax)) - case "milestones": - for range workstream.PlanMilestonesMax { - params.Milestones = append(params.Milestones, appwire.WorkstreamMilestone{Title: "Milestone"}) - } - case "waves": - params.Waves = []appwire.WorkstreamWave{} - for i := range workstream.PlanWavesMax { - params.Waves = append(params.Waves, appwire.WorkstreamWave{N: i + 1, Items: []appwire.WorkstreamPlanItem{}}) - } - case "items": - item := params.Waves[0].Items[0] - params.Waves[0].Items = []appwire.WorkstreamPlanItem{} - for range workstream.PlanItemsMax { - params.Waves[0].Items = append(params.Waves[0].Items, item) - } - } - var before appwire.WorkstreamPlanSetResult - if err := json.Unmarshal(workstreamWireEncode(t, workstreamWireResult(t, connection.call(t, "workstream.plan_set", params))), &before); err != nil { - t.Fatal(err) - } - if before.Plan.Version != 1 { - t.Fatalf("at bound: version=%d", before.Plan.Version) - } - params.BaseVersion = before.Plan.Version - switch name { - case "draft text": - params.Waves[0].Items[0].Text = workstreamPtr(*params.Waves[0].Items[0].Text + "x") - case "milestones": - params.Milestones = append(params.Milestones, appwire.WorkstreamMilestone{Title: "Extra"}) - case "waves": - params.Waves = append(params.Waves, appwire.WorkstreamWave{N: workstream.PlanWavesMax + 1, Items: []appwire.WorkstreamPlanItem{}}) - case "items": - params.Waves[0].Items = append(params.Waves[0].Items, params.Waves[0].Items[0]) - case "encoded JSON": - item := params.Waves[0].Items[0] - // JSON escapes each '<' to six bytes, while every draft stays below its text bound. - item.Text = workstreamPtr(strings.Repeat("<", workstream.PlanDraftTextMax)) - params.Waves[0].Items = []appwire.WorkstreamPlanItem{} - for range 9 { - params.Waves[0].Items = append(params.Waves[0].Items, item) - } - } - state := workstreamLimitSnapshot(t, b, w.ID) - events := len(workstreamEvents(rec)) - workstreamLimitWireRefusal(t, connection, "workstream.plan_set", params) - if name == "items" { - // The agent uses the same store authority and must receive the actual limit. - plan, err := door.Plan(ctx) - if err != nil { - t.Fatal(err) - } - agentPlan := plan - agentPlan.Waves[0].Items = append(agentPlan.Waves[0].Items, workstream.PlanItem{Kind: "draft", DraftID: "extra", Title: "Extra"}) - _, err = door.SetPlan(ctx, plan.Version, agentPlan) - if !errors.Is(err, workstream.ErrLimit) || !strings.Contains(err.Error(), "5000 items") || len(workstreamEvents(rec)) != events { - t.Fatalf("agent plan limit: %v", err) - } - } - after, err := b.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{ID: w.ID}) - if err != nil || !reflect.DeepEqual(before.Plan, after.Plan) || !reflect.DeepEqual(state, workstreamLimitSnapshot(t, b, w.ID)) || len(workstreamEvents(rec)) != events { - t.Fatalf("refused plan changed state: version=%d: %v", after.Plan.Version, err) - } - }) - } -} - -func TestStabEM4PageCountIncludesDeleted(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - // Together with Overview, these rows leave exactly one page slot. - if err := b.collections.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `WITH RECURSIVE workstream_seed(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM workstream_seed WHERE n= total { - return nil - } - _, err := tx.Exec(`WITH RECURSIVE workstream_seed(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM workstream_seed WHERE n 0 { - comparison.Differed[itemPath] = true - // The question permits only the shape explicitly illustrated for - // this element, so it cannot hide new keys, nulls or wrong kinds. - differences = comparison.shape(itemPath, item, example[i], workstreamWirePreparedShape) - } - failures = append(failures, differences...) - } - } - return failures -} - -func workstreamWireCheck(t *testing.T, failures []string) { - t.Helper() - for _, failure := range failures { - t.Error(failure) - } -} - -func workstreamWireLoad(t *testing.T) (workstreamWireContract, map[string]workstreamWireExamples) { - t.Helper() - root := filepath.Join("testdata", "workstreams-wire") - raw, err := os.ReadFile(filepath.Join(root, "contract.json")) - if err != nil { - t.Fatal(err) - } - var contract workstreamWireContract - if err := json.Unmarshal(raw, &contract); err != nil { - t.Fatal(err) - } - paths, err := filepath.Glob(filepath.Join(root, "*.json")) - if err != nil { - t.Fatal(err) - } - files := make(map[string]workstreamWireExamples) - for _, path := range paths { - name := strings.TrimSuffix(filepath.Base(path), ".json") - if name == "contract" { - continue - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - var examples workstreamWireExamples - if err := json.Unmarshal(raw, &examples); err != nil { - t.Fatalf("%s: %v", path, err) - } - files[name] = examples - } - return contract, files -} - -// Warm would open real provider/process owners. The existing harness already -// supplies their fakes; method dispatch and Close still belong to appBackend. -type workstreamWireBackend struct{ *appBackend } - -func (*workstreamWireBackend) Warm() {} - -type workstreamWireUnavailable struct{ *appBackend } - -func (*workstreamWireUnavailable) Warm() {} -func (*workstreamWireUnavailable) ServesWorkstreams() bool { return false } - -const workstreamWireTimeout = 30 * time.Second - -type workstreamWireConnection struct { - In *io.PipeWriter - Lines <-chan []byte - Err <-chan error - Next int - Writes sync.WaitGroup -} - -func workstreamWireConnect(t *testing.T, backend appwire.Engine) *workstreamWireConnection { - t.Helper() - inR, inW := io.Pipe() - outR, outW := io.Pipe() - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - server := appwire.NewServer(outW) - go func() { - err := server.Serve(ctx, inR, appwire.Hello{Version: "test"}, backend) - _ = inR.Close() - _ = outW.Close() - done <- err - }() - lines := make(chan []byte, 32) - readErr := make(chan error, 1) - readerDone := make(chan struct{}) - go func() { - defer close(readerDone) - defer close(lines) - scanner := bufio.NewScanner(outR) - scanner.Buffer(make([]byte, 64<<10), 4<<20) - for scanner.Scan() { - select { - case lines <- bytes.Clone(scanner.Bytes()): - case <-ctx.Done(): - return - } - } - readErr <- scanner.Err() - }() - connection := &workstreamWireConnection{In: inW, Lines: lines, Err: readErr} - t.Cleanup(func() { - _ = inW.Close() - connection.Writes.Wait() - select { - case err := <-done: - if err != nil { - t.Errorf("wire Serve: %v", err) - } - case <-time.After(workstreamWireTimeout): - t.Error("wire Serve did not close the backend") - } - cancel() - _ = inR.Close() - _ = outR.Close() - select { - case <-readerDone: - case <-time.After(5 * time.Second): - t.Error("wire reader did not stop") - } - }) - hello := connection.read(t) - if hello["event"] != appwire.EventHello { - t.Fatalf("want hello before replies, got %v", hello) - } - return connection -} - -func (connection *workstreamWireConnection) read(t *testing.T) map[string]any { - t.Helper() - select { - case raw, open := <-connection.Lines: - if !open { - t.Fatalf("wire ended before reply: %v", <-connection.Err) - } - frame, ok := workstreamWireDecode(t, raw).(map[string]any) - if !ok { - t.Fatalf("wire frame is not an object: %s", raw) - } - return frame - case <-time.After(workstreamWireTimeout): - t.Fatal("wire reply timed out") - return nil - } -} - -func (connection *workstreamWireConnection) call(t *testing.T, method string, params any) map[string]any { - t.Helper() - raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": method, "params": params}) - return connection.callRaw(t, raw) -} - -func (connection *workstreamWireConnection) callRaw(t *testing.T, raw []byte) map[string]any { - t.Helper() - connection.Next++ - writeErr := make(chan error, 1) - connection.Writes.Add(1) - go func() { - defer connection.Writes.Done() - _, err := connection.In.Write(append(raw, '\n')) - writeErr <- err - }() - select { - case err := <-writeErr: - if err != nil { - t.Fatal(err) - } - case <-time.After(workstreamWireTimeout): - t.Fatal("wire request write timed out") - } - for { - frame := connection.read(t) - id, reply := frame["id"].(json.Number) - if !reply { - if _, event := frame["event"].(string); !event { - t.Fatalf("frame has neither a numeric id nor an event: %v", frame) - } - continue - } - if id.String() != strconv.Itoa(connection.Next) { - t.Fatalf("reply id %s, want %d", id, connection.Next) - } - if len(frame) != 2 { - t.Fatalf("reply must have id and one result or error, got keys %v", workstreamWireKeys(frame)) - } - return frame - } -} - -func workstreamWireResult(t *testing.T, frame map[string]any) any { - t.Helper() - result, present := frame["result"] - if !present || result == nil { - t.Fatalf("want result without error, got %v", frame) - } - return result -} - -type workstreamWireFixture struct { - Backend *appBackend - Recorder *appRecorder - IDs map[string]string - Now time.Time - Stream appwire.Workstream -} - -func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWireFixture { - t.Helper() - backend, recorder, _, origin := workstreamBackend(t) - now := time.Now().UTC().Truncate(time.Millisecond) - backend.now = func() time.Time { return now } - backend.workstreams.archiveChats = func([]string) error { return nil } - fixture := &workstreamWireFixture{ - Backend: backend, Recorder: recorder, Now: now, - IDs: map[string]string{"conv_5b9e": origin.ID, "ms_usage": "conformance-milestone", "draft_418c": "conformance-draft"}, - } - fixture.IDs["/Users/ada/billing/usage-pricing.md"] = workstreamSourcesFile(t) - if name == "workstream.create" { - // An unobserved member job keeps all optional numbers unknown on create. - return fixture - } - zero := name == "workstream.get" && situation == 1 - unknown := name == "workstream.list" && situation == 1 - params := appwire.WorkstreamCreateParams{Name: "Usage-based billing", Why: workstreamPtr("Charge for recorded usage.")} - if !zero { - // WP-F2 (c), (f): the unknown list example deliberately has no origin. - if !unknown { - params.OriginChatID = &origin.ID - } - if name != "collections.add" { - ref := workstreamJobRef("1107") - ref.Factory = workstreamPtr("f_local") - params.Refs = []appwire.WorkstreamRef{ref} - } - } - if zero { - params.Why = nil - } - made, err := backend.WorkstreamCreate(params) - if err != nil { - t.Fatal(err) - } - fixture.Stream = made.Workstream - fixture.IDs["ws_8f2c1a"] = made.Workstream.ID - fixture.IDs["conv_8d22"] = made.Workstream.AgentID - if unknown { - backend.workstreams.spend = func(string) *float64 { return nil } - } else { - backend.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{{ID: 1107, Num: 2107, State: "needs_you", Cost: 2.5}}}, nil - } - } - if !zero && !unknown && strings.HasPrefix(name, "workstream.") && name != "workstream.changed" { - if _, err := backend.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: made.Workstream.ID, Patch: appwire.WorkstreamPatch{BudgetUSD: workstreamPtr(100.0)}}); err != nil { - t.Fatal(err) - } - } - pages, err := backend.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: made.Workstream.ID}) - if err != nil || len(pages.Pages) != 1 { - t.Fatalf("seed Overview: %+v, %v", pages, err) - } - fixture.IDs["pg_31d0"] = pages.Pages[0].ID - switch name { - case "wake.list", "wake.apply", "wake.undo", "wake.not_now": - fixture.wake(t, name) - case "workstream.reopen": - if _, err := backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: made.Workstream.ID}); err != nil { - t.Fatal(err) - } - case "page.update", "page.delete", "page.move", "workstream.changed": - parent := fixture.IDs["pg_31d0"] - if name == "page.move" { - parent = "" - } - fixture.page(t, "pg_32e1", parent) - if name == "page.move" && situation == 1 { - fixture.page(t, "pg_548c", fixture.IDs["pg_31d0"]) - } - if name == "page.delete" { - fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"]) - } - case "page.history": - fixture.saveByAgent(t) - case "page.save": - // WP-F2 (b), (f): example_1 saves version 3 with a source. - if situation == 1 { - if _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: fixture.IDs["pg_31d0"], BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { - t.Fatal(err) - } - } - // WP-F1 (g): prepare all three additive example_1 pairs with the exact - // optional keys illustrated by the wire contract. - case "page.list": - if situation == 1 { - fixture.deletedPage(t) - } - case "page.get": - if situation == 1 { - fixture.saveByAgent(t) - } - case "page.restore": - if situation == 1 { - fixture.deletedPage(t) - } else { - fixture.saveByAgent(t) - } - case "suggestion.list", "suggestion.keep", "suggestion.drop": - fixture.suggestion(t) - if name == "suggestion.keep" && situation == 1 { - if _, err := backend.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: fixture.IDs["sg_7c41"], EditID: workstreamPtr("e1")}); err != nil { - t.Fatal(err) - } - } - } - if name == "record.list" || name == "record.keep" || name == "record.drop" || name == "record.supersede" || name == "workstream.changed" { - // WP-F2 (d), (f): only the illustrated kind is present in each example. - if situation == 1 && (name == "record.list" || name == "record.keep") { - fixture.plan(t) - fixture.goaheadRecord(t) - } else { - fixture.record(t) - } - if name == "record.supersede" { - if _, err := backend.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: fixture.IDs["rec_7a1e"]}); err != nil { - t.Fatal(err) - } - } - } - if name == "record.propose" && situation == 1 { - fixture.plan(t) - } - if strings.HasPrefix(name, "goahead.") || name == "workstream.changed" { - fixture.plan(t) - if name != "goahead.grant" { - fixture.grant(t) - } - } - if name == "source.list" || name == "source.update" || name == "source.remove" { - fixture.sources(t) - } - return fixture -} - -func (fixture *workstreamWireFixture) page(t *testing.T, alias, parent string) { - t.Helper() - params := appwire.WorkstreamPageCreateParams{WorkstreamID: fixture.Stream.ID, Title: "Metering decisions"} - if parent != "" { - params.ParentID = &parent - } - page, err := fixture.Backend.WorkstreamPageCreate(params) - if err != nil { - t.Fatal(err) - } - fixture.IDs[alias] = page.Page.ID -} - -// WP-6d conformance: both anchors and every optional key match the wire contract. -func (fixture *workstreamWireFixture) suggestion(t *testing.T) { - t.Helper() - s, err := fixture.Backend.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - id := fixture.IDs["pg_31d0"] - doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Meter usage nightly."}]},{"type":"paragraph","content":[{"type":"text","text":"Bill at month end."}]}]}`) - page, err := s.Page(context.Background(), id, false) - if err != nil { - t.Fatal(err) - } - for page.Version < 3 { - page, _, err = s.SavePage(context.Background(), id, page.Version, doc, workstream.Attribution{}) - if err != nil { - t.Fatal(err) - } - } - suggestion, _, err := s.CreateSuggestion(context.Background(), workstream.SuggestionCreate{WorkstreamID: fixture.Stream.ID, PageID: id, BaseVersion: page.Version, Why: "api#1107 moved metering to hourly", Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: fixture.Stream.AgentID}}, Edits: []workstream.SuggestionFind{ - {Find: "nightly", Replace: "every hour", Why: "The meter runs hourly since api#1107"}, - {Find: "Bill at month end.", Replace: "Invoices stay monthly. Bill at month end.", Why: "Says what did not change"}, - }}) - if err != nil { - t.Fatal(err) - } - fixture.IDs["sg_7c41"] = suggestion.ID -} - -func (fixture *workstreamWireFixture) deletedPage(t *testing.T) { - t.Helper() - fixture.page(t, "pg_33f2", fixture.IDs["pg_31d0"]) - id := fixture.IDs["pg_33f2"] - if _, err := fixture.Backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { - t.Fatal(err) - } - if _, err := fixture.Backend.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); err != nil { - t.Fatal(err) - } -} - -func (fixture *workstreamWireFixture) source() *appwire.WorkstreamSource { - return &appwire.WorkstreamSource{Kind: "message", ID: "msg_19a2", ChatID: workstreamPtr(fixture.IDs["conv_5b9e"]), Label: workstreamPtr("Billing scope")} -} - -func (fixture *workstreamWireFixture) saveByAgent(t *testing.T) { - t.Helper() - store, err := fixture.Backend.workstreamStore(false) - if err != nil || store == nil { - t.Fatalf("seed page store: %v", err) - } - // The agent writes only its own pages, so the seed hands it the Overview - // for the one save the contract's examples show and then hands it back, - // still attributed to the agent, without a new version. - ctx, id := context.Background(), fixture.IDs["pg_31d0"] - agent := workstream.Attribution{By: "agent", From: workstreamSource(fixture.source())} - lent, kept := "agent", "you" - if _, _, err := store.UpdatePage(ctx, id, workstream.PagePatch{Owner: &lent, Attribution: agent}); err != nil { - t.Fatal(err) - } - if _, _, err := store.SavePage(ctx, id, 1, json.RawMessage(`{"type":"doc","content":[]}`), agent); err != nil { - t.Fatal(err) - } - if _, _, err := store.UpdatePage(ctx, id, workstream.PagePatch{Owner: &kept, Attribution: agent}); err != nil { - t.Fatal(err) - } -} - -func (fixture *workstreamWireFixture) record(t *testing.T) { - t.Helper() - record, err := fixture.Backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{ - WorkstreamID: fixture.Stream.ID, Kind: "question", Text: "Should retries count toward usage?", Source: fixture.source(), - }) - if err != nil { - t.Fatal(err) - } - fixture.IDs["rec_7a1e"] = record.Record.ID -} - -func (fixture *workstreamWireFixture) goaheadRecord(t *testing.T) { - t.Helper() - record, err := fixture.Backend.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{ - WorkstreamID: fixture.Stream.ID, Kind: "goahead", Text: "Wave 1 is small; let it run without asking.", Source: fixture.source(), - GoAhead: &appwire.WorkstreamGoAheadProposal{Scope: "wave", Wave: workstreamPtr(1), CapUSD: 40, EndsAt: fixture.Now.Add(time.Hour)}, - }) - if err != nil { - t.Fatal(err) - } - fixture.IDs["rec_5c20"] = record.Record.ID -} - -func (fixture *workstreamWireFixture) plan(t *testing.T) { - t.Helper() - _, err := fixture.Backend.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ - ID: fixture.Stream.ID, - Milestones: []appwire.WorkstreamMilestone{{ID: fixture.IDs["ms_usage"], Title: "Recorded usage"}}, - Waves: []appwire.WorkstreamWave{{N: 1, Title: workstreamPtr("Metering"), MilestoneID: workstreamPtr(fixture.IDs["ms_usage"]), Items: []appwire.WorkstreamPlanItem{ - {Kind: "job", Factory: workstreamPtr("f_local"), Job: workstreamPtr(1107)}, - {Kind: "draft", DraftID: workstreamPtr(fixture.IDs["draft_418c"]), Title: workstreamPtr("Expose usage totals"), Repo: workstreamPtr("acme/billing"), Text: workstreamPtr("Show the recorded usage.")}, - }}}, - }) - if err != nil { - t.Fatal(err) - } -} - -func (fixture *workstreamWireFixture) grant(t *testing.T) { - t.Helper() - grant, err := fixture.Backend.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{ - WorkstreamID: fixture.Stream.ID, Scope: "job", Factory: workstreamPtr("f_local"), Job: workstreamPtr(1107), CapUSD: 40, EndsAt: fixture.Now.Add(time.Hour), - }) - if err != nil { - t.Fatal(err) - } - fixture.IDs["ga_0c44"] = grant.GoAhead.ID -} - -// The decoded example is a fresh tree on every call. Only existing values are -// replaced; prepared shape comparison proves that keys and kinds survive. -func (fixture *workstreamWireFixture) prepare(t *testing.T, raw json.RawMessage) map[string]any { - t.Helper() - var replace func(any) any - replace = func(value any) any { - switch value := value.(type) { - case map[string]any: - for key, item := range value { - if key == "ends_at" { - value[key] = fixture.Now.Add(time.Hour).Format(time.RFC3339Nano) - } else { - value[key] = replace(item) - } - } - case []any: - for i, item := range value { - value[i] = replace(item) - } - case string: - if seeded, known := fixture.IDs[value]; known { - return seeded - } - } - return value - } - return replace(workstreamWireDecode(t, raw)).(map[string]any) -} - -func (contract *workstreamWireContract) errorType(code string) (workstreamWireErrorType, bool) { - for _, definition := range contract.Errors { - if definition.Code == code { - return definition, true - } - } - return workstreamWireErrorType{}, false -} - -func (contract *workstreamWireContract) checkError(t *testing.T, path string, value any, code string, example any, exactMessage bool) { - t.Helper() - object, ok := value.(map[string]any) - if !ok { - t.Fatalf("%s: want error object, got %s", path, workstreamWireKind(value)) - } - workstreamWireCheck(t, workstreamWireNoNulls(path, object)) - if object["code"] != code { - t.Errorf("%s.code: got %v, want %s", path, object["code"], code) - } - message, ok := object["message"].(string) - if !ok || strings.TrimSpace(message) == "" { - t.Errorf("%s.message: want non-empty string, got %v", path, object["message"]) - } - definition, known := contract.errorType(code) - if !known || definition.DeskLocal { - t.Fatalf("%s: %s is not an engine-sent contract error", path, code) - } - for key := range object { - if key != "code" && key != "message" && key != "detail" { - t.Errorf("%s.%s: unexpected error field", path, key) - } - } - detail, present := object["detail"] - if definition.DetailType == nil { - if present { - t.Errorf("%s.detail: must be absent for %s", path, code) - } - } else if !present { - t.Errorf("%s.detail: missing %s", path, *definition.DetailType) - } else { - workstreamWireCheck(t, contract.conforms(path+".detail", detail, *definition.DetailType)) - } - if example != nil { - comparison := &workstreamWireComparison{} - workstreamWireCheck(t, comparison.shape(path, object, example, workstreamWireReplyShape)) - if exactMessage && object["message"] != example.(map[string]any)["message"] { - t.Errorf("%s.message: got %q, want %q", path, message, example.(map[string]any)["message"]) - } - } -} - -type workstreamWireErrorCase struct { - Name string - Method string - Code string - Example string - ParamsExample int -} - -// TestAppWorkstreamWireConformance keeps the table independent of the fixtures: -// adding a contract method, event, file or engine-sent error must require a case. -func TestAppWorkstreamWireConformance(t *testing.T) { - contract, files := workstreamWireLoad(t) - cases := []string{ - "workstream.list", "workstream.get", "workstream.create", "workstream.update", - "workstream.mark_done", "workstream.reopen", "workstream.plan_get", "workstream.plan_set", - "page.list", "page.get", "page.save", "page.create", "page.update", "page.move", - "page.delete", "page.history", "page.restore", "record.list", "record.propose", - "record.keep", "record.drop", "record.supersede", "goahead.list", "goahead.grant", - "goahead.revoke", "wake.list", "wake.apply", "wake.undo", "wake.not_now", - "suggestion.list", "suggestion.keep", "suggestion.drop", - "source.list", "source.add", "source.update", "source.remove", "collections.add", "workstream.changed", - } - errorCases := []workstreamWireErrorCase{ - {Name: "bad_params", Method: "workstream.get", Code: "bad_params"}, - {Name: "not_found", Method: "workstream.get", Code: "not_found", Example: "workstream.get"}, - // WP-F1 (a), (b), (g): new version and restore error doors use real dispatch. - {Name: "get_unknown_version", Method: "page.get", Code: "not_found", ParamsExample: 1}, - {Name: "restore_version_only", Method: "page.restore", Code: "bad_params"}, - {Name: "restore_base_only", Method: "page.restore", Code: "bad_params"}, - {Name: "restore_live_id_only", Method: "page.restore", Code: "bad_params", ParamsExample: 1}, - {Name: "restore_deleted_version", Method: "page.restore", Code: "bad_params"}, - {Name: "conflict", Method: "workstream.create", Code: "conflict"}, - {Name: "collection_owner", Method: "collections.add", Code: "in_other_workstream", Example: "collections.add"}, - {Name: "create_owner", Method: "workstream.create", Code: "in_other_workstream", Example: "collections.add"}, - {Name: "page_version", Method: "page.save", Code: "version_conflict", Example: "page.save"}, - {Name: "plan_version", Method: "workstream.plan_set", Code: "version_conflict", Example: "page.save"}, - {Name: "suggestion_version", Method: "suggestion.keep", Code: "version_conflict", Example: "suggestion.keep"}, - {Name: "cycle", Method: "page.move", Code: "cycle"}, - {Name: "too_large", Method: "page.save", Code: "too_large"}, - {Name: "plan_too_large", Method: "workstream.plan_set", Code: "too_large"}, - {Name: "records_too_large", Method: "record.propose", Code: "too_large"}, - {Name: "unknown_method", Method: "workstream.list", Code: "unknown_method", Example: "workstream.list"}, - {Name: "busy", Method: "workstream.update", Code: "busy"}, - // WP-12 (b), (h): each sources door exercises its own refusals. - {Name: "source_list_bad", Method: "source.list", Code: "bad_params"}, - {Name: "source_list_missing", Method: "source.list", Code: "not_found"}, - {Name: "source_add_bad", Method: "source.add", Code: "bad_params"}, - {Name: "source_add_missing", Method: "source.add", Code: "not_found"}, - {Name: "source_add_done", Method: "source.add", Code: "conflict"}, - {Name: "source_add_limit", Method: "source.add", Code: "too_large"}, - {Name: "source_update_bad", Method: "source.update", Code: "bad_params"}, - {Name: "source_update_missing", Method: "source.update", Code: "not_found"}, - {Name: "source_update_done", Method: "source.update", Code: "conflict"}, - {Name: "source_remove_bad", Method: "source.remove", Code: "bad_params"}, - {Name: "source_remove_missing", Method: "source.remove", Code: "not_found"}, - {Name: "source_remove_done", Method: "source.remove", Code: "conflict"}, - } - comparison := &workstreamWireComparison{Questions: make(map[string]string), Differed: make(map[string]bool)} - for _, question := range workstreamWireDocQuestions { - if question.Path == "" || strings.TrimSpace(question.Question) == "" || comparison.Questions[question.Path] != "" { - t.Fatalf("invalid or duplicate doc question: %+v", question) - } - comparison.Questions[question.Path] = question.Question - } - - // (a), (c): coverage is checked in both directions, including auxiliary files - // and all engine-sent errors. No busy exemption is needed: it has a real case. - if !t.Run("coverage", func(t *testing.T) { - workstreamWireCoverage(t, &contract, files, cases, errorCases) - }) { - return - } - - // (b), (d): the same strict type validator checks all documentation examples. - t.Run("shape_rules", func(t *testing.T) { - for _, rule := range []struct { - Name string - Type string - JSON string - Valid bool - }{ - {"string", "string", `"text"`, true}, - {"string_number", "string", `1`, false}, - {"nano_time", "string (RFC 3339)", `"2026-11-02T14:05:00.123456789Z"`, true}, - {"bad_time", "string (RFC 3339)", `"yesterday"`, false}, - {"boolean", "boolean", `false`, true}, - {"boolean_string", "boolean", `"false"`, false}, - {"zero", "integer", `0`, true}, - {"integral_decimal", "integer", `1.0`, true}, - {"fraction", "integer", `1.5`, false}, - {"precise_fraction", "integer", `9007199254740992.1`, false}, - {"negative", "integer", `-1`, false}, - {"numeric_string", "integer", `"1"`, false}, - {"usd", "number (USD)", `2.5`, true}, - {"usd_string", "number (USD)", `"2.5"`, false}, - {"enum", "WorkstreamStage", `"building"`, true}, - {"unknown_enum", "WorkstreamStage", `"working"`, false}, - {"empty_array", "WorkstreamChangedPart[]", `[]`, true}, - {"unknown_array_enum", "WorkstreamChangedPart[]", `["jobs","other"]`, false}, - {"null_array", "WorkstreamChangedPart[]", `null`, false}, - {"optional_absent", "WorkstreamChanged", `{"id":"seeded"}`, true}, - {"optional_null", "WorkstreamChanged", `{"id":"seeded","parts":null}`, false}, - {"missing_required", "WorkstreamChanged", `{"parts":[]}`, false}, - {"unknown_field", "WorkstreamChanged", `{"id":"seeded","extra":true}`, false}, - {"opaque_object", "object", `{"arbitrary":[{"value":0}]}`, true}, - {"opaque_nested_null", "object", `{"arbitrary":[null]}`, false}, - } { - t.Run(rule.Name, func(t *testing.T) { - path := "shape rule " + rule.Name + " " - failures := contract.conforms(path, workstreamWireDecode(t, []byte(rule.JSON)), rule.Type) - if (len(failures) == 0) != rule.Valid { - t.Errorf("valid = %v, want %v: %v", len(failures) == 0, rule.Valid, failures) - } - for _, failure := range failures { - if !strings.HasPrefix(failure, path) { - t.Errorf("failure lost its JSON path: %s", failure) - } - } - }) - } - }) - - // (b), (d): missing reply tails cannot skip illustrated shapes; events may - // report fewer changed parts, and prepared arrays must retain exact lengths. - t.Run("array_coverage", func(t *testing.T) { - example := `[{"job":1},{"draft":"draft"}]` - for _, rule := range []struct { - Name string - JSON string - Example string - Mode workstreamWireShapeMode - Valid bool - }{ - {"same_index", example, example, workstreamWireReplyShape, true}, - {"missing_tail", `[{"job":1}]`, example, workstreamWireReplyShape, false}, - {"empty_reply", `[]`, example, workstreamWireReplyShape, false}, - {"nested_missing_tail", `{"items":[{"job":1}]}`, `{"items":` + example + `}`, workstreamWireReplyShape, false}, - {"additional_first_shape", `[{"job":1},{"draft":"draft"},{"job":2}]`, example, workstreamWireReplyShape, true}, - {"additional_wrong_shape", `[{"job":1},{"draft":"draft"},{"draft":"other"}]`, example, workstreamWireReplyShape, false}, - {"empty_example", `[{"job":1}]`, `[]`, workstreamWireReplyShape, true}, - {"prepared_short", `[{"job":1}]`, example, workstreamWirePreparedShape, false}, - {"prepared_long", `[{"job":1},{"draft":"draft"},{"job":2}]`, example, workstreamWirePreparedShape, false}, - {"event_fewer_parts", `{"parts":["pages"]}`, `{"parts":["pages","plan"]}`, workstreamWireEventShape, true}, - {"event_empty_parts", `{"parts":[]}`, `{"parts":["pages","plan"]}`, workstreamWireEventShape, false}, - } { - t.Run(rule.Name, func(t *testing.T) { - path := "array coverage " + rule.Name + " " - comparison := &workstreamWireComparison{} - failures := comparison.shape(path, workstreamWireDecode(t, []byte(rule.JSON)), workstreamWireDecode(t, []byte(rule.Example)), rule.Mode) - if (len(failures) == 0) != rule.Valid { - t.Errorf("valid = %v, want %v: %v", len(failures) == 0, rule.Valid, failures) - } - for _, failure := range failures { - if !strings.HasPrefix(failure, path) { - t.Errorf("failure lost its JSON path: %s", failure) - } - } - }) - } - }) - - // (b), (d): malformed examples are contract failures, before any engine call. - t.Run("doc_self_check", func(t *testing.T) { - for _, name := range cases { - t.Run(name, func(t *testing.T) { - examples := files[name] - for _, part := range []struct { - Name string - Type string - Rows []json.RawMessage - }{ - {"params", examples.ParamsType, examples.Examples.Params}, - {"result", examples.ResultType, examples.Examples.Result}, - {"data", examples.DataType, examples.Examples.Data}, - } { - for i, raw := range part.Rows { - typeString := part.Type - if name == "collections.add" { - typeString = "object" - } - path := fmt.Sprintf("%s %s[%d] ", name, part.Name, i) - workstreamWireCheck(t, contract.conforms(path, workstreamWireDecode(t, raw), typeString)) - } - } - for i, raw := range examples.Examples.Error { - value := workstreamWireDecode(t, raw) - object, ok := value.(map[string]any) - if !ok { - t.Fatalf("%s error[%d] is not an object", name, i) - } - code, ok := object["code"].(string) - if !ok || (name != "collections.add" && !slices.Contains(examples.Errors, code)) { - t.Fatalf("%s error[%d] has unlisted code %v", name, i, object["code"]) - } - contract.checkError(t, fmt.Sprintf("%s error[%d]", name, i), value, code, nil, false) - } - }) - } - }) - - // (b), (d): each params/result pairing gets an isolated seeded situation. - // A single params example with multiple results exercises every situation. - for _, name := range cases { - t.Run(name, func(t *testing.T) { - examples := files[name] - if examples.Event != "" { - workstreamWireEvent(t, &contract, examples, comparison) - return - } - count := max(len(examples.Examples.Params), len(examples.Examples.Result)) - for i := 0; i < count; i++ { - t.Run(fmt.Sprintf("example_%d", i), func(t *testing.T) { - paramsIndex, resultIndex := i, i - if len(examples.Examples.Params) == 1 { - paramsIndex = 0 - } - if len(examples.Examples.Result) == 1 { - resultIndex = 0 - } - fixture := workstreamWireSeed(t, name, i) - params := fixture.prepare(t, examples.Examples.Params[paramsIndex]) - paramsPath := fmt.Sprintf("%s params[%d] ", name, paramsIndex) - workstreamWireCheck(t, comparison.shape(paramsPath, params, workstreamWireDecode(t, examples.Examples.Params[paramsIndex]), workstreamWirePreparedShape)) - if examples.ParamsType != "" { - workstreamWireCheck(t, contract.conforms(paramsPath, params, examples.ParamsType)) - } - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: fixture.Backend}) - result := workstreamWireResult(t, connection.call(t, name, params)) - resultPath := fmt.Sprintf("%s result[%d] ", name, resultIndex) - if examples.ResultType != "" { - workstreamWireCheck(t, contract.conforms(resultPath, result, examples.ResultType)) - } - workstreamWireCheck(t, workstreamWireNoNulls(resultPath, result)) - workstreamWireCheck(t, comparison.shape(resultPath, result, workstreamWireDecode(t, examples.Examples.Result[resultIndex]), workstreamWireReplyShape)) - if name == "page.delete" { - deleted, ok := result.(map[string]any)["deleted"].([]any) - want := []any{fixture.IDs["pg_32e1"], fixture.IDs["conformance-descendant"]} - if !ok || len(deleted) != len(want) || !slices.Contains(deleted, want[0]) || !slices.Contains(deleted, want[1]) { - t.Errorf("%s.deleted: want exactly seeded parent and descendant %v, got %v", resultPath, want, deleted) - } - } - if name == "collections.add" { - workstreamWireCollectionResult(t, result) - } - if name == "record.keep" && i == 1 { - workstreamWireKeptGoAhead(t, connection, fixture.Stream.ID, result) - } - }) - } - }) - } - - // (c): every engine-sent code is produced by a real wire call, including - // both ownership doors and both optimistic-version doors. - t.Run("errors", func(t *testing.T) { - for _, errorCase := range errorCases { - t.Run(errorCase.Name, func(t *testing.T) { - workstreamWireError(t, &contract, files, errorCase) - }) - } - }) - - // (d): fixing an example or changing the observed discrepancy requires - // removing its exception; doc questions cannot silently become permanent. - t.Run("doc_questions_are_current", func(t *testing.T) { - for _, question := range workstreamWireDocQuestions { - if !comparison.Differed[question.Path] { - t.Errorf("stale doc question at %s: %s", question.Path, question.Question) - } - } - }) -} - -// WP-F2 (d), (f): the keep example grants exactly its structured permission. -func workstreamWireKeptGoAhead(t *testing.T, connection *workstreamWireConnection, id string, result any) { - t.Helper() - record := result.(map[string]any)["record"].(map[string]any) - proposal := record["goahead"].(map[string]any) - listed := workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": id})).(map[string]any)["goaheads"].([]any) - if len(listed) != 1 { - t.Fatalf("keep granted %d go-aheads, want one", len(listed)) - } - grant := listed[0].(map[string]any) - if grant["id"] != record["goahead_id"] || grant["state"] != "active" { - t.Fatalf("keep grant identity/state: %v %v", record, grant) - } - for _, key := range []string{"scope", "wave", "cap_usd", "ends_at"} { - if !reflect.DeepEqual(grant[key], proposal[key]) { - t.Errorf("keep grant %s: got %v, want %v", key, grant[key], proposal[key]) - } - } -} - -func workstreamWireCoverage(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, cases []string, errorCases []workstreamWireErrorCase) { - t.Helper() - if len(contract.Methods) != 36 { - t.Errorf("the Suggestions addition (v0.5) has %d methods, want 36", len(contract.Methods)) - } - if len(contract.Events) != 1 || contract.Events[0].Event != appwire.EventWorkstreamChanged { - t.Errorf("the Suggestions addition (v0.5) events: want only workstream.changed, got %v", contract.Events) - } - if !reflect.DeepEqual(contract.Methods, appwire.WorkstreamMethods) { - t.Errorf("contract methods/order %v differ from appwire.WorkstreamMethods %v", contract.Methods, appwire.WorkstreamMethods) - } - counts := make(map[string]int) - for _, name := range cases { - counts[name]++ - if _, exists := files[name]; !exists { - t.Errorf("stale case %s has no example file", name) - } - } - events := make(map[string]string) - for _, event := range contract.Events { - if _, duplicate := events[event.Event]; duplicate { - t.Errorf("duplicate contract event %s", event.Event) - } - events[event.Event] = event.DataType - if _, exists := files[event.Event]; !exists || counts[event.Event] != 1 { - t.Errorf("event %s needs one file and one case", event.Event) - } - } - methods := make(map[string]bool) - for _, name := range contract.Methods { - if methods[name] { - t.Errorf("duplicate contract method %s", name) - } - methods[name] = true - if _, exists := files[name]; !exists || counts[name] != 1 { - t.Errorf("method %s needs one file and one case, got %d cases", name, counts[name]) - } - } - for name, examples := range files { - if counts[name] != 1 { - t.Errorf("file %s.json needs exactly one case, got %d", name, counts[name]) - } - if dataType, event := events[name]; event { - if examples.Event != name || examples.Method != "" || examples.DataType != dataType || len(examples.Examples.Data) == 0 { - t.Errorf("event file %s disagrees with contract or lacks examples", name) - } - continue - } - if !methods[name] && name != "collections.add" { - t.Errorf("file/case %s names no contract method or event", name) - } - if examples.Method != name || examples.Event != "" || len(examples.Examples.Params) == 0 || len(examples.Examples.Result) == 0 { - t.Errorf("method file %s disagrees with its name or lacks examples", name) - } - params, results := len(examples.Examples.Params), len(examples.Examples.Result) - if params != results && params != 1 && results != 1 { - t.Errorf("%s: unpaired example counts %d/%d need explicit situations", name, params, results) - } - if name != "collections.add" { - for _, typeString := range []string{examples.ParamsType, examples.ResultType} { - if _, known := contract.Types[typeString]; !known { - t.Errorf("%s: missing declared type %q", name, typeString) - } - } - } - for _, code := range examples.Errors { - if _, known := contract.errorType(code); !known { - t.Errorf("%s: unlisted error code %s", name, code) - } - } - } - if !slices.Contains(appwire.Methods(), "collections.add") { - t.Error("collections.add is not a registered wire method") - } - coveredErrors := make(map[string]bool) - caseNames := make(map[string]bool) - for _, errorCase := range errorCases { - if caseNames[errorCase.Name] { - t.Errorf("duplicate error case %s", errorCase.Name) - } - caseNames[errorCase.Name] = true - definition, known := contract.errorType(errorCase.Code) - if !known || definition.DeskLocal { - t.Errorf("stale error case %s: code %s is not engine-sent", errorCase.Name, errorCase.Code) - } - if _, exists := files[errorCase.Method]; !exists { - t.Errorf("stale error case %s: missing method file %s", errorCase.Name, errorCase.Method) - } - if errorCase.Method != "collections.add" && !slices.Contains(files[errorCase.Method].Errors, errorCase.Code) { - t.Errorf("%s does not list %s", errorCase.Method, errorCase.Code) - } - if errorCase.Example != "" { - found := false - for _, raw := range files[errorCase.Example].Examples.Error { - if workstreamWireDecode(t, raw).(map[string]any)["code"] == errorCase.Code { - found = true - } - } - if !found { - t.Errorf("stale error example %s/%s", errorCase.Example, errorCase.Code) - } - } - coveredErrors[errorCase.Code] = true - } - listedErrors := make(map[string]bool) - for _, definition := range contract.Errors { - if listedErrors[definition.Code] { - t.Errorf("duplicate contract error %s", definition.Code) - } - listedErrors[definition.Code] = true - if !definition.DeskLocal && !coveredErrors[definition.Code] { - t.Errorf("engine-sent contract error %s has no wire case", definition.Code) - } - } -} - -func workstreamWireCollectionResult(t *testing.T, result any) { - t.Helper() - object := result.(map[string]any) - added, addedArray := object["added"].([]any) - already, alreadyArray := object["already"].([]any) - if !addedArray || len(added) != 1 || !alreadyArray || len(already) != 0 { - t.Fatalf("collections.add result: want one added job and empty already array, got %v", result) - } - for i, value := range added { - member := value.(map[string]any) - if member["kind"] != "factory_job" || member["id"] != "1107" || member["factory"] != "f_local" { - t.Errorf("collections.add .added[%d]: wrong factory job ref %v", i, member) - } - at, ok := member["added_at"].(string) - if _, err := time.Parse(time.RFC3339Nano, at); !ok || err != nil { - t.Errorf("collections.add .added[%d].added_at: want RFC 3339 string, got %v", i, member["added_at"]) - } - } -} - -func workstreamWireEvent(t *testing.T, contract *workstreamWireContract, examples workstreamWireExamples, comparison *workstreamWireComparison) { - t.Helper() - fixture := workstreamWireSeed(t, "workstream.changed", 0) - before := len(workstreamEvents(fixture.Recorder)) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: fixture.Backend}) - workstreamWireResult(t, connection.call(t, "workstream.update", map[string]any{ - "id": fixture.Stream.ID, "patch": map[string]any{"why": "Updated through the wire"}, - })) - events := workstreamEvents(fixture.Recorder) - if len(events) <= before { - t.Fatal("wire mutation did not emit workstream.changed") - } - for i, event := range events { - value := workstreamWireDecode(t, workstreamWireEncode(t, event)) - path := fmt.Sprintf("workstream.changed data[%d] ", i) - workstreamWireCheck(t, contract.conforms(path, value, examples.DataType)) - exampleIndex := 0 - if _, parts := value.(map[string]any)["parts"]; !parts { - exampleIndex = 1 - } - if exampleIndex >= len(examples.Examples.Data) { - t.Fatalf("%s: no example for parts presence", path) - } - workstreamWireCheck(t, comparison.shape(path, value, workstreamWireDecode(t, examples.Examples.Data[exampleIndex]), workstreamWireEventShape)) - if event.ID != fixture.Stream.ID { - t.Errorf("%s.id: got %s, want seeded workstream %s", path, event.ID, fixture.Stream.ID) - } - } -} - -func workstreamWireError(t *testing.T, contract *workstreamWireContract, files map[string]workstreamWireExamples, errorCase workstreamWireErrorCase) { - t.Helper() - fixture := workstreamWireSeed(t, "workstream.get", 0) - if errorCase.Name == "suggestion_version" { - fixture.suggestion(t) - } - if errorCase.Method == "source.update" || errorCase.Method == "source.remove" { - fixture.sources(t) - } - backend := fixture.Backend - examples := files[errorCase.Method] - params := fixture.prepare(t, examples.Examples.Params[errorCase.ParamsExample]) - var engine appwire.Engine = &workstreamWireBackend{appBackend: backend} - release := func() {} - changedShape := false - switch errorCase.Name { - case "bad_params": - delete(params, "id") - changedShape = true - case "not_found": - params["id"] = "missing-workstream" - case "get_unknown_version": - params["version"] = json.Number("99") - case "restore_version_only": - delete(params, "base_version") - changedShape = true - case "restore_base_only": - delete(params, "version") - changedShape = true - case "restore_live_id_only": - params["id"] = fixture.IDs["pg_31d0"] - case "restore_deleted_version": - fixture.deletedPage(t) - params["id"] = fixture.IDs["pg_33f2"] - case "conflict": - params["name"] = fixture.Stream.Name - case "collection_owner": - other := workstreamCreateForTest(t, backend, "Account migration") - params["id"] = other.ID - case "create_owner": - params["name"] = "Another workstream" - case "page_version": - fixture.saveByAgent(t) - case "plan_version": - fixture.plan(t) - case "suggestion_version": - if _, err := backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: fixture.IDs["pg_31d0"], BaseVersion: 3, Doc: json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Meter usage daily."}]},{"type":"paragraph","content":[{"type":"text","text":"Bill at month end."}]}]}`)}); err != nil { - t.Fatal(err) - } - case "cycle": - fixture.page(t, "pg_32e1", fixture.IDs["pg_31d0"]) - fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"]) - params["id"] = fixture.IDs["pg_32e1"] - params["parent_id"] = fixture.IDs["conformance-descendant"] - case "too_large": - changedShape = true - params["doc"] = map[string]any{"type": "doc", "content": []any{ - map[string]any{"type": "paragraph", "content": []any{ - map[string]any{"type": "text", "text": strings.Repeat("x", (1<<20)+1)}, - }}, - }} - case "plan_too_large": - changedShape = true - params["milestones"] = []any{} - params["waves"] = []any{map[string]any{"n": 1, "auto_start": false, "items": []any{ - map[string]any{"kind": "draft", "draft_id": "oversize-draft", "title": "Draft", "text": strings.Repeat("x", workstream.PlanDraftTextMax+1)}, - }}} - case "records_too_large": - workstreamSeedRecords(t, backend, fixture.Stream.ID, workstream.RecordsMax) - case "unknown_method": - engine = &workstreamWireUnavailable{appBackend: backend} - case "busy": - release = workstreamWireHoldWriter(t, backend) - default: - if !workstreamWireSourceError(t, fixture, errorCase, params) { - t.Fatalf("unimplemented error case %s", errorCase.Name) - } - changedShape = strings.HasSuffix(errorCase.Name, "_bad") - } - if !changedShape { - comparison := &workstreamWireComparison{} - workstreamWireCheck(t, comparison.shape(errorCase.Method+" error params ", params, workstreamWireDecode(t, examples.Examples.Params[errorCase.ParamsExample]), workstreamWirePreparedShape)) - } - connection := workstreamWireConnect(t, engine) - frame := connection.call(t, errorCase.Method, params) - release() - value, present := frame["error"] - if !present { - t.Fatalf("%s: want %s error, got %v", errorCase.Method, errorCase.Code, frame) - } - var example any - if errorCase.Example != "" { - for _, raw := range files[errorCase.Example].Examples.Error { - candidate := workstreamWireDecode(t, raw).(map[string]any) - if candidate["code"] == errorCase.Code { - example = candidate - break - } - } - } - contract.checkError(t, errorCase.Method+" error", value, errorCase.Code, example, errorCase.Code == "unknown_method") - object := value.(map[string]any) - if errorCase.Code == "in_other_workstream" { - detail, ok := object["detail"].(map[string]any) - if !ok || detail["workstream_id"] != fixture.Stream.ID || detail["workstream_name"] != fixture.Stream.Name || detail["factory"] != "f_local" || detail["job"] != json.Number("1107") { - t.Errorf("ownership detail does not identify the seeded owner/job: %v", object["detail"]) - } - } - if errorCase.Code == "version_conflict" { - detail, ok := object["detail"].(map[string]any) - id, version, by := fixture.IDs["pg_31d0"], "2", "agent" - if errorCase.Name == "plan_version" { - id, version, by = fixture.Stream.ID, "1", "you" - } - if errorCase.Name == "suggestion_version" { - version, by = "4", "you" - } - if !ok || detail["id"] != id || detail["version"] != json.Number(version) || detail["updated_by"] != by { - t.Errorf("stale detail does not describe the current seeded version: %v", object["detail"]) - } - } -} - -// Hold a real writer on a second handle to the same store. Shorten only the -// backend's lock wait to 200 ms on its single connection so the case proves the -// real SQLITE_BUSY -> ErrBusy -> wire "busy" path without waiting ten seconds. -// Production keeps internal/workspace's 10-second wait. The refusal still says -// "(waited 10s)" because storeError uses that constant; conformance checks the -// code and a non-empty message, not that text. -func workstreamWireHoldWriter(t *testing.T, backend *appBackend) func() { - t.Helper() - if backend.collections == nil { - t.Fatal("busy fixture must seed the backend's collections store before holding a writer") - } - const workstreamBusyTimeoutMS = 200 - if err := backend.collections.Write(context.Background(), func(tx *sql.Tx) error { - _, err := tx.Exec(fmt.Sprintf("PRAGMA busy_timeout = %d", workstreamBusyTimeoutMS)) - return err - }); err != nil { - t.Fatalf("set backend busy timeout: %v", err) - } - var ms int - if err := backend.collections.Read(context.Background(), func(tx *sql.Tx, _ int) error { - return tx.QueryRow("PRAGMA busy_timeout").Scan(&ms) - }); err != nil { - t.Fatalf("read backend busy timeout: %v", err) - } - if ms != workstreamBusyTimeoutMS { - t.Fatalf("backend busy timeout = %d ms, want %d ms", ms, workstreamBusyTimeoutMS) - } - other, err := workspace.Open(backend.collectionsPath) - if err != nil { - t.Fatal(err) - } - entered := make(chan struct{}) - unlock := make(chan struct{}) - done := make(chan error, 1) - go func() { - done <- other.Write(context.Background(), func(*sql.Tx) error { - close(entered) - <-unlock - return nil - }) - }() - var once sync.Once - release := func() { - once.Do(func() { - close(unlock) - select { - case err := <-done: - if err != nil { - t.Errorf("held writer: %v", err) - } - case <-time.After(30 * time.Second): - t.Error("held writer did not release") - } - if err := other.Close(); err != nil { - t.Errorf("held writer close: %v", err) - } - }) - } - t.Cleanup(release) - select { - case <-entered: - case err := <-done: - // Put the result back so the shared cleanup can still consume it. - done <- err - t.Fatalf("writer did not take the lock: %v", err) - case <-time.After(30 * time.Second): - t.Fatal("writer did not take the lock") - } - return release -} - -// WP-11 (j): every wake example uses a fake turn and the real durable action path. -func (fixture *workstreamWireFixture) wake(t *testing.T, name string) { - t.Helper() - b := fixture.Backend - s, err := b.workstreamStore(false) - if err != nil || s == nil { - t.Fatal(err) - } - if name == "wake.list" { - small := "small" - if _, _, err := s.Update(context.Background(), fixture.Stream.ID, workstream.Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - _, _, err = s.SetPlan(context.Background(), fixture.Stream.ID, 0, nil, []workstream.Wave{{N: 1, Items: []workstream.PlanItem{{Kind: "job", Factory: "f_local", Job: 1107}}}, {N: 3, Items: []workstream.PlanItem{}}}, workstream.Attribution{}) - if err != nil { - t.Fatal(err) - } - } - b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - text := `{"summary":"Review the rollout.","items":[{"kind":"decision","text":"Roll out after review."}]}` - if name == "wake.list" { - text = `{"summary":"Applied: moved it to wave 3; updated Where it stands. Keep the rollout decision and consider a small follow-up job.","items":[{"kind":"move","factory":"f_local","job":1107,"to_wave":3},{"kind":"decision","text":"Roll out after review.","why":"The merge is ready for review."},{"kind":"draft","title":"Check rollout","budget_usd":3,"wave":99}]}` - } - return session.WorkstreamWakeAnswer{Text: text, CostUSD: workstreamPtr(.04), Model: "fake"}, nil - } - var task workstreamWakeTask - b.workstreams.wakeDispatch = func(v workstreamWakeTask) { task = v } - id, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f_local", Job: 1107, Kind: "merged", ID: "wire-contract", At: fixture.Now, Label: "api#1102 merged"}) - if !woke { - t.Fatal("seed wake was refused") - } - b.workstreamRunWake(context.Background(), task) - fixture.IDs["wake_11"] = id - runs, err := s.Wakes(context.Background(), fixture.Stream.ID, 20) - if err != nil || len(runs) != 1 || len(runs[0].Items) == 0 { - t.Fatalf("seed wake: %+v %v", runs, err) - } - item := runs[0].Items[0] - fixture.IDs["wi_decision"] = item.ID - if name == "wake.undo" { - if _, _, err := s.ActOnWake(context.Background(), id, item.ID, "apply"); err != nil { - t.Fatal(err) - } - } -} diff --git a/cmd/codeaf/appworkstreams_fields_v02_test.go b/cmd/codeaf/appworkstreams_fields_v02_test.go deleted file mode 100644 index 17859777e6..0000000000 --- a/cmd/codeaf/appworkstreams_fields_v02_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "math" - "reflect" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WP-F2 (a): real plan_set/get dispatch preserves draft fields and refuses atomically. -func TestAppWorkstreamDraftFieldsWireRoundTrip(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Draft wire", workstreamJobRef("7")) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - item := map[string]any{"kind": "draft", "draft_id": "d", "title": "Draft", "size": "M", "budget_usd": 25.0, "source": map[string]any{"kind": "message", "id": "m", "chat_id": "chat", "label": "Scope"}} - params := map[string]any{"id": w.ID, "base_version": 0, "milestones": []any{}, "waves": []any{map[string]any{"n": 1, "auto_start": false, "items": []any{item}}}} - set := workstreamWireResult(t, connection.call(t, "workstream.plan_set", params)) - get := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) - if !reflect.DeepEqual(set, get) { - t.Fatalf("round trip: %v %v", set, get) - } - draft := get.(map[string]any)["plan"].(map[string]any)["waves"].([]any)[0].(map[string]any)["items"].([]any)[0] - want := workstreamWireDecode(t, json.RawMessage(`{"kind":"draft","draft_id":"d","title":"Draft","size":"M","budget_usd":25,"source":{"kind":"message","id":"m","chat_id":"chat","label":"Scope"}}`)) - if !reflect.DeepEqual(draft, want) { - t.Fatalf("draft fields: %v", draft) - } - before := len(workstreamEvents(rec)) - for _, invalid := range []map[string]any{ - {"kind": "job", "factory": "f", "job": 7, "size": "S"}, - {"kind": "job", "factory": "f", "job": 7, "budget_usd": 1}, - {"kind": "job", "factory": "f", "job": 7, "source": map[string]any{"kind": "chat", "id": "c"}}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "size": "XL"}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": 0}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": -1}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "budget_usd": 10000.01}, - {"kind": "draft", "draft_id": "d", "title": "Draft", "source": map[string]any{"kind": "message", "id": "m"}}, - } { - params["base_version"] = 1 - params["waves"] = []any{map[string]any{"n": 1, "auto_start": false, "items": []any{invalid}}} - frame := connection.call(t, "workstream.plan_set", params) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { - t.Fatalf("refusal: %v", frame) - } - after := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) - if !reflect.DeepEqual(after, get) { - t.Fatalf("refusal changed plan: %v", after) - } - } - _, err := b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: w.ID, BaseVersion: 1, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{{N: 1, Items: []appwire.WorkstreamPlanItem{{Kind: "draft", DraftID: workstreamPtr("d"), Title: workstreamPtr("Draft"), BudgetUSD: workstreamPtr(math.NaN())}}}}}) - workstreamCheckError(t, err, appwire.CodeBadParams) - after := workstreamWireResult(t, connection.call(t, "workstream.plan_get", map[string]any{"id": w.ID})) - if !reflect.DeepEqual(after, get) { - t.Fatalf("NaN refusal changed plan: %v", after) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("refused plan emitted a change") - } -} - -// WP-F2 (b): save/create source belongs to that version, and absent source clears it. -func TestAppWorkstreamPageSourcesThroughWire(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Page source") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - source := map[string]any{"kind": "message", "id": "message", "chat_id": "chat", "label": "Scope"} - doc := map[string]any{"type": "doc", "content": []any{}} - created := workstreamWireResult(t, connection.call(t, "page.create", map[string]any{"workstream_id": w.ID, "title": "Notes", "source": source, "doc": doc})).(map[string]any)["page"].(map[string]any) - id := created["id"] - if !reflect.DeepEqual(created["updated_from"], source) { - t.Fatalf("create source: %v", created) - } - saved := workstreamWireResult(t, connection.call(t, "page.save", map[string]any{"id": id, "base_version": 1, "doc": doc, "source": source})).(map[string]any)["page"].(map[string]any) - if !reflect.DeepEqual(saved["updated_from"], source) { - t.Fatalf("save source: %v", saved) - } - last := workstreamWireResult(t, connection.call(t, "page.save", map[string]any{"id": id, "base_version": 2, "doc": doc})).(map[string]any)["page"].(map[string]any) - if _, exists := last["updated_from"]; exists { - t.Fatalf("source retained: %v", last) - } - history := workstreamWireResult(t, connection.call(t, "page.history", map[string]any{"id": id})).(map[string]any)["versions"].([]any) - if len(history) != 3 { - t.Fatalf("history: %v", history) - } - if _, exists := history[0].(map[string]any)["from"]; exists { - t.Fatalf("source retained in history: %v", history) - } - for _, version := range history[1:] { - if !reflect.DeepEqual(version.(map[string]any)["from"], source) { - t.Fatalf("version source: %v", version) - } - } - before := last - for _, method := range []string{"page.save", "page.create"} { - params := map[string]any{"id": id, "base_version": 3, "doc": doc, "workstream_id": w.ID, "title": "Invalid", "source": map[string]any{"kind": "message", "id": "m"}} - frame := connection.call(t, method, params) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { - t.Fatalf("invalid source: %v", frame) - } - } - current := workstreamWireResult(t, connection.call(t, "page.get", map[string]any{"id": id})).(map[string]any)["page"] - if !reflect.DeepEqual(current, before) { - t.Fatalf("invalid source changed page: %v", current) - } -} - -// WP-F2 (c): list gets both conversation identities from existing row data. -func TestAppWorkstreamListConversationIDsThroughWire(t *testing.T) { - b, _, _, origin := workstreamBackend(t) - first, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "With origin", OriginChatID: &origin.ID}) - if err != nil { - t.Fatal(err) - } - second := workstreamCreateForTest(t, b, "Without origin") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - rows := workstreamWireResult(t, connection.call(t, "workstream.list", map[string]any{})).(map[string]any)["workstreams"].([]any) - if len(rows) != 2 { - t.Fatalf("rows: %v", rows) - } - for _, raw := range rows { - row := raw.(map[string]any) - get := workstreamWireResult(t, connection.call(t, "workstream.get", map[string]any{"id": row["id"]})).(map[string]any)["workstream"].(map[string]any) - if row["agent_id"] == "" || row["agent_id"] != get["agent_id"] { - t.Fatalf("agent id: %v %v", row, get) - } - if row["id"] == first.Workstream.ID && row["origin_chat_id"] != origin.ID { - t.Fatalf("origin: %v", row) - } - if row["id"] == second.ID { - if _, exists := row["origin_chat_id"]; exists { - t.Fatalf("absent origin: %v", row) - } - } - } -} - -// WP-F2 (e): the adapter resolves real sources before any draft can be added. -func TestAppWorkstreamAgentDraftSourceResolution(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Spec"}) - if err != nil { - t.Fatal(err) - } - for _, asked := range []*workstream.Source{nil, {Kind: "page", ID: page.Page.ID}} { - source, err := door.Source(ctx, asked) - if err != nil { - t.Fatal(err) - } - want := &workstream.Source{Kind: "chat", ID: w.AgentID} - if asked != nil { - want = asked - } - if !reflect.DeepEqual(source, want) { - t.Fatalf("source: %+v", source) - } - plan, err := door.Plan(ctx) - if err != nil { - t.Fatal(err) - } - n := len(plan.Waves) + 1 - plan.Waves = append(plan.Waves, workstream.Wave{N: n, Items: []workstream.PlanItem{{Kind: "draft", DraftID: source.Kind, Title: "Draft", Source: source}}}) - saved, err := door.SetPlan(ctx, plan.Version, plan) - if err != nil || !reflect.DeepEqual(saved.Waves[n-1].Items[0].Source, want) { - t.Fatalf("saved source: %+v %v", saved, err) - } - } - before, err := door.Plan(ctx) - if err != nil { - t.Fatal(err) - } - for _, source := range []*workstream.Source{{Kind: "page", ID: "missing"}, {Kind: "factory_job", ID: "99", Factory: "f"}, {Kind: "url", ID: "file:///tmp/source"}, {Kind: "page", ID: page.Page.ID, ChatID: "chat"}} { - if _, err := door.Source(ctx, source); err == nil { - t.Fatalf("accepted source: %+v", source) - } - } - after, err := door.Plan(ctx) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("source refusal changed plan: %+v %v", after, err) - } -} - -// WP-F2 (e), (f): agent reads include proposed and kept go-ahead structures. -func TestAppWorkstreamAgentReadsGoAheadRecords(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - ctx := context.Background() - ask := session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 10, EndsOn: b.now().AddDate(0, 0, 89).Format("2006-01-02"), Why: "Review"} - proposal, err := door.Propose(ctx, session.WorkstreamProposal{GoAhead: &ask}) - if err != nil { - t.Fatal(err) - } - for _, kind := range []string{"", "goahead"} { - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "records", Kind: kind}) - var records appwire.WorkstreamRecordListResult - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &records); err != nil || len(records.Records) != 1 || records.Records[0].Kind != "goahead" || records.Records[0].GoAhead == nil { - t.Fatalf("read: %s %v", raw, err) - } - } - kept, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: proposal.ID}) - if err != nil { - t.Fatal(err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "records"}) - var records appwire.WorkstreamRecordListResult - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &records); err != nil || len(records.Records) != 1 || !reflect.DeepEqual(records.Records[0], kept.Record) { - t.Fatalf("kept read: %s %v", raw, err) - } - grant, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID}) - if err != nil || len(grant.GoAheads) != 1 || grant.GoAheads[0].EndsAt.Hour() != 23 || grant.GoAheads[0].EndsAt.Minute() != 59 || grant.GoAheads[0].EndsAt.Second() != 59 { - t.Fatalf("end of day: %+v %v", grant, err) - } - ask.EndsOn = b.now().AddDate(0, 0, 90).Format("2006-01-02") - if _, err := door.Propose(ctx, session.WorkstreamProposal{GoAhead: &ask}); err == nil { - t.Fatal("90-day end accepted") - } -} - -// WP-F2 (d), (f): real wire dispatch exposes grants, undo and compatibility filters. -func TestAppWorkstreamGoAheadRecordsThroughWire(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) - b.now = func() time.Time { return now } - w := workstreamCreateForTest(t, b, "Record wire", workstreamJobRef("7")) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - proposal := map[string]any{"scope": "job", "factory": "f", "job": 7, "cap_usd": 12.5, "ends_at": now.Add(time.Hour).Format(time.RFC3339)} - raw := workstreamWireResult(t, connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": "goahead", "text": "Why", "goahead": proposal})).(map[string]any)["record"].(map[string]any) - id := raw["id"] - if raw["kind"] != "goahead" || raw["state"] != "proposed" || raw["text"] != "Why" { - t.Fatalf("proposal: %v", raw) - } - for _, kind := range []string{"decision", "question"} { - workstreamWireResult(t, connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": kind, "text": kind})) - } - for _, tc := range []struct { - kind string - include *bool - count int - }{{"", nil, 2}, {"", workstreamPtr(false), 2}, {"", workstreamPtr(true), 3}, {"goahead", nil, 1}, {"question", workstreamPtr(true), 1}} { - params := map[string]any{"workstream_id": w.ID} - if tc.kind != "" { - params["kind"] = tc.kind - } - if tc.include != nil { - params["include_goaheads"] = *tc.include - } - rows := workstreamWireResult(t, connection.call(t, "record.list", params)).(map[string]any)["records"].([]any) - if len(rows) != tc.count { - t.Fatalf("filter %+v: %v", tc, rows) - } - for _, row := range rows { - kind := row.(map[string]any)["kind"] - if tc.kind != "" && kind != tc.kind || tc.kind == "" && (tc.include == nil || !*tc.include) && kind == "goahead" { - t.Fatalf("filter %+v: %v", tc, rows) - } - } - } - kept := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) - grants := workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID})).(map[string]any)["goaheads"].([]any) - if len(grants) != 1 || grants[0].(map[string]any)["id"] != kept["goahead_id"] { - t.Fatalf("keep: %v %v", kept, grants) - } - before := len(workstreamEvents(rec)) - again := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"] - grants = workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID})).(map[string]any)["goaheads"].([]any) - if !reflect.DeepEqual(again, kept) || len(grants) != 1 || len(workstreamEvents(rec)) != before { - t.Fatal("repeat keep changed state") - } - for _, kind := range []string{"", "decision", "goahead"} { - params := map[string]any{"id": id, "text": "Replacement"} - if kind != "" { - params["kind"] = kind - } - frame := connection.call(t, "record.supersede", params) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { - t.Fatalf("supersede: %v", frame) - } - } - dropped := workstreamWireResult(t, connection.call(t, "record.drop", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) - grants = workstreamWireResult(t, connection.call(t, "goahead.list", map[string]any{"workstream_id": w.ID, "ended": true})).(map[string]any)["goaheads"].([]any) - if dropped["state"] != "dropped" || dropped["goahead_id"] != kept["goahead_id"] || len(grants) != 1 || grants[0].(map[string]any)["end_reason"] != "revoked" { - t.Fatalf("drop: %v %v", dropped, grants) - } - newKeep := workstreamWireResult(t, connection.call(t, "record.keep", map[string]any{"id": id})).(map[string]any)["record"].(map[string]any) - if newKeep["goahead_id"] == kept["goahead_id"] { - t.Fatal("re-keep reused the grant") - } - workstreamWireResult(t, connection.call(t, "record.drop", map[string]any{"id": id})) - workstreamWireResult(t, connection.call(t, "workstream.mark_done", map[string]any{"id": w.ID})) - frame := connection.call(t, "record.keep", map[string]any{"id": id}) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "conflict" { - t.Fatalf("keep done: %v", frame) - } - frame = connection.call(t, "record.propose", map[string]any{"workstream_id": w.ID, "kind": "goahead", "text": "Why", "goahead": proposal}) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "conflict" { - t.Fatalf("propose done: %v", frame) - } -} diff --git a/cmd/codeaf/appworkstreams_pages_test.go b/cmd/codeaf/appworkstreams_pages_test.go deleted file mode 100644 index 1a521d06f6..0000000000 --- a/cmd/codeaf/appworkstreams_pages_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WP-F1 (c): creation alone chooses the title default; explicit booleans win, -// renames retain the existing choice, and the initial Overview remains off. -func TestAppWorkstreamPageReadByJobsDefaults(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Read defaults") - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil || len(pages.Pages) != 1 || pages.Pages[0].ReadByJobs { - t.Fatalf("initial Overview: %+v %v", pages, err) - } - for _, tc := range []struct { - title string - value *bool - want bool - }{ - {"Spec", nil, true}, {" decisions ", nil, true}, {"DECISIONS", nil, true}, - {"Spec notes", nil, false}, {"Overview", nil, false}, {"Notes", nil, false}, - {"Spec", workstreamPtr(false), false}, {"Notes", workstreamPtr(true), true}, - } { - t.Run(tc.title, func(t *testing.T) { - made, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: tc.title, ReadByJobs: tc.value}) - if err != nil || made.Page.ReadByJobs != tc.want { - t.Fatalf("created: %+v %v, want read_by_jobs=%v", made, err, tc.want) - } - rename := "Spec" - if tc.want { - rename = "Notes" - } - updated, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: made.Page.ID, Title: &rename}) - if err != nil || updated.Page.ReadByJobs != tc.want { - t.Fatalf("rename changed read_by_jobs: %+v %v", updated, err) - } - }) - } -} - -// WP-F1 (a): handlers expose historical content and attribution, optional -// markdown and current metadata, including deleted_at for a deleted page. -func TestAppWorkstreamPageGetVersion(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Version handler") - store, err := b.workstreamStore(false) - if err != nil || store == nil { - t.Fatalf("store: %v", err) - } - doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Earlier"}]}]}`) - first, _, err := store.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Before", Owner: "agent", Doc: doc, - Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: "old-source"}}}) - if err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: first.ID, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: first.ID, Title: workstreamPtr("After"), ReadByJobs: workstreamPtr(true)}); err != nil { - t.Fatal(err) - } - before := len(workstreamEvents(rec)) - current, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID}) - if err != nil { - t.Fatal(err) - } - selected, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(2)}) - if err != nil || !reflect.DeepEqual(selected, current) { - t.Fatalf("current version: %+v, want %+v: %v", selected, current, err) - } - old, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(1), Markdown: workstreamPtr(true)}) - if err != nil || old.Page.Title != "Before" || string(old.Page.Doc) != string(doc) || old.Page.Version != 1 || old.Page.UpdatedBy != "agent" || !old.Page.UpdatedAt.Equal(first.UpdatedAt) || old.Page.UpdatedFrom == nil || old.Page.UpdatedFrom.ID != "old-source" || !old.Page.ReadByJobs || old.Page.Markdown == nil || *old.Page.Markdown != "Earlier\n" { - t.Fatalf("historical page: %+v %v", old, err) - } - _, err = b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(99)}) - workstreamCheckError(t, err, appwire.CodeNotFound) - for _, version := range []int{0, -1} { - _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: &version}) - workstreamCheckError(t, err, appwire.CodeBadParams) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("version reads emitted changes") - } - if _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: first.ID}); err != nil { - t.Fatal(err) - } - old, err = b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(1)}) - if err != nil || old.Page.DeletedAt.IsZero() || old.Page.Markdown != nil || old.Page.Title != "Before" { - t.Fatalf("deleted historical page: %+v %v", old, err) - } -} - -// WP-F1 (b): list presence, deleted write refusals, restore parameter dispatch -// and the committed pages event are visible through the backend handlers. -func TestAppWorkstreamDeletedPageHandlers(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Deleted handler") - list := func(include *bool) map[string]json.RawMessage { - t.Helper() - result, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID, IncludeDeleted: include}) - if err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(result) - if err != nil { - t.Fatal(err) - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - t.Fatal(err) - } - return fields - } - for _, include := range []*bool{nil, workstreamPtr(false)} { - if _, present := list(include)["deleted"]; present { - t.Fatal("deleted present without include_deleted=true") - } - } - if raw := string(list(workstreamPtr(true))["deleted"]); raw != "[]" { - t.Fatalf("empty deleted list = %s", raw) - } - made, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Spec"}) - if err != nil { - t.Fatal(err) - } - id := made.Page.ID - _, err = b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeBadParams) - if _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); err != nil { - t.Fatal(err) - } - fields := list(workstreamPtr(true)) - var nodes []appwire.WorkstreamPageNode - if err := json.Unmarshal(fields["deleted"], &nodes); err != nil || len(nodes) != 1 || nodes[0].ID != id || nodes[0].DeletedAt.IsZero() { - t.Fatalf("deleted list: %s %v", fields["deleted"], err) - } - for name, call := range map[string]func() error{ - "save": func() error { - _, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: made.Page.Doc}) - return err - }, - "update": func() error { _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: id}); return err }, - "move": func() error { _, err := b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: id}); return err }, - "delete": func() error { _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); return err }, - "history": func() error { - _, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) - return err - }, - "create parent": func() error { - _, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, ParentID: &id, Title: "No"}) - return err - }, - "create after": func() error { - _, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, AfterID: &id, Title: "No"}) - return err - }, - } { - t.Run(name, func(t *testing.T) { workstreamCheckError(t, call(), appwire.CodeNotFound) }) - } - for _, p := range []appwire.WorkstreamPageRestoreParams{ - {ID: id, Version: workstreamPtr(1)}, {ID: id, BaseVersion: workstreamPtr(1)}, - {ID: id, Version: workstreamPtr(1), BaseVersion: workstreamPtr(1)}, - } { - _, err := b.WorkstreamPageRestore(p) - workstreamCheckError(t, err, appwire.CodeBadParams) - } - _, err = b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - before := len(workstreamEvents(rec)) - restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id}) - if err != nil || restored.Page.Version != 1 || !restored.Page.DeletedAt.IsZero() || restored.Page.UpdatedBy != "you" || restored.Page.UpdatedFrom != nil { - t.Fatalf("undelete: %+v %v", restored, err) - } - events := workstreamEvents(rec) - if len(events) != before+1 || events[before].ID != w.ID || !reflect.DeepEqual(events[before].Parts, []string{"workstream", "pages"}) { - t.Fatalf("undelete event: %+v", events[before:]) - } - history, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) - if err != nil || len(history.Versions) != 1 { - t.Fatalf("undelete added history: %+v %v", history, err) - } - if raw := string(list(workstreamPtr(true))["deleted"]); raw != "[]" { - t.Fatalf("deleted list after restore = %s", raw) - } -} diff --git a/cmd/codeaf/appworkstreams_source_open_other.go b/cmd/codeaf/appworkstreams_source_open_other.go deleted file mode 100644 index cd6d0a0173..0000000000 --- a/cmd/codeaf/appworkstreams_source_open_other.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build !unix - -package main - -import "os" - -// Verify the final component and its opened identity before any content is read. -func workstreamAgentOpenSourcePath(path string) (*os.File, error) { - before, err := os.Lstat(path) - if err != nil { - return nil, err - } - if before.Mode()&os.ModeSymlink != 0 { - return nil, errWorkstreamSourceLink - } - file, err := os.Open(path) - if err != nil { - return nil, err - } - after, err := file.Stat() - if err != nil { - file.Close() - return nil, err - } - if !os.SameFile(before, after) { - file.Close() - return nil, os.ErrInvalid - } - return file, nil -} diff --git a/cmd/codeaf/appworkstreams_source_open_unix.go b/cmd/codeaf/appworkstreams_source_open_unix.go deleted file mode 100644 index a4da049da2..0000000000 --- a/cmd/codeaf/appworkstreams_source_open_unix.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build unix - -package main - -import ( - "errors" - "os" - "syscall" -) - -// Refuse final-component links atomically and keep FIFO replacement reads nonblocking. -func workstreamAgentOpenSourcePath(path string) (*os.File, error) { - fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NONBLOCK|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0) - if errors.Is(err, syscall.ELOOP) { - return nil, errWorkstreamSourceLink - } - if err != nil { - return nil, err - } - return os.NewFile(uintptr(fd), path), nil -} diff --git a/cmd/codeaf/appworkstreams_source_open_unix_test.go b/cmd/codeaf/appworkstreams_source_open_unix_test.go deleted file mode 100644 index 0a6c351126..0000000000 --- a/cmd/codeaf/appworkstreams_source_open_unix_test.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build unix - -package main - -import ( - "context" - "os" - "strings" - "syscall" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" -) - -// WP-12 (g): replacing an added file with a named pipe cannot make a source -// read wait for a writer or read bytes from a non-regular descriptor. -func TestWorkstreamAgentSourceRefusesAReplacementFIFO(t *testing.T) { - b, _, w, door := workstreamAgentFixture(t) - path := workstreamSourcesFile(t) - item, err := b.WorkstreamSourceAdd(appwire.WorkstreamSourceAddParams{WorkstreamID: w.ID, Kind: "file", Path: &path}) - if err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := syscall.Mkfifo(path, 0600); err != nil { - t.Fatal(err) - } - done := make(chan string, 1) - go func() { - raw, err := door.Read(context.Background(), session.WorkstreamReadAsk{What: "source", SourceID: item.Source.ID}) - if err != nil { - done <- err.Error() - return - } - done <- string(raw) - }() - select { - case answer := <-done: - if !strings.Contains(answer, "not a regular text file") { - t.Fatalf("special file answer: %s", answer) - } - case <-time.After(5 * time.Second): - if writer, err := os.OpenFile(path, os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil { - writer.Close() - } - t.Fatal("source read blocked on a named pipe") - } -} diff --git a/cmd/codeaf/appworkstreams_sources.go b/cmd/codeaf/appworkstreams_sources.go deleted file mode 100644 index 31a44b644b..0000000000 --- a/cmd/codeaf/appworkstreams_sources.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "context" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func appWorkstreamSourceItem(item workstream.SourceItem) appwire.WorkstreamSourceItem { - out := appwire.WorkstreamSourceItem{ID: item.ID, WorkstreamID: item.WorkstreamID, Kind: item.Kind, Title: item.Title, Path: workstreamOptional(item.Path), Repo: workstreamOptional(item.Repo), Number: workstreamPositive(item.Number), URL: workstreamOptional(item.URL), ReadByJobs: item.ReadByJobs, Proposed: item.Proposed, Why: workstreamOptional(item.Why), AddedBy: item.AddedBy, AddedAt: item.AddedAt} - if item.Ref != nil { - out.Ref = &appwire.WorkstreamKeptRef{Kind: item.Ref.Kind, ID: item.Ref.ID, ChatID: item.Ref.ChatID, Factory: item.Ref.Factory} - } - if item.Gone { - out.Gone = &item.Gone - } - return out -} - -func (b *appBackend) WorkstreamSourceList(p appwire.WorkstreamSourceListParams) (appwire.WorkstreamSourceListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceListResult, workstream.Change, error) { - items, err := s.Sources(context.Background(), p.WorkstreamID) - out := appwire.WorkstreamSourceListResult{Sources: []appwire.WorkstreamSourceItem{}} - for _, item := range items { - out.Sources = append(out.Sources, appWorkstreamSourceItem(item)) - } - return out, workstream.Change{}, err - }) -} - -func (b *appBackend) WorkstreamSourceAdd(p appwire.WorkstreamSourceAddParams) (appwire.WorkstreamSourceAddResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceAddResult, workstream.Change, error) { - item, already, change, err := s.AddSource(context.Background(), p.WorkstreamID, p.SourceAdd()) - return appwire.WorkstreamSourceAddResult{Source: appWorkstreamSourceItem(item), Already: already}, change, err - }) -} - -func (b *appBackend) WorkstreamSourceUpdate(p appwire.WorkstreamSourceUpdateParams) (appwire.WorkstreamSourceUpdateResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceUpdateResult, workstream.Change, error) { - item, change, err := s.UpdateSource(context.Background(), p.ID, workstream.SourcePatch{Title: p.Title, ReadByJobs: p.ReadByJobs, Keep: p.Keep}) - return appwire.WorkstreamSourceUpdateResult{Source: appWorkstreamSourceItem(item)}, change, err - }) -} - -func (b *appBackend) WorkstreamSourceRemove(p appwire.WorkstreamSourceRemoveParams) (appwire.WorkstreamSourceRemoveResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamSourceRemoveResult, workstream.Change, error) { - item, change, err := s.RemoveSource(context.Background(), p.ID) - return appwire.WorkstreamSourceRemoveResult{Source: appWorkstreamSourceItem(item)}, change, err - }) -} diff --git a/cmd/codeaf/appworkstreams_sources_test.go b/cmd/codeaf/appworkstreams_sources_test.go deleted file mode 100644 index 0782bb6477..0000000000 --- a/cmd/codeaf/appworkstreams_sources_test.go +++ /dev/null @@ -1,514 +0,0 @@ -package main - -import ( - "bytes" - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "reflect" - "slices" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func workstreamSourcesFile(t *testing.T) string { - t.Helper() - path := filepath.Join(t.TempDir(), "usage-pricing.md") - if err := os.WriteFile(path, []byte("Recorded usage.\n"), 0600); err != nil { - t.Fatal(err) - } - return path -} - -func workstreamSourceWireCall(t *testing.T, connection *workstreamWireConnection, method string, params any) map[string]any { - t.Helper() - return workstreamWireResult(t, connection.call(t, method, params)).(map[string]any) -} - -// WP-12 (a), (d), (f): all target forms round-trip through actual typed wire -// dispatch, with exact kind keys, derived titles, order and duplicate receipts. -func TestAppWorkstreamSourcesEveryKindOnWire(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Sources wire", workstreamJobRef("7")) - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil { - t.Fatal(err) - } - record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "decision", Text: "Use recorded totals.\nDetails.", Kept: workstreamPtr(true)}) - if err != nil { - t.Fatal(err) - } - path := workstreamSourcesFile(t) - folder := t.TempDir() - cases := []struct { - fields map[string]any - title string - }{ - {map[string]any{"kind": "file", "path": path}, "usage-pricing.md"}, - {map[string]any{"kind": "repo", "repo": "ACME/Billing"}, "ACME/Billing"}, - {map[string]any{"kind": "repo", "repo": folder}, filepath.Base(folder)}, - {map[string]any{"kind": "issue", "repo": "acme/billing", "number": 412}, "acme/billing#412"}, - {map[string]any{"kind": "link", "url": "HTTPS://Docs.Example.com/x/?q=1#part"}, "docs.example.com/x"}, - {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "record", "id": record.Record.ID}}, "Use recorded totals."}, - {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "page", "id": pages.Pages[0].ID}}, "Overview"}, - {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "message", "id": "m", "chat_id": "chat"}}, "Kept message"}, - {map[string]any{"kind": "kept", "ref": map[string]any{"kind": "factory_job", "id": "7", "factory": "f"}}, "Job 7"}, - } - membersBefore, err := b.collections.Members(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - want := []any{} - for i, tc := range cases { - params := map[string]any{"workstream_id": w.ID} - for key, value := range tc.fields { - params[key] = value - } - before := len(workstreamEvents(rec)) - result := workstreamSourceWireCall(t, connection, "source.add", params) - item := result["source"].(map[string]any) - keys := []string{"id", "workstream_id", "kind", "title", "read_by_jobs", "proposed", "added_by", "added_at"} - for key := range tc.fields { - if key != "kind" { - keys = append(keys, key) - } - } - slices.Sort(keys) - if result["already"] != false || item["title"] != tc.title || item["added_by"] != "you" || item["read_by_jobs"] != false || item["proposed"] != false || !reflect.DeepEqual(workstreamWireKeys(item), keys) { - t.Fatalf("kind %d: %+v", i, result) - } - for key, value := range tc.fields { - if key == "url" { - value = "https://docs.example.com/x/?q=1#part" - } - if !reflect.DeepEqual(item[key], workstreamWireDecode(t, workstreamWireEncode(t, value))) { - t.Fatalf("kind %d field %s: %v", i, key, item) - } - } - if i == 1 { - params["repo"] = "acme/billing" - } - if i == 3 { - params["repo"] = "ACME/Billing" - } - if i == 4 { - params["url"] = "https://docs.example.com/x/?q=1#part" - } - again := workstreamSourceWireCall(t, connection, "source.add", params) - if again["already"] != true || !reflect.DeepEqual(again["source"], item) || len(workstreamEvents(rec)) != before+1 { - t.Fatalf("duplicate kind %d: %+v", i, again) - } - want = append(want, item) - } - listed := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"] - if !reflect.DeepEqual(listed, want) { - t.Fatalf("source list order or fields: %v", listed) - } - membersAfter, err := b.collections.Members(context.Background(), w.ID) - if err != nil || !reflect.DeepEqual(membersAfter, membersBefore) { - t.Fatalf("sources changed members: %+v %v", membersAfter, err) - } - for _, index := range []int{0, 5, 6} { - item := want[index].(map[string]any) - removed := workstreamSourceWireCall(t, connection, "source.remove", map[string]any{"id": item["id"]})["source"] - if !reflect.DeepEqual(item, removed) { - t.Fatalf("remove changed receipt: %v", removed) - } - } - data, err := os.ReadFile(path) - if err != nil || string(data) != "Recorded usage.\n" { - t.Fatalf("removed source changed file: %q %v", data, err) - } - if _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: pages.Pages[0].ID}); err != nil { - t.Fatal(err) - } - if records, err := b.WorkstreamRecordList(appwire.WorkstreamRecordListParams{WorkstreamID: w.ID}); err != nil || len(records.Records) != 1 { - t.Fatalf("source removal changed records: %v %v", records, err) - } -} - -// WP-12 (b): invalid paths, URLs, kind fields, refs and updates return -// bad_params/not_found without rows, timestamps or changed events. -func TestAppWorkstreamSourcesRefusalsOnWire(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Invalid source wire") - path := workstreamSourcesFile(t) - bad := []map[string]any{} - for _, p := range []string{"relative", filepath.Dir(path) + "/../file", filepath.Dir(path) + "/./usage-pricing.md", filepath.Dir(path) + "//usage-pricing.md", path + "/", path + "\x00", path + "\n", filepath.Join(t.TempDir(), "missing"), "/" + strings.Repeat("x", 4096)} { - bad = append(bad, map[string]any{"kind": "file", "path": p}) - } - for _, repo := range []string{"../x", "owner/..", "-owner/x", "owner/name/extra", "relative", path} { - bad = append(bad, map[string]any{"kind": "repo", "repo": repo}) - } - for _, number := range []int{0, -1, 2147483648} { - bad = append(bad, map[string]any{"kind": "issue", "repo": "owner/name", "number": number}) - } - bad = append(bad, map[string]any{"kind": "issue", "repo": filepath.Dir(path), "number": 1}) - for _, url := range []string{"file:///etc/passwd", "javascript:alert(1)", "ftp://h/x", "//h/x", "https:///x", "https://user:pw@h/x", "https://h/x y", "https://h/x\n", "https://h/" + strings.Repeat("x", 2039)} { - bad = append(bad, map[string]any{"kind": "link", "url": url}) - } - bad = append(bad, map[string]any{"kind": "file", "path": path, "url": "https://example.com"}) - for _, title := range []string{"", strings.Repeat("x", 201), "two\nlines", " untrimmed "} { - bad = append(bad, map[string]any{"kind": "file", "path": path, "title": title}) - } - bad = append(bad, map[string]any{"kind": "kept", "ref": map[string]any{"kind": "factory_job", "id": "7", "factory": "f"}}, map[string]any{"kind": "kept", "ref": map[string]any{"kind": "message", "id": "m", "chat_id": "c", "factory": ""}}) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - before := len(workstreamEvents(rec)) - for i, params := range bad { - params["workstream_id"] = w.ID - frame := connection.call(t, "source.add", params) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("invalid %d: %v", i, frame) - } - } - for _, kind := range []string{"record", "page"} { - frame := connection.call(t, "source.add", map[string]any{"workstream_id": w.ID, "kind": "kept", "ref": map[string]any{"kind": kind, "id": "missing"}}) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "not_found" { - t.Fatalf("unknown kept: %v", frame) - } - } - for _, patch := range []map[string]any{{"id": "missing"}, {"id": "missing", "keep": false}, {"id": "missing", "title": ""}} { - frame := connection.call(t, "source.update", patch) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("invalid update: %v", frame) - } - } - listed := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"].([]any) - after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || len(listed) != 0 || len(workstreamEvents(rec)) != before || !after.Workstream.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("refusal mutated state: %v %v", listed, err) - } -} - -// WP-12 Fix 1 Major 3 (b): literal invalid UTF-8 must be refused before JSON -// can repair source strings, with no source, workstream, agent or event added. -func TestAppWorkstreamMajor3InvalidUTF8BOnWire(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "UTF-8 source wire") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - seed := workstreamSourceWireCall(t, connection, "source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com", "title": "Original title"})["source"].(map[string]any) - path := filepath.Join(t.TempDir(), "source-�.txt") - if err := os.WriteFile(path, []byte("Replacement-character file exists."), 0600); err != nil { - t.Fatal(err) - } - listParams := map[string]any{"workstream_id": w.ID} - before := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] - rowBefore, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - for _, tc := range []struct { - name string - fields map[string]any - }{ - {"path", map[string]any{"kind": "file", "path": path}}, - {"url", map[string]any{"kind": "link", "url": "https://example.com/�"}}, - {"title", map[string]any{"kind": "link", "url": "https://example.com/title", "title": "Title �"}}, - } { - for _, method := range []string{"source.add", "workstream.create"} { - t.Run(method+"/"+tc.name, func(t *testing.T) { - params := map[string]any{"name": "Invalid UTF-8 " + tc.name, "sources": []any{tc.fields}} - if method == "source.add" { - params = map[string]any{"workstream_id": w.ID} - for key, value := range tc.fields { - params[key] = value - } - } - raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": method, "params": params}) - raw = bytes.ReplaceAll(raw, []byte("�"), []byte{0xff}) - if !bytes.Contains(raw, []byte{0xff}) { - t.Fatal("fixture must contain a literal invalid UTF-8 byte") - } - frame := connection.callRaw(t, raw) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("invalid raw UTF-8 accepted: %v", frame) - } - }) - } - } - raw := workstreamWireEncode(t, map[string]any{"id": connection.Next + 1, "method": "source.update", "params": map[string]any{"id": seed["id"], "title": "Changed �"}}) - frame := connection.callRaw(t, bytes.ReplaceAll(raw, []byte("�"), []byte{0xff})) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("invalid update accepted: %v", frame) - } - after := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] - rowAfter, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || !reflect.DeepEqual(after, before) || !rowAfter.Workstream.UpdatedAt.Equal(rowBefore.Workstream.UpdatedAt) || *calls != 1 || len(workstreamEvents(rec)) != events { - t.Fatalf("invalid UTF-8 changed state: sources=%v agents=%d err=%v", after, *calls, err) - } - if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 1 { - t.Fatalf("invalid UTF-8 created workstreams: %+v %v", list, err) - } -} - -// WP-12 Fix 1 Major 3 (b): source validation preserves create's existing -// replacement decoding for name and why, and accepts literal valid U+FFFD. -func TestAppWorkstreamMajor3InvalidUTF8BLeavesCreateTextDecoding(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - raw := fmt.Appendf(nil, `{"id":%d,"method":"workstream.create","params":{"name":"Name `, connection.Next+1) - raw = append(raw, 0xff) - raw = append(raw, []byte(`","why":"Why `)...) - raw = append(raw, 0xff) - raw = append(raw, []byte(`","sources":[{"kind":"link","url":"https://example.com","title":"Valid �"}]}}`)...) - created := workstreamWireResult(t, connection.callRaw(t, raw)).(map[string]any)["workstream"].(map[string]any) - if created["name"] != "Name �" || created["why"] != "Why �" { - t.Fatalf("create text decoding changed: %v", created) - } - sources := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": created["id"]})["sources"].([]any) - if len(sources) != 1 || sources[0].(map[string]any)["title"] != "Valid �" { - t.Fatalf("valid replacement character refused: %v", sources) - } -} - -// WP-12 Fix 1 Minor 1 (a), (b): wire adds refuse overlong normalized URLs -// without writes/events, while a returned URL at the bound can be re-added. -func TestAppWorkstreamMinor1LinkBoundABOnWire(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Normalized URL wire") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - events := len(workstreamEvents(rec)) - params := map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://h/" + strings.Repeat("é", 1019)} - frame := connection.call(t, "source.add", params) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" { - t.Fatalf("expanded URL accepted: %v", frame) - } - listParams := map[string]any{"workstream_id": w.ID} - list := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"].([]any) - after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || len(list) != 0 || len(workstreamEvents(rec)) != events || !after.Workstream.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("invalid URL changed state: %v %v", list, err) - } - params["url"] = "https://h/" + strings.Repeat("é", 339) + "abcd" - created := workstreamSourceWireCall(t, connection, "source.add", params) - item := created["source"].(map[string]any) - want := "https://h/" + strings.Repeat("%C3%A9", 339) + "abcd" - if created["already"] != false || item["url"] != want || len(item["url"].(string)) != 2048 { - t.Fatalf("fitting URL: %v", created) - } - params["url"] = item["url"] - again := workstreamSourceWireCall(t, connection, "source.add", params) - list = workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"].([]any) - if again["already"] != true || !reflect.DeepEqual(again["source"], item) || len(list) != 1 || len(workstreamEvents(rec)) != events+1 { - t.Fatalf("returned URL retry: %v sources=%v", again, list) - } -} - -// WP-12 (b), (e): create refuses every bad source before opening an agent; -// good sources are separate from why and duplicate/client retries add none. -func TestAppWorkstreamCreateSourcesPreflight(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - path := workstreamSourcesFile(t) - good := appwire.WorkstreamSourceAdd{Kind: "file", Path: &path, ReadByJobs: workstreamPtr(true)} - for _, bad := range []appwire.WorkstreamSourceAdd{{Kind: "file", Path: workstreamPtr("relative")}, {Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: "missing"}}} { - _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Free name", Sources: []appwire.WorkstreamSourceAdd{good, bad}}) - workstreamCheckError(t, err, appwire.CodeBadParams) - } - if *calls != 0 || len(workstreamEvents(rec)) != 0 { - t.Fatal("refused source created an agent or event") - } - if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 0 { - t.Fatalf("refused create left rows: %+v %v", list, err) - } - why := "Exactly sent.\n" - p := appwire.WorkstreamCreateParams{Name: "Free name", Why: &why, ClientID: workstreamPtr("source-retry"), Sources: []appwire.WorkstreamSourceAdd{{Kind: "repo", Repo: workstreamPtr("acme/billing")}, good, good}} - created, err := b.WorkstreamCreate(p) - if err != nil || created.Workstream.Why != why || *calls != 1 { - t.Fatalf("create: %+v %v", created, err) - } - p.Sources = []appwire.WorkstreamSourceAdd{{Kind: "link", URL: workstreamPtr("https://example.com/new")}} - if again, err := b.WorkstreamCreate(p); err != nil || again.Workstream.ID != created.Workstream.ID || *calls != 1 { - t.Fatalf("retry: %+v %v", again, err) - } - list, err := b.WorkstreamSourceList(appwire.WorkstreamSourceListParams{WorkstreamID: created.Workstream.ID}) - if err != nil || len(list.Sources) != 2 || list.Sources[0].ReadByJobs || !list.Sources[1].ReadByJobs || list.Sources[1].AddedBy != "you" || list.Sources[1].Proposed { - t.Fatalf("created sources: %+v %v", list, err) - } - tooMany := make([]appwire.WorkstreamSourceAdd, workstream.CreateSourcesMax+1) - for i := range tooMany { - tooMany[i] = good - } - _, err = b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Over limit", Sources: tooMany}) - workstreamCheckError(t, err, appwire.CodeBadParams) -} - -// WP-12 Fix 1 Major 2 (e): identical wire retries bypass source existence -// checks and create no agent, sources or events; new clients keep the checks. -func TestAppWorkstreamMajor2CreateRetryEOnWire(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - path := workstreamSourcesFile(t) - p := map[string]any{"name": "Retry with removed source", "client_id": "retry-removed", "sources": []any{map[string]any{"kind": "file", "path": path}}} - created := workstreamSourceWireCall(t, connection, "workstream.create", p)["workstream"].(map[string]any) - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - listParams := map[string]any{"workstream_id": created["id"]} - before := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] - events := len(workstreamEvents(rec)) - again := workstreamSourceWireCall(t, connection, "workstream.create", p)["workstream"] - after := workstreamSourceWireCall(t, connection, "source.list", listParams)["sources"] - if !reflect.DeepEqual(again, created) || !reflect.DeepEqual(after, before) || len(after.([]any)) != 1 || *calls != 1 || len(workstreamEvents(rec)) != events { - t.Fatalf("retry changed state: workstream=%v sources=%v calls=%d", again, after, *calls) - } - p["client_id"], p["name"] = "new-client", "New client name" - frame := connection.call(t, "workstream.create", p) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" || *calls != 1 || len(workstreamEvents(rec)) != events { - t.Fatalf("new client bypassed validation: %v", frame) - } - if list, err := b.WorkstreamList(appwire.WorkstreamListParams{}); err != nil || len(list.Workstreams) != 1 { - t.Fatalf("new client wrote a workstream: %+v %v", list, err) - } - delete(p, "sources") - workstreamSourceWireCall(t, connection, "workstream.create", p) -} - -// WP-12 (c), (h): toggles and keeping a proposal use the real wire path; -// done rejects all mutations and reopen restores them. -func TestAppWorkstreamSourceUpdatesAndDoneOnWire(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - proposal, err := door.ProposeSource(ctx, sessionWorkstreamSourceProposalForTest()) - if err != nil { - t.Fatal(err) - } - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - before := len(workstreamEvents(rec)) - frame := connection.call(t, "source.update", map[string]any{"id": proposal.ID, "read_by_jobs": true}) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "bad_params" || len(workstreamEvents(rec)) != before { - t.Fatalf("proposal jobs: %v", frame) - } - kept := workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "keep": true, "read_by_jobs": true})["source"].(map[string]any) - if kept["proposed"] != false || kept["read_by_jobs"] != true || kept["why"] != "Read the guide." || kept["added_by"] != "agent" { - t.Fatalf("keep: %v", kept) - } - for _, flag := range []bool{false, true} { - workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "read_by_jobs": flag}) - list := workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID})["sources"].([]any) - if len(list) != 1 || list[0].(map[string]any)["read_by_jobs"] != flag { - t.Fatalf("toggle: %v", list) - } - } - before = len(workstreamEvents(rec)) - workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "keep": true}) - if len(workstreamEvents(rec)) != before { - t.Fatal("no-op update sent an event") - } - workstreamSourceWireCall(t, connection, "workstream.mark_done", map[string]any{"id": w.ID}) - before = len(workstreamEvents(rec)) - for _, tc := range []struct { - method string - params map[string]any - }{ - {"source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com/new"}}, - {"source.update", map[string]any{"id": proposal.ID, "title": "Changed"}}, - {"source.remove", map[string]any{"id": proposal.ID}}, - } { - frame := connection.call(t, tc.method, tc.params) - if frame["error"] == nil || frame["error"].(map[string]any)["code"] != "conflict" { - t.Fatalf("done: %v", frame) - } - } - if len(workstreamEvents(rec)) != before { - t.Fatal("done write sent an event") - } - if _, err := door.ProposeSource(ctx, sessionWorkstreamSourceProposalForTest()); err == nil { - t.Fatal("done proposal succeeded") - } - workstreamSourceWireCall(t, connection, "source.list", map[string]any{"workstream_id": w.ID}) - workstreamSourceWireCall(t, connection, "workstream.reopen", map[string]any{"id": w.ID}) - workstreamSourceWireCall(t, connection, "source.update", map[string]any{"id": proposal.ID, "title": "Reopened"}) - workstreamSourceWireCall(t, connection, "source.remove", map[string]any{"id": proposal.ID}) - workstreamSourceWireCall(t, connection, "source.add", map[string]any{"workstream_id": w.ID, "kind": "link", "url": "https://example.com/new"}) -} - -// WP-12 conformance: four heterogeneous source rows retain exact optional keys -// at each illustrated index, including the agent's reason only on its proposal. -func (fixture *workstreamWireFixture) sources(t *testing.T) { - t.Helper() - b := fixture.Backend - fixture.record(t) - adds := []appwire.WorkstreamSourceAddParams{ - {WorkstreamID: fixture.Stream.ID, Kind: "file", Path: workstreamPtr(fixture.IDs["/Users/ada/billing/usage-pricing.md"]), ReadByJobs: workstreamPtr(true)}, - {WorkstreamID: fixture.Stream.ID, Kind: "issue", Repo: workstreamPtr("acme/billing"), Number: workstreamPtr(412), ReadByJobs: workstreamPtr(true)}, - {WorkstreamID: fixture.Stream.ID, Kind: "kept", Ref: &appwire.WorkstreamKeptRef{Kind: "record", ID: fixture.IDs["rec_7a1e"]}}, - } - for i, ask := range adds { - made, err := b.WorkstreamSourceAdd(ask) - if err != nil { - t.Fatal(err) - } - fixture.IDs[fmt.Sprintf("src_2b7%d", i+1)] = made.Source.ID - } - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - item, _, change, err := s.ProposeSource(context.Background(), fixture.Stream.ID, workstream.SourceAdd{Kind: "link", URL: workstreamPtr("https://docs.stripe.com/billing/subscriptions/usage-based")}, "The metering rules the plan follows.") - if err != nil { - t.Fatal(err) - } - b.workstreamChanged(change) - fixture.IDs["src_2b74"] = item.ID -} - -func workstreamSeedSources(t *testing.T, b *appBackend, id string, total int) { - t.Helper() - store, err := b.collectionStore(false) - if err != nil { - t.Fatal(err) - } - if err := store.Write(context.Background(), func(tx *sql.Tx) error { - statement, err := tx.Prepare(`INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,added_by,added_at) VALUES (?,?,'link',?,'Example',?,'you',100)`) - if err != nil { - return err - } - defer statement.Close() - for i := 0; i < total; i++ { - url := fmt.Sprintf("https://example.com/source/%d", i) - if _, err := statement.Exec(fmt.Sprintf("fixture-source-%d", i), id, url, url); err != nil { - return err - } - } - return nil - }); err != nil { - t.Fatal(err) - } -} - -func workstreamWireSourceError(t *testing.T, fixture *workstreamWireFixture, errorCase workstreamWireErrorCase, params map[string]any) bool { - t.Helper() - if !strings.HasPrefix(errorCase.Name, "source_") { - return false - } - switch { - case strings.HasSuffix(errorCase.Name, "_bad"): - if errorCase.Method == "source.list" || errorCase.Method == "source.add" { - delete(params, "workstream_id") - } else { - delete(params, "id") - } - case strings.HasSuffix(errorCase.Name, "_missing"): - if errorCase.Method == "source.list" || errorCase.Method == "source.add" { - params["workstream_id"] = "missing-workstream" - } else { - params["id"] = "missing-source" - } - case strings.HasSuffix(errorCase.Name, "_done"): - if _, err := fixture.Backend.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: fixture.Stream.ID}); err != nil { - t.Fatal(err) - } - case strings.HasSuffix(errorCase.Name, "_limit"): - workstreamSeedSources(t, fixture.Backend, fixture.Stream.ID, workstream.SourcesMax) - default: - return false - } - return true -} diff --git a/cmd/codeaf/appworkstreams_suggestions_test.go b/cmd/codeaf/appworkstreams_suggestions_test.go deleted file mode 100644 index 970abafa05..0000000000 --- a/cmd/codeaf/appworkstreams_suggestions_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -// WP-6d Schema, Tool: both wire lists and workstream_read pages work before -// suggestion tables exist. Refused decisions do not commit their migration. -func TestAppWorkstreamSuggestionsOnOlderSchemas(t *testing.T) { - for _, version := range []int{3, 4, 5} { - t.Run(fmt.Sprint(version), func(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil || len(pages.Pages) == 0 { - t.Fatal("missing fixture page", err) - } - pageID := pages.Pages[0].ID - store, err := b.collectionStore(false) - if err != nil { - t.Fatal(err) - } - if err := store.Write(ctx, func(tx *sql.Tx) error { - ddl := "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions;" - if version < workspace.WorkstreamSourcesSchemaVersion { - ddl += "DROP TABLE workstream_sources;" - } - if version == 3 { - ddl += "DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes;" - } - _, err := tx.Exec(ddl + fmt.Sprintf("PRAGMA user_version=%d", version)) - return err - }); err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - listed, err := b.WorkstreamSuggestionList(appwire.WorkstreamSuggestionListParams{PageID: pageID}) - if err != nil || listed.Suggestions == nil || len(listed.Suggestions) != 0 { - t.Fatalf("old-schema list: %+v %v", listed, err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: pageID}) - if err != nil { - t.Fatal(err) - } - var read struct { - Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` - } - if err := json.Unmarshal(raw, &read); err != nil || read.Suggestions == nil || len(read.Suggestions) != 0 { - t.Fatalf("old-schema page read: %s %v", raw, err) - } - _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - _, err = b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - if len(workstreamEvents(rec)) != events { - t.Fatal("old-schema reads or refused decisions emitted an event") - } - if err := store.Read(ctx, func(_ *sql.Tx, got int) error { - if got != version { - t.Fatalf("old-schema reads or refused decisions migrated to %d", got) - } - return nil - }); err != nil { - t.Fatal(err) - } - }) - } -} - -// WP-6d (a), Tool: the real door refuses agent-owned pages and direct writes -// to person pages, then suggests with chat attribution and pages-only events. -func TestAppWorkstreamAgentSuggestionsAndOwnership(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - ctx := context.Background() - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old words.")}) - if err != nil { - t.Fatal(err) - } - history, err := s.PageHistory(ctx, page.ID, 50) - if err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - _, err = door.WritePage(ctx, session.WorkstreamPageWrite{PageID: page.ID, Markdown: "Direct write", UpdatedFrom: "Discussion"}) - if !errors.Is(err, session.ErrWorkstreamNotAgentPage) || err.Error() != "That page is the person's. You write only your own pages; suggest edits on theirs with suggest_page_edit." { - t.Fatalf("direct write refusal: %v", err) - } - after, _ := s.Page(ctx, page.ID, false) - afterHistory, _ := s.PageHistory(ctx, page.ID, 50) - if !reflect.DeepEqual(page, after) || !reflect.DeepEqual(history, afterHistory) || len(workstreamEvents(rec)) != events { - t.Fatal("direct write changed state") - } - owner := "agent" - if _, _, err := s.UpdatePage(ctx, page.ID, workstream.PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - ask := session.WorkstreamSuggestionAsk{PageID: page.ID, BaseVersion: page.Version, Why: "Clarify", Edits: []workstream.SuggestionFind{{Find: "Old", Replace: "New", Why: "Clearer"}}} - _, err = door.SuggestEdits(ctx, ask) - if !errors.Is(err, workstream.ErrNotPersonPage) || err.Error() != "That page is yours; change it with write_agent_page. Suggest edits only on the person's pages." { - t.Fatalf("own page refusal: %v", err) - } - open, err := s.Suggestions(ctx, page.ID) - if err != nil || len(open) != 0 || len(workstreamEvents(rec)) != events { - t.Fatal("own-page refusal stored or emitted", err) - } - owner = "you" - if _, _, err := s.UpdatePage(ctx, page.ID, workstream.PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - result, err := door.SuggestEdits(ctx, ask) - if err != nil || result.Title != page.Title || result.Count != 1 { - t.Fatalf("suggest: %+v %v", result, err) - } - open, err = s.Suggestions(ctx, page.ID) - if err != nil || len(open) != 1 || open[0].ID != result.ID || !reflect.DeepEqual(open[0].Source, &workstream.Source{Kind: "chat", ID: w.AgentID}) { - t.Fatalf("suggested source: %+v %v", open, err) - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: page.ID}) - if err != nil { - t.Fatal(err) - } - var read struct { - Suggestions []appwire.WorkstreamSuggestion `json:"suggestions"` - } - if err := json.Unmarshal(raw, &read); err != nil || len(read.Suggestions) != 1 || !reflect.DeepEqual(read.Suggestions[0], appWorkstreamSuggestion(open[0])) { - t.Fatalf("page read lost suggestions: %s %v", raw, err) - } - after, _ = s.Page(ctx, page.ID, false) - afterHistory, _ = s.PageHistory(ctx, page.ID, 50) - if !reflect.DeepEqual(after.Doc, page.Doc) || after.Version != page.Version || !reflect.DeepEqual(history, afterHistory) { - t.Fatal("suggestion changed page") - } - changes := workstreamEvents(rec) - if len(changes) != events+1 || !reflect.DeepEqual(changes[len(changes)-1], appwire.WorkstreamChanged{ID: w.ID, Parts: []string{"pages"}}) { - t.Fatalf("create events: %+v", changes[events:]) - } -} - -// WP-6d (a): every source-taking wire parameter refuses suggestion attribution -// without changing pages, history, records, plans, workstream or event count. -func TestAppWorkstreamSuggestionSourceParamsRefused(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Source refusals") - connection := workstreamWireConnect(t, &workstreamWireBackend{appBackend: b}) - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - pages, err := s.Pages(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - id := pages[0].ID - record, _, err := s.ProposeRecord(context.Background(), workstream.RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Decision"}) - if err != nil { - t.Fatal(err) - } - snapshot := func() []any { - page, err := s.Page(context.Background(), id, false) - if err != nil { - t.Fatal(err) - } - history, err := s.PageHistory(context.Background(), id, 50) - if err != nil { - t.Fatal(err) - } - nodes, err := s.Pages(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - records, err := s.Records(context.Background(), w.ID, "", "", true) - if err != nil { - t.Fatal(err) - } - plan, err := s.Plan(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - stream, err := s.Get(context.Background(), w.ID) - if err != nil { - t.Fatal(err) - } - return []any{page, history, nodes, records, plan, stream} - } - before := snapshot() - events := len(workstreamEvents(rec)) - source := map[string]any{"kind": "suggestion", "id": "sg_forged"} - for _, test := range []struct { - method string - params map[string]any - }{ - {"page.save", map[string]any{"id": id, "base_version": 1, "doc": map[string]any{"type": "doc"}, "source": source}}, - {"page.create", map[string]any{"workstream_id": w.ID, "title": "Forged", "source": source}}, - {"record.propose", map[string]any{"workstream_id": w.ID, "kind": "decision", "text": "Forged", "source": source}}, - {"record.supersede", map[string]any{"id": record.ID, "text": "Forged", "source": source}}, - {"workstream.plan_set", map[string]any{"id": w.ID, "base_version": 0, "milestones": []any{}, "waves": []any{map[string]any{"n": 1, "auto_start": false, "items": []any{map[string]any{"kind": "draft", "draft_id": "draft", "title": "Forged", "source": source}}}}}}, - } { - frame := connection.call(t, test.method, test.params) - if failure, ok := frame["error"].(map[string]any); !ok || failure["code"] != "bad_params" { - t.Fatalf("%s: %v", test.method, frame) - } - if !reflect.DeepEqual(before, snapshot()) || len(workstreamEvents(rec)) != events { - t.Fatalf("%s refusal changed state", test.method) - } - } -} - -// WP-6d (b), (d), (h): wire decisions emit pages only; stale and done decisions -// emit nothing and carry the contract error/detail. Done reads remain available. -func TestAppWorkstreamSuggestionDecisionsAndDone(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old words.\n\nOther words.")}) - if err != nil { - t.Fatal(err) - } - ask := session.WorkstreamSuggestionAsk{PageID: page.ID, BaseVersion: page.Version, Why: "Clarity", Edits: []workstream.SuggestionFind{{Find: "Old", Replace: "New", Why: "First"}, {Find: "Other", Replace: "Clear", Why: "Second"}}} - suggested, err := door.SuggestEdits(context.Background(), ask) - if err != nil { - t.Fatal(err) - } - events := len(workstreamEvents(rec)) - if _, err := b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: suggested.ID, EditID: workstreamPtr("e1")}); err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: suggested.ID, EditID: workstreamPtr("e2")}); err != nil { - t.Fatal(err) - } - changes := workstreamEvents(rec) - if len(changes) != events+2 { - t.Fatal("missing decision events") - } - for _, change := range changes[events:] { - if !reflect.DeepEqual(change, appwire.WorkstreamChanged{ID: w.ID, Parts: []string{"pages"}}) { - t.Fatalf("decision parts: %+v", change) - } - } - page, err = s.Page(context.Background(), page.ID, false) - if err != nil { - t.Fatal(err) - } - ask.BaseVersion, ask.Edits = page.Version, []workstream.SuggestionFind{{Find: "New", Replace: "Newest", Why: "Update"}} - stale, err := door.SuggestEdits(context.Background(), ask) - if err != nil { - t.Fatal(err) - } - page, _, err = s.SavePage(context.Background(), page.ID, page.Version, pagedoc.FromMarkdown("Changed by the person."), workstream.Attribution{}) - if err != nil { - t.Fatal(err) - } - events = len(workstreamEvents(rec)) - _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: stale.ID}) - failure := workstreamCheckError(t, err, appwire.CodeVersionConflict) - want := appwire.WorkstreamVersionConflict{ID: page.ID, Version: page.Version, UpdatedAt: page.UpdatedAt, UpdatedBy: page.UpdatedBy} - if failure.Message != "the page changed and this edit no longer applies" || !reflect.DeepEqual(failure.Detail, want) || len(workstreamEvents(rec)) != events { - t.Fatalf("stale receipt: %+v", failure) - } - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - events = len(workstreamEvents(rec)) - _, err = door.SuggestEdits(context.Background(), ask) - if !errors.Is(err, session.ErrWorkstreamDone) || err.Error() != "This workstream is done; nothing was added or written." { - t.Fatalf("done tool: %v", err) - } - _, err = b.WorkstreamSuggestionKeep(appwire.WorkstreamSuggestionKeepParams{ID: stale.ID}) - workstreamCheckError(t, err, appwire.CodeConflict) - _, err = b.WorkstreamSuggestionDrop(appwire.WorkstreamSuggestionDropParams{ID: stale.ID}) - workstreamCheckError(t, err, appwire.CodeConflict) - if _, err := b.WorkstreamSuggestionList(appwire.WorkstreamSuggestionListParams{PageID: page.ID}); err != nil { - t.Fatal(err) - } - if len(workstreamEvents(rec)) != events { - t.Fatal("done refusals or read emitted events") - } -} - -// WP-6d Tool: the adapter maps the all-states count bound to its exact answer. -func TestAppWorkstreamSuggestionCountRefusal(t *testing.T) { - b, rec, w, door := workstreamAgentFixture(t) - s, err := b.workstreamStore(false) - if err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Notes", Doc: pagedoc.FromMarkdown("Old")}) - if err != nil { - t.Fatal(err) - } - store, err := b.collectionStore(false) - if err != nil { - t.Fatal(err) - } - if err := store.Write(context.Background(), func(tx *sql.Tx) error { - _, err := tx.Exec(`WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x 1 { - return api.Queue{}, errors.New("unreachable") - } - return api.Queue{Jobs: []api.Job{{ID: 7, State: "running", Cost: 1.25}}}, nil - } - w := workstreamCreateForTest(t, b, "First start", workstreamJobRef("7")) - events := len(workstreamEvents(rec)) - var result appwire.Workstream - switch method { - case "update": - v, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Updated before get")}}) - if err != nil { - t.Fatal(err) - } - result = v.Workstream - case "mark_done": - v, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - result = v.Workstream - case "reopen": - // Reopening an already open workstream still observes its jobs. - v, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - result = v.Workstream - } - stage := "building" - if method == "mark_done" { - stage = "done" - } - if result.Stage != stage || result.StartedAt.IsZero() || !result.UpdatedAt.After(w.UpdatedAt) { - t.Fatalf("first observation: %+v", result) - } - changed := workstreamEvents(rec)[events:] - if len(changed) != 1 || changed[0].ID != w.ID || !reflect.DeepEqual(changed[0].Parts, []string{"workstream"}) { - t.Fatalf("mutation events: %+v", changed) - } - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || !reflect.DeepEqual(result, got.Workstream) || len(workstreamEvents(rec)) != events+1 || reads != 1 { - t.Fatalf("get after mutation: %+v, %v; events %d, reads %d", got, err, len(workstreamEvents(rec))-events, reads) - } - clock.Add(workstreamFactoryFresh.Milliseconds()) - got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.Stage != stage || !got.Workstream.StartedAt.Equal(result.StartedAt) || !got.Workstream.UpdatedAt.Equal(result.UpdatedAt) || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil || reads != 2 || len(workstreamEvents(rec)) != events+1 { - t.Fatalf("unreachable Factory lost durable observation: %+v, %v; reads %d", got, err, reads) - } - updated, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("Committed while unreachable")}}) - if err != nil || updated.Workstream.Why != "Committed while unreachable" || updated.Workstream.Stage != stage || updated.Workstream.SpendUSD != nil || len(workstreamEvents(rec)) != events+2 { - t.Fatalf("unreachable Factory failed mutation: %+v, %v", updated, err) - } - }) - } -} - -// (e): marking done ends grants of all three scopes and archives member chats -// through the existing archive door, excluding the workstream's agent record. -func TestAppWorkstreamDoneEndsGoAheadsAndArchivesChats(t *testing.T) { - b, _, _, origin := workstreamBackend(t) - made, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Done", OriginChatID: &origin.ID, Refs: []appwire.WorkstreamRef{workstreamJobRef("7"), {Kind: appwire.RefConversation, ID: "second-chat"}}}) - if err != nil { - t.Fatal(err) - } - id := made.Workstream.ID - _, err = b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: id, Milestones: []appwire.WorkstreamMilestone{{ID: "m", Title: "Ship"}}, Waves: []appwire.WorkstreamWave{{N: 1, MilestoneID: workstreamPtr("m"), Items: []appwire.WorkstreamPlanItem{}}}}) - if err != nil { - t.Fatal(err) - } - for _, p := range []appwire.WorkstreamGoAheadGrantParams{ - {Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7)}, - {Scope: "wave", Wave: workstreamPtr(1)}, - {Scope: "milestone", MilestoneID: workstreamPtr("m")}, - } { - p.WorkstreamID = id - p.CapUSD = 40 - p.EndsAt = time.Now().Add(time.Hour) - if _, err := b.WorkstreamGoAheadGrant(p); err != nil { - t.Fatal(err) - } - } - var archived []string - b.workstreams.archiveChats = func(ids []string) error { archived = append(archived, ids...); return nil } - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: id, ArchiveChats: workstreamPtr(true)}); err != nil { - t.Fatal(err) - } - grants, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: id, Ended: workstreamPtr(true)}) - if err != nil || len(grants.GoAheads) != 3 { - t.Fatalf("grants %+v, %v", grants, err) - } - for _, g := range grants.GoAheads { - if g.State != "ended" || workstreamValue(g.EndReason) != "workstream_done" || g.EndedAt.IsZero() { - t.Fatalf("unended %+v", g) - } - } - if slices.Contains(archived, made.Workstream.AgentID) || len(archived) != 2 || !slices.Contains(archived, origin.ID) || !slices.Contains(archived, "second-chat") { - t.Fatalf("archived %v", archived) - } -} - -// (f): stale saves preserve the document and history; restore creates a new -// version instead of modifying an old one, with complete typed stale detail. -func TestAppWorkstreamPageVersions(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Pages") - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil { - t.Fatal(err) - } - id := pages.Pages[0].ID - doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Saved"}]}]}`) - saved, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: doc}) - if err != nil || saved.Page.Version != 2 { - t.Fatalf("save %+v, %v", saved, err) - } - before := len(workstreamEvents(rec)) - _, err = b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: json.RawMessage(`{}`)}) - wire := workstreamCheckError(t, err, appwire.CodeVersionConflict) - detail, ok := wire.Detail.(appwire.WorkstreamVersionConflict) - if !ok || detail.ID != id || detail.Version != 2 || detail.UpdatedAt != saved.Page.UpdatedAt || detail.UpdatedBy != "you" { - t.Fatalf("detail %+v", wire.Detail) - } - read, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: id, Markdown: workstreamPtr(true)}) - if err != nil || !bytes.Equal(read.Page.Doc, doc) || read.Page.Markdown == nil || !strings.Contains(*read.Page.Markdown, "Saved") { - t.Fatalf("read %+v, %v", read, err) - } - history, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) - if err != nil || len(history.Versions) != 2 || history.Versions[0].Version != 2 { - t.Fatalf("history %+v, %v", history, err) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("stale save or reads emitted") - } - restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id, Version: workstreamPtr(1), BaseVersion: workstreamPtr(2)}) - if err != nil || restored.Page.Version != 3 || string(restored.Page.Doc) != `{"type":"doc","content":[]}` { - t.Fatalf("restore %+v, %v", restored, err) - } - history, err = b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id}) - if err != nil || len(history.Versions) != 3 || history.Versions[0].Version != 3 { - t.Fatalf("restored history %+v, %v", history, err) - } - _, err = b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeBadParams) -} - -// (g): keep/drop support undo; a replacement links both records and closes -// every later mutation of the superseded record with conflict. -func TestAppWorkstreamRecordLifecycle(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Records") - proposed, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "Ship?", Source: &appwire.WorkstreamSource{Kind: "chat", ID: "chat"}}) - if err != nil || proposed.Record.State != "proposed" || proposed.Record.By != "agent" { - t.Fatalf("propose %+v, %v", proposed, err) - } - id := proposed.Record.ID - kept, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}) - if err != nil || kept.Record.State != "kept" { - t.Fatalf("keep %+v, %v", kept, err) - } - dropped, err := b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: id}) - if err != nil || dropped.Record.State != "dropped" { - t.Fatalf("drop %+v, %v", dropped, err) - } - if _, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}); err != nil { - t.Fatal(err) - } - replacement, err := b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: id, Text: "Ship it", Kind: workstreamPtr("decision")}) - if err != nil || replacement.Record.State != "kept" || replacement.Record.Kind != "decision" || workstreamValue(replacement.Record.Supersedes) != id || replacement.Superseded.State != "superseded" || workstreamValue(replacement.Superseded.SupersededBy) != replacement.Record.ID { - t.Fatalf("supersede %+v, %v", replacement, err) - } - _, err = b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeConflict) - _, err = b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeConflict) - _, err = b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: id, Text: "Again"}) - workstreamCheckError(t, err, appwire.CodeConflict) -} - -// (h): missing scope, cap or end date, and clock-dependent invalid ends, store -// nothing and emit nothing, through the backend's own checked entry point. -func TestAppWorkstreamInvalidGoAheads(t *testing.T) { - for _, name := range []string{"scope", "cap", "end", "past", "too far", "too much"} { - t.Run(name, func(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Invalid", workstreamJobRef("7")) - p := appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)} - switch name { - case "scope": - p.Scope = "" - case "cap": - p.CapUSD = 0 - case "end": - p.EndsAt = time.Time{} - case "past": - p.EndsAt = time.Now().Add(-time.Second) - case "too far": - p.EndsAt = time.Now().Add(91 * 24 * time.Hour) - case "too much": - p.CapUSD = 10001 - } - before := len(workstreamEvents(rec)) - _, err := b.WorkstreamGoAheadGrant(p) - workstreamCheckError(t, err, appwire.CodeBadParams) - list, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID, Ended: workstreamPtr(true)}) - if err != nil || len(list.GoAheads) != 0 || len(workstreamEvents(rec)) != before { - t.Fatalf("invalid grant stored or emitted: %+v, %v", list, err) - } - }) - } -} - -// (j): encode the actual handler results so absence cannot pass as null or 0. -func TestAppWorkstreamUnknownNumbersAreAbsent(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Unknown", workstreamJobRef("7")) - for _, method := range []string{"get", "list"} { - var result any - var err error - if method == "get" { - result, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - } else { - result, err = b.WorkstreamList(appwire.WorkstreamListParams{}) - } - if err != nil { - t.Fatal(err) - } - data, err := json.Marshal(result) - if err != nil { - t.Fatal(err) - } - for _, field := range []string{`"merged":`, `"needs_you":`, `"spend_usd":`} { - if bytes.Contains(data, []byte(field)) { - t.Fatalf("%s fabricated %s: %s", method, field, data) - } - } - if !bytes.Contains(data, []byte(`"total":1`)) { - t.Fatalf("total absent: %s", data) - } - } - opened, err := b.OpenCollection(w.ID, 1, 200, "") - if err != nil { - t.Fatal(err) - } - for _, node := range opened.Members { - if node.Kind == appwire.RefFactoryJob && node.Target.State != appwire.TargetUnknown { - t.Fatalf("unattached Factory fabricated target state: %+v", node.Target) - } - } -} - -func TestAppWorkstreamCreatePreflightAndCleanup(t *testing.T) { - for _, name := range []string{"name", "origin", "ref", "bounds"} { - t.Run(name, func(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - p := appwire.WorkstreamCreateParams{Name: "Refused"} - code := appwire.CodeBadParams - switch name { - case "name": - if _, err := b.CreateCollection("Refused"); err != nil { - t.Fatal(err) - } - code = appwire.CodeConflict - case "origin": - p.OriginChatID = workstreamPtr("no-chat") - code = appwire.CodeNotFound - case "ref": - p.Refs = []appwire.WorkstreamRef{{Kind: appwire.RefCollection, ID: "no-collection"}} - code = appwire.CodeNotFound - case "bounds": - p.Why = workstreamPtr(strings.Repeat("x", 4001)) - } - before := len(workstreamEvents(rec)) - _, err := b.WorkstreamCreate(p) - workstreamCheckError(t, err, code) - if *calls != 0 || len(workstreamEvents(rec)) != before { - t.Fatal("preflight created or emitted") - } - if name != "name" { - if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("preflight created store folder: %v", err) - } - } - }) - } - t.Run("peer name race closes unused agent", func(t *testing.T) { - b, rec, calls, _ := workstreamBackend(t) - create := b.workstreams.createAgent - b.workstreams.createAgent = func(p appwire.CreateAsk) (appwire.Agent, error) { - if _, err := b.CreateCollection("Raced"); err != nil { - return appwire.Agent{}, err - } - return create(p) - } - closed := "" - b.workstreams.closeAgent = func(id string) error { closed = id; return nil } - _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Raced"}) - workstreamCheckError(t, err, appwire.CodeConflict) - list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil || len(list.Workstreams) != 0 || *calls != 1 || closed != "workstream-agent-1" || len(workstreamEvents(rec)) != 0 { - t.Fatalf("cleanup: %+v, %v, closed %q", list, err, closed) - } - }) - t.Run("agent refusal creates no store", func(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - b.workstreams.createAgent = func(appwire.CreateAsk) (appwire.Agent, error) { - return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "agent refused") - } - _, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Refused"}) - workstreamCheckError(t, err, appwire.CodeBadParams) - if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("agent refusal created store folder: %v", err) - } - if len(workstreamEvents(rec)) != 0 { - t.Fatal("agent refusal emitted") - } - }) -} - -// A fresh workstream list remains a non-migrating, non-creating read. -func TestAppWorkstreamFreshReadsDoNotCreate(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil || list.Workstreams == nil || len(list.Workstreams) != 0 { - t.Fatalf("fresh list %+v, %v", list, err) - } - _, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - if _, err := os.Stat(filepath.Dir(b.collectionsPath)); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("fresh read made store folder: %v", err) - } -} - -// The shared hello hook must recognize the real backend's capability. -func TestAppWorkstreamHelloCarriesFeature(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - var output bytes.Buffer - server := appwire.NewServer(&output) - if err := server.Serve(context.Background(), strings.NewReader(""), appwire.Hello{}, workstreamHelloEngine{Engine: b, Workstreamer: b}); err != nil { - t.Fatal(err) - } - var frame struct { - Event string `json:"event"` - Data appwire.Hello `json:"data"` - } - if err := json.NewDecoder(&output).Decode(&frame); err != nil { - t.Fatal(err) - } - if frame.Event != appwire.EventHello || !slices.Contains(frame.Data.Features, appwire.FeatureWorkstreams) { - t.Fatalf("hello %+v", frame) - } -} - -// Only the tested capability is exposed, leaving the real backend's unrelated -// process warm-up outside this protocol test. -type workstreamHelloEngine struct { - appwire.Engine - appwire.Workstreamer -} - -// Session metadata is the recorded spend source, including a fresh bare open -// excluded from ordinary agents.list. Missing/corrupt metadata stays unknown. -func TestAppWorkstreamRecordedAgentSpend(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Spend") - b.workstreams.spend = nil - dir := t.TempDir() - transcript := filepath.Join(dir, "transcript.jsonl") - a := b.agent(w.AgentID) - b.locked(func() { a.transcript = transcript }) - if err := os.WriteFile(transcript, nil, 0600); err != nil { - t.Fatal(err) - } - if got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); err != nil || got.Workstream.SpendUSD != nil { - t.Fatalf("missing metadata: %+v, %v", got, err) - } - for _, cost := range []float64{0, 2.5} { - meta := session.Meta{ID: w.AgentID, SpentUSD: cost} - if cost > 0 { - meta.LastUserAt = time.Now() - } - data, err := json.Marshal(meta) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "meta.json"), data, 0600); err != nil { - t.Fatal(err) - } - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != cost { - t.Fatalf("recorded %v, got %+v, %v", cost, got, err) - } - } -} - -// (i): every mutation is observed through its public handler and produces one -// event for the changed workstream, containing every part its write changed. -func TestAppWorkstreamEveryWriteEmitsOnce(t *testing.T) { - type fixture struct { - b *appBackend - id, page, parent, record, grant string - base int - } - cases := []struct { - method string - parts []string - done bool - call func(*fixture) error - }{ - {"workstream.create", []string{"workstream", "pages", "jobs"}, false, func(f *fixture) error { - v, err := f.b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Second"}) - f.id = v.Workstream.ID - return err - }}, - {"workstream.update", []string{"workstream"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: f.id, Patch: appwire.WorkstreamPatch{Why: workstreamPtr("New why")}}) - return err - }}, - {"workstream.mark_done", []string{"workstream", "goaheads"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: f.id}) - return err - }}, - {"workstream.reopen", []string{"workstream"}, true, func(f *fixture) error { - _, err := f.b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: f.id}) - return err - }}, - {"workstream.plan_set", []string{"workstream", "plan"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPlanSet(appwire.WorkstreamPlanSetParams{ID: f.id, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}}) - return err - }}, - {"page.create", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: f.id, Title: "New"}) - return err - }}, - {"page.save", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: f.page, BaseVersion: f.base, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}) - return err - }}, - {"page.update", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: f.page, Title: workstreamPtr("Renamed")}) - return err - }}, - {"page.move", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: f.page}) - return err - }}, - {"page.delete", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: f.parent}) - return err - }}, - {"page.restore", []string{"workstream", "pages"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: f.page, Version: workstreamPtr(1), BaseVersion: workstreamPtr(f.base)}) - return err - }}, - {"record.propose", []string{"workstream", "records"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: f.id, Kind: "decision", Text: "New"}) - return err - }}, - {"record.keep", []string{"workstream", "records"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: f.record}) - return err - }}, - {"record.drop", []string{"workstream", "records"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamRecordDrop(appwire.WorkstreamRecordDropParams{ID: f.record}) - return err - }}, - {"record.supersede", []string{"workstream", "records"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamRecordSupersede(appwire.WorkstreamRecordSupersedeParams{ID: f.record, Text: "Replaced"}) - return err - }}, - {"goahead.grant", []string{"workstream", "goaheads"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: f.id, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 2, EndsAt: time.Now().Add(time.Hour)}) - return err - }}, - {"goahead.revoke", []string{"workstream", "goaheads"}, false, func(f *fixture) error { - _, err := f.b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: f.grant}) - return err - }}, - {"collections.add", []string{"workstream", "jobs"}, false, func(f *fixture) error { - _, err := f.b.AddToCollection(f.id, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "8", Factory: "f"}}}) - return err - }}, - {"collections.remove", []string{"workstream", "jobs"}, false, func(f *fixture) error { - _, err := f.b.RemoveFromCollection(f.id, []appwire.Ref{{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}) - return err - }}, - {"collections.rename", []string{"workstream"}, false, func(f *fixture) error { _, err := f.b.RenameCollection(f.id, "Renamed"); return err }}, - {"collections.relink", []string{"workstream", "jobs"}, false, func(f *fixture) error { - _, err := f.b.RelinkCollection(f.id, appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}, appwire.Ref{Kind: appwire.RefFactoryJob, ID: "8", Factory: "f"}) - return err - }}, - } - for _, tc := range cases { - t.Run(tc.method, func(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}, {ID: 8, State: "backlog"}}}, nil - } - w := workstreamCreateForTest(t, b, "Events", workstreamJobRef("7")) - parent, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Parent"}) - if err != nil { - t.Fatal(err) - } - page, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, ParentID: &parent.Page.ID, Title: "Child"}) - if err != nil { - t.Fatal(err) - } - saved, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: page.Page.ID, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc"}`)}) - if err != nil { - t.Fatal(err) - } - record, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "question", Text: "What?"}) - if err != nil { - t.Fatal(err) - } - grant, err := b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)}) - if err != nil { - t.Fatal(err) - } - f := fixture{b: b, id: w.ID, page: page.Page.ID, parent: parent.Page.ID, record: record.Record.ID, grant: grant.GoAhead.ID, base: saved.Page.Version} - if tc.done { - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - } - before := len(workstreamEvents(rec)) - collectionsBefore := len(changedEvents(rec)) - if err := tc.call(&f); err != nil { - t.Fatal(err) - } - events := workstreamEvents(rec)[before:] - if len(events) != 1 || events[0].ID != f.id || !reflect.DeepEqual(events[0].Parts, tc.parts) { - t.Fatalf("%s: %+v, want %s %v", tc.method, events, f.id, tc.parts) - } - if strings.HasPrefix(tc.method, "collections.") && len(changedEvents(rec)) != collectionsBefore+1 { - t.Fatal("membership change lost collection.changed") - } - }) - } -} - -// (i): the read methods and idempotent mutations never manufacture a receipt. -func TestAppWorkstreamReadsAndUnchangedWritesEmitNothing(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Quiet", workstreamJobRef("7")) - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil - } - pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - if err != nil { - t.Fatal(err) - } - page := pages.Pages[0] - r, err := b.WorkstreamRecordPropose(appwire.WorkstreamRecordProposeParams{WorkstreamID: w.ID, Kind: "decision", Text: "Keep", Kept: workstreamPtr(true)}) - if err != nil { - t.Fatal(err) - } - g, err := b.WorkstreamGoAheadGrant(appwire.WorkstreamGoAheadGrantParams{WorkstreamID: w.ID, Scope: "job", Factory: workstreamPtr("f"), Job: workstreamPtr(7), CapUSD: 1, EndsAt: time.Now().Add(time.Hour)}) - if err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: g.GoAhead.ID}); err != nil { - t.Fatal(err) - } - calls := []struct { - name string - call func() error - }{ - {"workstream.list", func() error { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); return err }}, - {"workstream.get", func() error { _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); return err }}, - {"workstream.plan_get", func() error { _, err := b.WorkstreamPlanGet(appwire.WorkstreamPlanGetParams{ID: w.ID}); return err }}, - {"page.list", func() error { - _, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - return err - }}, - {"page.get", func() error { _, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: page.ID}); return err }}, - {"page.history", func() error { - _, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: page.ID}) - return err - }}, - {"record.list", func() error { - _, err := b.WorkstreamRecordList(appwire.WorkstreamRecordListParams{WorkstreamID: w.ID}) - return err - }}, - {"goahead.list", func() error { - _, err := b.WorkstreamGoAheadList(appwire.WorkstreamGoAheadListParams{WorkstreamID: w.ID, Ended: workstreamPtr(true)}) - return err - }}, - {"workstream.update empty", func() error { _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID}); return err }}, - {"workstream.reopen open", func() error { _, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: w.ID}); return err }}, - {"page.update same", func() error { - _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: page.ID, Title: &page.Title}) - return err - }}, - {"record.keep kept", func() error { - _, err := b.WorkstreamRecordKeep(appwire.WorkstreamRecordKeepParams{ID: r.Record.ID}) - return err - }}, - {"goahead.revoke ended", func() error { - _, err := b.WorkstreamGoAheadRevoke(appwire.WorkstreamGoAheadRevokeParams{ID: g.GoAhead.ID}) - return err - }}, - {"collections.add already", func() error { - _, err := b.AddToCollection(w.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"}}}) - return err - }}, - {"collections.remove absent", func() error { - _, err := b.RemoveFromCollection(w.ID, []appwire.Ref{{Kind: appwire.RefFactoryJob, ID: "99", Factory: "f"}}) - return err - }}, - {"collections.rename same", func() error { _, err := b.RenameCollection(w.ID, w.Name); return err }}, - } - before := len(workstreamEvents(rec)) - for _, tc := range calls { - t.Run(tc.name, func(t *testing.T) { - if err := tc.call(); err != nil { - t.Fatal(err) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("unchanged operation emitted") - } - }) - } - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - before = len(workstreamEvents(rec)) - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("mark_done again emitted") - } -} - -// B3: distinct factories are read concurrently, once per list, and reused by -// get and collections.open. A barrier tests work rather than elapsed time. -func TestAppWorkstreamFactoryBatchCacheAndCollectionTargets(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - a := workstreamCreateForTest(t, b, "One", workstreamJobRef("7")) - second := workstreamJobRef("9") - second.Factory = workstreamPtr("g") - workstreamCreateForTest(t, b, "Two", second) - entered := make(chan string, 2) - release := make(chan struct{}) - var mu sync.Mutex - calls := map[string]int{} - workstreamRecordCall := func(factory string) { - mu.Lock() - defer mu.Unlock() - calls[factory]++ - } - b.workstreams.queue = func(ctx context.Context, factory string) (api.Queue, error) { - workstreamRecordCall(factory) - entered <- factory - select { - case <-release: - case <-ctx.Done(): - return api.Queue{}, ctx.Err() - } - id := 7 - if factory == "g" { - id = 9 - } - return api.Queue{Jobs: []api.Job{{ID: id, Num: 1107, Title: "Recorded title", State: "backlog", Repo: "acme/api", Cost: 0}}}, nil - } - done := make(chan error, 1) - guard.Go("app/workstream-list-test", func() { _, err := b.WorkstreamList(appwire.WorkstreamListParams{}); done <- err }) - for range 2 { - select { - case <-entered: - case <-time.After(time.Second): - close(release) - t.Fatal("factories were not read concurrently") - } - } - close(release) - if err := <-done; err != nil { - t.Fatal(err) - } - if _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: a.ID}); err != nil { - t.Fatal(err) - } - open, err := b.OpenCollection(a.ID, 1, 200, "") - if err != nil { - t.Fatal(err) - } - found := false - for _, node := range open.Members { - if node.Kind == appwire.RefFactoryJob { - found = true - if node.Target.State != appwire.TargetLive || node.Target.Title != "Recorded title" || node.Target.Status != "backlog" || node.Target.Repo != "acme/api" { - t.Fatalf("target %+v", node.Target) - } - } - } - mu.Lock() - defer mu.Unlock() - if !found || calls["f"] != 1 || calls["g"] != 1 { - t.Fatalf("target found %v, queue calls %v", found, calls) - } -} - -// workstreamQueueWaitContext signals when a coalesced reader selects on Done, -// so the panic cannot race ahead of the waiter entering the flight branch. -type workstreamQueueWaitContext struct { - context.Context - waiting chan struct{} - once sync.Once -} - -func (c *workstreamQueueWaitContext) Done() <-chan struct{} { - c.once.Do(func() { close(c.waiting) }) - return c.Context.Done() -} - -// (j), B3: a guarded queue panic releases concurrent waiters, caches an -// unavailable reading for the normal window, and permits a successful retry. -func TestAppWorkstreamFactoryPanicFinishesFlight(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - var clock atomic.Int64 - clock.Store(time.Now().UnixMilli()) - b.now = func() time.Time { return time.UnixMilli(clock.Load()) } - w := workstreamCreateForTest(t, b, "Queue panic", workstreamJobRef("7")) - entered := make(chan struct{}) - release := make(chan struct{}) - var releaseOnce sync.Once - defer releaseOnce.Do(func() { close(release) }) - var calls atomic.Int32 - b.workstreams.queue = func(ctx context.Context, _ string) (api.Queue, error) { - if calls.Add(1) == 1 { - close(entered) - select { - case <-release: - panic("queue reader fault") - case <-ctx.Done(): - return api.Queue{}, ctx.Err() - } - } - return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog", Cost: 1.25}}}, nil - } - b.workstreams.job = func(context.Context, string, int) (api.Job, error) { - t.Error("unavailable queue triggered a job fallback") - return api.Job{}, errors.New("unexpected job fallback") - } - type workstreamGetRead struct { - result appwire.WorkstreamGetResult - err error - } - owner := make(chan workstreamGetRead, 1) - guard.Go("app/workstream-panic-owner-test", func() { - result, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - owner <- workstreamGetRead{result: result, err: err} - }) - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("queue reader did not enter") - } - _, flight, owns := b.workstreams.workstreamQueueStart("f", b.now()) - if flight == nil || owns { - t.Fatal("queue reader did not own a shared flight") - } - ctx, cancel := context.WithTimeout(context.Background(), workstreamFactoryTimeout) - defer cancel() - waitCtx := &workstreamQueueWaitContext{Context: ctx, waiting: make(chan struct{})} - waiter := make(chan workstreamQueueRead, 1) - guard.Go("app/workstream-panic-waiter-test", func() { waiter <- b.workstreamQueue(waitCtx, "f") }) - select { - case <-waitCtx.waiting: - case <-time.After(time.Second): - t.Fatal("concurrent reader did not wait on the flight") - } - releaseOnce.Do(func() { close(release) }) - select { - case read := <-owner: - if read.err != nil { - t.Fatal(read.err) - } - encoded, err := json.Marshal(read.result) - if err != nil { - t.Fatal(err) - } - for _, key := range []string{"merged", "needs_you", "spend_usd"} { - if bytes.Contains(encoded, []byte(`"`+key+`"`)) { - t.Fatalf("panic exposed a number in %s", encoded) - } - } - if read.result.Workstream.Total != 1 { - t.Fatalf("panic lost total: %+v", read.result) - } - case <-time.After(time.Second): - t.Fatal("guarded queue owner did not finish") - } - select { - case <-flight: - default: - t.Fatal("panic did not close the flight") - } - select { - case read := <-waiter: - if read.err == nil || errors.Is(read.err, context.DeadlineExceeded) || !read.at.Equal(b.now()) { - t.Fatalf("waiter did not receive the cached failure: %+v", read) - } - case <-time.After(time.Second): - t.Fatal("panic stranded the concurrent waiter") - } - func() { - b.workstreams.cacheMu.Lock() - defer b.workstreams.cacheMu.Unlock() - if _, ok := b.workstreams.flights["f"]; ok { - t.Fatal("panic left an in-flight entry") - } - }() - entry, flight, owns := b.workstreams.workstreamQueueStart("f", b.now()) - if entry.err == nil || flight != nil || owns || calls.Load() != 1 { - t.Fatalf("panic did not remove and cache the failed flight: %+v, %v, %v", entry, flight, owns) - } - clock.Add((workstreamFactoryFresh - time.Millisecond).Milliseconds()) - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil || calls.Load() != 1 { - t.Fatalf("failure did not stay cached: %+v, %v; calls %d", got, err, calls.Load()) - } - clock.Add(1) - got, err = b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || calls.Load() != 2 || got.Workstream.Merged == nil || *got.Workstream.Merged != 0 || got.Workstream.NeedsYou == nil || *got.Workstream.NeedsYou != 0 || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 1.25 { - t.Fatalf("retry after failure-cache expiry: %+v, %v; calls %d", got, err, calls.Load()) - } -} - -// B3: only successful queue reads allow missing-job fallbacks, each distinct -// address is asked once, and work over the per-call allowance remains unknown. -func TestAppWorkstreamMissingJobReadsAreBounded(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - refs := []appwire.WorkstreamRef{} - for i := 1; i <= 25; i++ { - refs = append(refs, workstreamJobRef(fmt.Sprint(i))) - } - w := workstreamCreateForTest(t, b, "Missing", refs...) - queuedReads, jobReads := 0, 0 - b.workstreams.queue = func(ctx context.Context, _ string) (api.Queue, error) { - queuedReads++ - if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > workstreamFactoryTimeout { - t.Error("queue read has no bounded deadline") - } - return api.Queue{}, nil - } - b.workstreams.job = func(ctx context.Context, _ string, job int) (api.Job, error) { - jobReads++ - if _, ok := ctx.Deadline(); !ok { - t.Error("job read has no deadline") - } - return api.Job{ID: job, Num: 999, State: "backlog", Cost: 0}, nil - } - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || queuedReads != 1 || jobReads != 20 || got.Workstream.Total != 25 || got.Workstream.Merged != nil || got.Workstream.NeedsYou != nil || got.Workstream.SpendUSD != nil { - t.Fatalf("bounded fallback %+v, %v; queue %d jobs %d", got, err, queuedReads, jobReads) - } - b.workstreams.cache = nil - jobReads = 0 - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, errors.New("unreachable") } - if _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - if jobReads != 0 { - t.Fatal("unreachable queue triggered per-job reads") - } - open, err := b.OpenCollection(w.ID, 1, 200, "") - if err != nil { - t.Fatal(err) - } - for _, node := range open.Members { - if node.Kind == appwire.RefFactoryJob && (node.Target.State != appwire.TargetUnknown || node.Target.Status != "unknown") { - t.Fatalf("unavailable target %+v", node.Target) - } - } -} - -func TestAppWorkstreamPlanConflictDetailAndDoneRefusal(t *testing.T) { - b, rec, _, _ := workstreamBackend(t) - w := workstreamCreateForTest(t, b, "Plan") - p := appwire.WorkstreamPlanSetParams{ID: w.ID, Milestones: []appwire.WorkstreamMilestone{}, Waves: []appwire.WorkstreamWave{}} - first, err := b.WorkstreamPlanSet(p) - if err != nil { - t.Fatal(err) - } - before := len(workstreamEvents(rec)) - _, err = b.WorkstreamPlanSet(p) - wire := workstreamCheckError(t, err, appwire.CodeVersionConflict) - detail, ok := wire.Detail.(appwire.WorkstreamVersionConflict) - if !ok || detail.ID != w.ID || detail.Version != 1 || detail.UpdatedAt != first.Plan.UpdatedAt || detail.UpdatedBy != "you" { - t.Fatalf("plan conflict %+v", wire) - } - if len(workstreamEvents(rec)) != before { - t.Fatal("stale plan emitted") - } - if _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}); err != nil { - t.Fatal(err) - } - p.BaseVersion = 1 - _, err = b.WorkstreamPlanSet(p) - workstreamCheckError(t, err, appwire.CodeConflict) -} - -// (c): a bare workstream agent survives the existing cleanup path and can be -// reopened after workstream.get restores it into a fresh backend's inventory. -func TestAppWorkstreamBareAgentSurvivesRestart(t *testing.T) { - t.Setenv(home.EnvVar, t.TempDir()) - b, _, _, _ := workstreamBackend(t) - b.collectionsPath = home.Join("v3", "collections.db") - b.workstreams.spend = nil - w := workstreamCreateForTest(t, b, "Persistent") - dir := filepath.Join(session.PlacesRoot(), "project", w.AgentID) - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - transcript := filepath.Join(dir, "transcript.jsonl") - if err := os.WriteFile(transcript, nil, 0600); err != nil { - t.Fatal(err) - } - if err := session.SaveMeta(dir, session.Meta{ID: w.AgentID, Workspace: t.TempDir(), Created: time.Now()}); err != nil { - t.Fatal(err) - } - a := b.agent(w.AgentID) - b.locked(func() { a.transcript = transcript; a.minted = true }) - if err := b.Close(); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(dir); err != nil { - t.Fatal(err) - } - restarted, _, _, _ := workstreamBackend(t) - restarted.collectionsPath = home.Join("v3", "collections.db") - restarted.workstreams.spend = nil - got, err := restarted.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != 0 { - t.Fatalf("restore %+v, %v", got, err) - } - if _, err := restarted.OpenAgent(w.AgentID); err != nil { - t.Fatalf("agent could not be reopened: %v", err) - } - listed, err := restarted.ListAgents("") - if err != nil { - t.Fatal(err) - } - if _, found := listsAgent(listed, w.AgentID); found { - t.Fatal("restoring the bare agent changed agents.list") - } -} - -// (i): chat archiving is an external owner action after the committed done -// transaction. Its failure cannot hide that change or duplicate it on retry. -func TestAppWorkstreamDoneArchiveFailureStillAnnouncesCommit(t *testing.T) { - b, rec, _, origin := workstreamBackend(t) - made, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Archive retry", OriginChatID: &origin.ID}) - if err != nil { - t.Fatal(err) - } - b.workstreams.archiveChats = func([]string) error { return appwire.Errorf(appwire.CodeBusy, "chat is busy") } - p := appwire.WorkstreamMarkDoneParams{ID: made.Workstream.ID, ArchiveChats: workstreamPtr(true)} - before := len(workstreamEvents(rec)) - _, err = b.WorkstreamMarkDone(p) - workstreamCheckError(t, err, appwire.CodeBusy) - events := workstreamEvents(rec) - if len(events) != before+1 || events[before].ID != made.Workstream.ID { - t.Fatalf("lost done notification: %+v", events) - } - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: made.Workstream.ID}) - if err != nil || got.Workstream.Stage != "done" { - t.Fatalf("done not committed: %+v, %v", got, err) - } - b.workstreams.archiveChats = func([]string) error { return nil } - if _, err := b.WorkstreamMarkDone(p); err != nil { - t.Fatal(err) - } - if len(workstreamEvents(rec)) != before+1 { - t.Fatal("archive retry duplicated workstream event") - } -} - -// A job that ran again keeps its earlier runs' money in the workstream's -// spend, through the queue read and the missing-job read alike; an older -// factory that sends no lifetime cost still counts the cost it does send. -func TestAppWorkstreamSpendKeepsAJobsEarlierRuns(t *testing.T) { - b, _, _, _ := workstreamBackend(t) - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{ - {ID: 7, Num: 1107, State: "running", Cost: 0, LifetimeCost: 12}, - {ID: 8, Num: 1108, State: "backlog", Cost: 1.25}, - }}, nil - } - b.workstreams.job = func(_ context.Context, _ string, job int) (api.Job, error) { - return api.Job{ID: job, Num: 1109, State: "running", Cost: 0.5, LifetimeCost: 3}, nil - } - b.workstreams.spend = func(string) *float64 { return workstreamPtr(2.0) } - w := workstreamCreateForTest(t, b, "Reruns", workstreamJobRef("7"), workstreamJobRef("8"), workstreamJobRef("9")) - const want = 12 + 1.25 + 3 + 2 - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.SpendUSD == nil || *got.Workstream.SpendUSD != want { - t.Fatalf("get spend %+v, %v; want %v", got.Workstream.SpendUSD, err, want) - } - rows, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil || len(rows.Workstreams) != 1 || rows.Workstreams[0].SpendUSD == nil || *rows.Workstreams[0].SpendUSD != want { - t.Fatalf("list spend %+v, %v; want %v", rows, err, want) - } -} diff --git a/cmd/codeaf/appworkstreams_wake.go b/cmd/codeaf/appworkstreams_wake.go deleted file mode 100644 index 4a1fd48931..0000000000 --- a/cmd/codeaf/appworkstreams_wake.go +++ /dev/null @@ -1,369 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - "sync" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/automation" - "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -const workstreamWakeTimeout = 3 * time.Minute - -// One active call across backends also bounds tests and embedded app instances. -var workstreamWakeTurnMu sync.Mutex - -type workstreamJobEvent struct { - Factory string - Job int - Kind string - ID string - At time.Time - Label string - Detail string -} - -type workstreamWakeTask struct{ Run workstream.WakeRun } - -// WorkstreamJobEvent is the admission hook for the future job event source. -// Queue reads do not call it: each occurrence must have a stable source identity. -func (b *appBackend) WorkstreamJobEvent(ev workstreamJobEvent) (wakeID string, woke bool) { - if ev.At.IsZero() || ev.Factory == "" || ev.Job <= 0 || ev.Kind == "morning" { - return "", false - } - occurrence := ev.ID - if occurrence == "" { - occurrence = ev.At.UTC().Format(time.RFC3339) - } - key := fmt.Sprintf("job:%s:%d:%s:%s", ev.Factory, ev.Job, ev.Kind, occurrence) - event := workstream.WakeEvent{Kind: ev.Kind, Factory: ev.Factory, Job: ev.Job, At: ev.At, Label: ev.Label, Detail: ev.Detail} - return b.workstreamAdmitWake("", key, event) -} - -func (b *appBackend) workstreamAdmitWake(id, key string, event workstream.WakeEvent) (string, bool) { - s, err := b.workstreamStore(false) - if err != nil || s == nil { - return "", false - } - run, woke, err := s.BeginWake(context.Background(), id, key, event) - if err != nil { - log.Printf("workstream wake: %v", err) - return "", false - } - if !woke { - return "", false - } - b.workstreamEnqueueWake(workstreamWakeTask{Run: run}) - return run.ID, true -} - -func (b *appBackend) workstreamEnqueueWake(task workstreamWakeTask) { - state := &b.workstreams - if state.wakeDispatch != nil { - state.wakeDispatch(task) - return - } - state.wakeMu.Lock() - defer state.wakeMu.Unlock() - if state.wakeSignal == nil { - state.wakeSignal = make(chan struct{}, 1) - ctx, cancel := context.WithCancel(context.Background()) - state.wakeCancel = cancel - guard.Go("app/workstream-wake-worker", func() { b.workstreamWakeWorker(ctx) }) - } - state.wakeQueue = append(state.wakeQueue, task) - select { - case state.wakeSignal <- struct{}{}: - default: - } -} - -func (b *appBackend) workstreamWakeWorker(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-b.workstreams.wakeSignal: - } - for { - if ctx.Err() != nil { - return - } - task, ok := b.workstreamNextWake() - if !ok { - break - } - b.workstreamRunWake(ctx, task) - } - } -} - -func (b *appBackend) watchWorkstreamWakes(stop <-chan struct{}) { - timer := time.NewTimer(30 * time.Second) - defer timer.Stop() - defer func() { - b.workstreams.wakeMu.Lock() - defer b.workstreams.wakeMu.Unlock() - if b.workstreams.wakeCancel != nil { - b.workstreams.wakeCancel() - } - }() - for { - select { - case <-stop: - return - case <-timer.C: - } - b.workstreamWakeTick(b.now()) - timer.Reset(time.Minute) - } -} - -func (b *appBackend) workstreamExpireWakes(s *workstream.Store) error { - changes, err := s.ExpireWakes(context.Background()) - if err == nil { - for _, change := range changes { - b.workstreamChanged(change) - } - } - return err -} - -// workstreamMorningSlot uses the automation clock's local-zone cron semantics. -func workstreamMorningSlot(now time.Time) (time.Time, error) { - local := now.In(time.Local) - midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local) - schedule := automation.Schedule{Every: "0 8 * * *"} - slot, err := schedule.Next(midnight.Add(-time.Minute)) - if err != nil || slot.After(now) { - return time.Time{}, err - } - return slot, nil -} -func (b *appBackend) workstreamWakeTick(now time.Time) { - s, err := b.workstreamStore(false) - if err != nil || s == nil { - return - } - if err := b.workstreamExpireWakes(s); err != nil { - log.Printf("workstream wake: %v", err) - return - } - slot, err := workstreamMorningSlot(now) - if err != nil || slot.IsZero() { - return - } - streams, err := s.List(context.Background()) - if err != nil { - return - } - for _, w := range streams { - if w.Stage == "done" || w.Autonomy == "off" || !w.Wake.Morning || slot.Before(w.CreatedAt) { - continue - } - b.workstreamAdmitWake(w.ID, "morning:"+slot.In(time.Local).Format("2006-01-02"), workstream.WakeEvent{Kind: "morning", At: slot}) - } -} - -// workstreamNextWake takes the oldest queued wake, if any. -func (b *appBackend) workstreamNextWake() (workstreamWakeTask, bool) { - b.workstreams.wakeMu.Lock() - defer b.workstreams.wakeMu.Unlock() - if len(b.workstreams.wakeQueue) == 0 { - return workstreamWakeTask{}, false - } - task := b.workstreams.wakeQueue[0] - b.workstreams.wakeQueue = b.workstreams.wakeQueue[1:] - return task, true -} - -func (b *appBackend) workstreamRunWake(parent context.Context, task workstreamWakeTask) { - workstreamWakeTurnMu.Lock() - defer workstreamWakeTurnMu.Unlock() - ctx, cancel := context.WithTimeout(parent, workstreamWakeTimeout) - defer cancel() - s, err := b.workstreamStore(false) - if err != nil || s == nil { - return - } - run, err := s.WakeRun(ctx, task.Run.ID) - if err != nil || run.State != "running" { - return - } - if b.now().Sub(run.StartedAt) > workstream.WakeAbandonedAfter { - _ = b.workstreamExpireWakes(s) - return - } - task.Run = run - w, err := s.Get(ctx, task.Run.WorkstreamID) - if err != nil { - return - } - // A queued turn must not call the model after done or off was chosen. - if w.Stage == "done" || w.Autonomy == "off" { - _, change, err := s.SettleWake(context.Background(), task.Run.ID, workstream.WakeAnswer{}, nil, "") - if err == nil { - b.workstreamChanged(change) - } - return - } - ask, err := b.workstreamWakeSnapshot(ctx, s, w, task.Run) - var answer session.WorkstreamWakeAnswer - if err == nil { - turn := b.workstreams.wakeTurn - if turn == nil { - turn = b.workstreamRealWakeTurn - } - answer, err = turn(ctx, ask) - } - parsed := workstream.WakeAnswer{} - failure := "" - if err != nil { - failure = "The agent could not finish this wake." - } else { - parsed, err = workstream.ParseWakeAnswer(answer.Text) - if err != nil { - failure = workstream.WakeAnswerError - } - } - _, change, err := s.SettleWake(context.Background(), task.Run.ID, parsed, answer.CostUSD, failure) - if err != nil { - // A refused small change rolls back the entire batch; its call cost is still owed. - _, change, err = s.SettleWake(context.Background(), task.Run.ID, workstream.WakeAnswer{}, answer.CostUSD, "The agent's changes could not be saved.") - } - if err == nil { - b.workstreamChanged(change) - } else { - log.Printf("workstream wake settle: %v", err) - } -} - -func (b *appBackend) workstreamWakeSnapshot(ctx context.Context, s *workstream.Store, w workstream.Workstream, run workstream.WakeRun) (session.WorkstreamWakeAsk, error) { - door := &workstreamAgentDoor{backend: b, agentID: w.AgentID} - snapshot := map[string]json.RawMessage{} - for _, what := range []string{"workstream", "plan", "pages"} { - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: what}) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - snapshot[what] = raw - } - records := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} - for _, state := range []string{"kept", "proposed"} { - rows, err := s.Records(ctx, w.ID, "", state, true) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - for _, r := range rows { - records.Records = append(records.Records, appWorkstreamRecord(r)) - } - } - rawRecords, err := json.Marshal(records) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - snapshot["records"] = rawRecords - grants, err := s.GoAheads(ctx, w.ID, false) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - goaheads := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} - for _, g := range grants { - goaheads.GoAheads = append(goaheads.GoAheads, appWorkstreamGoAhead(g)) - } - rawGrants, err := json.Marshal(goaheads) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - snapshot["goaheads"] = rawGrants - pages, err := s.Pages(ctx, w.ID) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - for _, p := range pages { - if p.Owner != "agent" || p.Title != session.WorkstreamWhereItStandsTitle { - continue - } - raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: p.ID}) - if err != nil { - return session.WorkstreamWakeAsk{}, err - } - snapshot["where_it_stands"] = raw - break - } - // The existing read mappers preserve numbers, plan draft ids and page ownership. - raw, err := json.Marshal(snapshot) - return session.WorkstreamWakeAsk{ID: run.ID, AgentID: w.AgentID, Event: run.Event, Snapshot: raw}, err -} - -func (b *appBackend) workstreamRealWakeTurn(ctx context.Context, ask session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - source, err := v3RolesSource("", b.crewProfile()) - if err != nil { - return session.WorkstreamWakeAnswer{}, err - } - proc, err := openAppEngineProcess() - if err != nil { - return session.WorkstreamWakeAnswer{}, err - } - settings := proc.Settings - return session.RunWorkstreamWake(ctx, session.TeamProposerConfig{Model: settings.Model, APIKey: settings.APIKey, BaseURL: settings.BaseURL, Sources: settings.Sources, ProfileDir: settings.ProfileDir, RolesSource: source}, ask) -} - -func appWorkstreamWake(run workstream.WakeRun) appwire.WorkstreamWakeRun { - out := appwire.WorkstreamWakeRun{ID: run.ID, WorkstreamID: run.WorkstreamID, Event: appwire.WorkstreamWakeEvent{Kind: run.Event.Kind, Factory: workstreamOptional(run.Event.Factory), Job: workstreamPositive(run.Event.Job), Label: workstreamOptional(run.Event.Label)}, State: run.State, StartedAt: run.StartedAt, SettledAt: run.SettledAt, CostUSD: run.CostUSD, Summary: workstreamOptional(run.Summary), Error: workstreamOptional(run.Error), DismissedAt: run.DismissedAt, Items: []appwire.WorkstreamWakeItem{}} - for _, item := range run.Items { - out.Items = append(out.Items, appwire.WorkstreamWakeItem{ID: item.ID, Kind: item.Kind, State: item.State, Text: item.Text, Why: workstreamOptional(item.Why), Factory: workstreamOptional(item.Factory), Job: workstreamPositive(item.Job), DraftID: workstreamOptional(item.DraftID), Wave: workstreamPositive(item.Wave), PageID: workstreamOptional(item.PageID), RecordID: workstreamOptional(item.RecordID), BudgetUSD: item.BudgetUSD, Note: workstreamOptional(item.Note), AppliedAt: item.AppliedAt, UndoneAt: item.UndoneAt}) - } - return out -} - -func (b *appBackend) WorkstreamWakeList(p appwire.WorkstreamWakeListParams) (appwire.WorkstreamWakeListResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeListResult, workstream.Change, error) { - out := appwire.WorkstreamWakeListResult{Wakes: []appwire.WorkstreamWakeRun{}} - if err := b.workstreamExpireWakes(s); err != nil { - return out, workstream.Change{}, err - } - limit := 20 - if p.Limit != nil { - limit = *p.Limit - } - wakes, err := s.Wakes(context.Background(), p.WorkstreamID, limit) - for _, run := range wakes { - out.Wakes = append(out.Wakes, appWorkstreamWake(run)) - } - return out, workstream.Change{}, err - }) -} -func (b *appBackend) WorkstreamWakeApply(p appwire.WorkstreamWakeApplyParams) (appwire.WorkstreamWakeApplyResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeApplyResult, workstream.Change, error) { - item := "" - if p.ItemID != nil { - item = *p.ItemID - } - run, c, err := s.ActOnWake(context.Background(), p.ID, item, "apply") - return appwire.WorkstreamWakeApplyResult{Wake: appWorkstreamWake(run)}, c, err - }) -} -func (b *appBackend) WorkstreamWakeUndo(p appwire.WorkstreamWakeUndoParams) (appwire.WorkstreamWakeUndoResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeUndoResult, workstream.Change, error) { - item := "" - if p.ItemID != nil { - item = *p.ItemID - } - run, c, err := s.ActOnWake(context.Background(), p.ID, item, "undo") - return appwire.WorkstreamWakeUndoResult{Wake: appWorkstreamWake(run)}, c, err - }) -} -func (b *appBackend) WorkstreamWakeNotNow(p appwire.WorkstreamWakeNotNowParams) (appwire.WorkstreamWakeNotNowResult, error) { - return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamWakeNotNowResult, workstream.Change, error) { - run, c, err := s.ActOnWake(context.Background(), p.ID, "", "not_now") - return appwire.WorkstreamWakeNotNowResult{Wake: appWorkstreamWake(run)}, c, err - }) -} diff --git a/cmd/codeaf/appworkstreams_wake_test.go b/cmd/codeaf/appworkstreams_wake_test.go deleted file mode 100644 index d76646a831..0000000000 --- a/cmd/codeaf/appworkstreams_wake_test.go +++ /dev/null @@ -1,452 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "reflect" - "slices" - "sync" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/factory/api" - "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/session" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func workstreamWakeBackend(t *testing.T) (*appBackend, *appRecorder, *time.Time, *[]workstreamWakeTask, *int) { - t.Helper() - b, rec, _, _ := workstreamBackend(t) - now := time.Date(2026, 11, 3, 7, 59, 0, 0, time.Local) - b.now = func() time.Time { return now } - tasks := []workstreamWakeTask{} - b.workstreams.wakeDispatch = func(task workstreamWakeTask) { tasks = append(tasks, task) } - turns := 0 - b.workstreams.wakeTurn = func(ctx context.Context, ask session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - turns++ - if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > workstreamWakeTimeout { - t.Error("wake timeout missing") - } - return session.WorkstreamWakeAnswer{Text: `{"summary":"Review next steps.","items":[{"kind":"decision","text":"Review first."},{"kind":"draft","title":"Follow up","budget_usd":3}]}`, CostUSD: workstreamPtr(.04), Model: "fake"}, nil - } - return b, rec, &now, &tasks, &turns -} -func workstreamDrainWakes(b *appBackend, tasks *[]workstreamWakeTask) { - pending := *tasks - *tasks = nil - for _, task := range pending { - b.workstreamRunWake(context.Background(), task) - } -} -func workstreamWakeListForTest(t *testing.T, b *appBackend, id string) []appwire.WorkstreamWakeRun { - t.Helper() - out, err := b.WorkstreamWakeList(appwire.WorkstreamWakeListParams{WorkstreamID: id}) - if err != nil { - t.Fatal(err) - } - return out.Wakes -} - -func TestWakeA_MorningOncePerLocalDay(t *testing.T) { - b, _, now, tasks, turns := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Morning") - if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true}}}); err != nil { - t.Fatal(err) - } - b.workstreamWakeTick(*now) - if len(*tasks) != 0 { - t.Fatal("woke before first slot") - } - *now = now.Add(time.Minute) - b.workstreamWakeTick(*now) - workstreamDrainWakes(b, tasks) - for range 60 { - *now = now.Add(time.Minute) - b.workstreamWakeTick(*now) - } - if *turns != 1 || len(*tasks) != 0 { - t.Fatal("morning repeated") - } - *now = now.AddDate(0, 0, 1) - b.workstreamWakeTick(*now) - workstreamDrainWakes(b, tasks) - if *turns != 2 || len(workstreamWakeListForTest(t, b, w.ID)) != 2 { - t.Fatal("next day did not wake") - } - // Closed days produce no rows; opening late admits just the current day's slot. - *now = now.AddDate(0, 0, 3) - b.workstreamWakeTick(*now) - workstreamDrainWakes(b, tasks) - if *turns != 3 { - t.Fatal("closed days were replayed") - } - off := "off" - if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}); err != nil { - t.Fatal(err) - } - *now = now.AddDate(0, 0, 1) - b.workstreamWakeTick(*now) - if len(*tasks) != 0 { - t.Fatal("off woke") - } -} - -func TestWakeA_DisabledAndCreatedAfterSlotDoNotWake(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - *now = time.Date(2026, 11, 3, 12, 0, 0, 0, time.Local) - w := workstreamCreateForTest(t, b, "Late") - b.workstreamWakeTick(*now) - if len(*tasks) != 0 { - t.Fatal("disabled morning woke") - } - b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true, RelatedIssues: true}}}) - b.workstreamWakeTick(*now) - if len(*tasks) != 0 { - t.Fatal("slot before creation woke") - } -} - -func TestWakeB_JobHookBatchesAndDeduplicates(t *testing.T) { - b, rec, now, tasks, turns := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Events", workstreamJobRef("7")) - ev := workstreamJobEvent{Factory: "f", Job: 7, Kind: "merged", ID: "merge-1", At: *now, Label: "api#7 merged", Detail: "The merge was recorded."} - id, woke := b.WorkstreamJobEvent(ev) - if !woke || id == "" || len(*tasks) != 1 { - t.Fatal(id, woke) - } - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("duplicate woke") - } - ev.Job = 8 - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("nonmember woke") - } - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - if *turns != 1 || len(runs) != 1 || len(runs[0].Items) != 2 || runs[0].State != "settled" { - t.Fatal(runs, *turns) - } - count := 0 - for _, e := range workstreamEvents(rec) { - if slices.Contains(e.Parts, "wakes") { - count++ - } - } - if count != 1 { - t.Fatal("wanted one settle receipt", count) - } - off := "off" - b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}) - ev.Job = 7 - ev.ID = "off" - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("off job event woke") - } -} - -func TestWakeF_CostIsRecordedAndAddedToAllSpendResults(t *testing.T) { - for _, mode := range []string{"known", "unknown", "unreadable"} { - t.Run(mode, func(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - return api.Queue{Jobs: []api.Job{{ID: 7, State: "done", Cost: 2}}}, nil - } - w := workstreamCreateForTest(t, b, "Spend", workstreamJobRef("7")) - before, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || before.Workstream.SpendUSD == nil { - t.Fatal(before, err) - } - b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - a := session.WorkstreamWakeAnswer{Text: `{"summary":"Review","items":[]}`, CostUSD: workstreamPtr(.04)} - if mode == "unknown" { - a.CostUSD = nil - } - if mode == "unreadable" { - a.Text = "not JSON" - } - return a, nil - } - if _, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}); !woke { - t.Fatal("did not wake") - } - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - after, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - list, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil { - t.Fatal(err) - } - want := *before.Workstream.SpendUSD - if mode != "unknown" { - want += .04 - } - if after.Workstream.SpendUSD == nil || *after.Workstream.SpendUSD != want || list.Workstreams[0].SpendUSD == nil || *list.Workstreams[0].SpendUSD != want { - t.Fatal("spend drift", after, list) - } - if mode == "unknown" { - if runs[0].CostUSD != nil { - t.Fatal("fabricated cost") - } - } else if runs[0].CostUSD == nil || *runs[0].CostUSD != .04 { - t.Fatal("cost lost") - } - if mode == "unreadable" && (runs[0].State != "failed" || workstreamValue(runs[0].Error) != workstream.WakeAnswerError) { - t.Fatal(runs) - } - }) - } -} - -func TestWakeG_WireActionsSendOneReceiptPerChange(t *testing.T) { - b, rec, now, tasks, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Actions", workstreamJobRef("7")) - id, _ := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "failed", At: *now}) - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - item := runs[0].Items[0].ID - events := func() int { return len(workstreamEvents(rec)) } - before := events() - first, err := b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id, ItemID: &item}) - if err != nil || events() != before+1 { - t.Fatal(first, err) - } - again, err := b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id, ItemID: &item}) - if err != nil || events() != before+1 || !reflect.DeepEqual(first, again) { - t.Fatal("repeated apply wrote") - } - if _, err := b.WorkstreamWakeNotNow(appwire.WorkstreamWakeNotNowParams{ID: id}); err != nil || events() != before+2 { - t.Fatal(err) - } - b.WorkstreamWakeNotNow(appwire.WorkstreamWakeNotNowParams{ID: id}) - if events() != before+2 { - t.Fatal("repeated dismissal wrote") - } - if _, err := b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: &item}); err != nil || events() != before+3 { - t.Fatal(err) - } - b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: &item}) - if events() != before+3 { - t.Fatal("repeated undo wrote") - } - _, err = b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: "missing"}) - workstreamCheckError(t, err, appwire.CodeNotFound) - _, err = b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id, ItemID: workstreamPtr("missing")}) - workstreamCheckError(t, err, appwire.CodeNotFound) - b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) - _, err = b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeConflict) - _, err = b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id}) - workstreamCheckError(t, err, appwire.CodeConflict) -} - -func TestWakeH_DoneAndOffAreRereadAtSettlement(t *testing.T) { - for _, choice := range []string{"done", "off"} { - t.Run(choice, func(t *testing.T) { - b, _, now, tasks, turns := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Finish", workstreamJobRef("7")) - small := "small" - b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &small, Wake: &appwire.WorkstreamWake{Morning: true}}}) - b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - *turns++ - if choice == "done" { - _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: w.ID}) - if err != nil { - t.Fatal(err) - } - } else { - off := "off" - _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &off}}) - if err != nil { - t.Fatal(err) - } - } - return session.WorkstreamWakeAnswer{Text: `{"summary":"Follow up","items":[{"kind":"draft","title":"Follow up","budget_usd":3},{"kind":"page","title":"Notes","markdown":"Next"}]}`}, nil - } - b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "needs_you", At: *now}) - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - for _, i := range runs[0].Items { - if i.State != "proposed" { - t.Fatal(i) - } - } - *now = now.AddDate(0, 0, 1) - b.workstreamWakeTick(*now) - if _, woke := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}); woke || len(*tasks) != 0 || *turns != 1 { - t.Fatal("stopped workstream woke") - } - }) - } -} - -func TestWakeE_NoFactoryActionsAndPersonPageIsUntouched(t *testing.T) { - for _, autonomy := range []string{"propose", "small"} { - t.Run(autonomy, func(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Pages", workstreamJobRef("7")) - queueReads, jobReads := 0, 0 - b.workstreams.queue = func(context.Context, string) (api.Queue, error) { - queueReads++ - return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil - } - b.workstreams.job = func(context.Context, string, int) (api.Job, error) { jobReads++; return api.Job{}, nil } - b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Autonomy: &autonomy}}) - pages, _ := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID}) - person, _ := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: pages.Pages[0].ID}) - b.workstreams.wakeTurn = func(context.Context, session.WorkstreamWakeAsk) (session.WorkstreamWakeAnswer, error) { - raw, _ := json.Marshal(map[string]any{"summary": "Next", "items": []any{map[string]any{"kind": "page", "page_id": person.Page.ID, "markdown": "Replace"}, map[string]any{"kind": "draft", "title": "Follow up", "budget_usd": 3}}}) - return session.WorkstreamWakeAnswer{Text: string(raw)}, nil - } - id, _ := b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - if len(runs[0].Items) != 1 || runs[0].Items[0].Kind != "draft" { - t.Fatal(runs) - } - b.WorkstreamWakeApply(appwire.WorkstreamWakeApplyParams{ID: id}) - b.WorkstreamWakeUndo(appwire.WorkstreamWakeUndoParams{ID: id}) - after, _ := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: person.Page.ID}) - if !reflect.DeepEqual(person, after) { - t.Fatal("person page changed") - } - // These seams are read-only Factory doors. No create/start door is reachable here. - if queueReads == 0 || jobReads != 0 { - t.Fatal("unexpected Factory door use", queueReads, jobReads) - } - }) - } -} - -func TestWakeB_ConcurrentDuplicateClaimsHaveOneTurn(t *testing.T) { - b, _, now, _, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Race", workstreamJobRef("7")) - var mu sync.Mutex - tasks := []workstreamWakeTask{} - b.workstreams.wakeDispatch = func(task workstreamWakeTask) { mu.Lock(); defer mu.Unlock(); tasks = append(tasks, task) } - var wg sync.WaitGroup - for range 8 { - wg.Add(1) - guard.Go("app/workstream-wake-claim-test", func() { - defer wg.Done() - b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "merged", ID: "one", At: *now}) - }) - } - wg.Wait() - if len(tasks) != 1 { - t.Fatal("multiple claims", len(tasks)) - } - workstreamDrainWakes(b, &tasks) - if runs := workstreamWakeListForTest(t, b, w.ID); len(runs) != 1 { - t.Fatal(runs) - } -} - -func TestWakeB_EventFallbackKeyUsesUTCSecondsAndValidatesWords(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Keys", workstreamJobRef("7")) - ev := workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: now.Add(100 * time.Millisecond)} - if _, woke := b.WorkstreamJobEvent(ev); !woke { - t.Fatal("first did not wake") - } - ev.At = ev.At.UTC().Add(200 * time.Millisecond) - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("same UTC second was not deduplicated") - } - for _, kind := range []string{"unknown", "morning"} { - ev.Kind = kind - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("invalid kind woke") - } - } - ev.Kind = "failed" - ev.Label = string(make([]byte, 201)) - if _, woke := b.WorkstreamJobEvent(ev); woke { - t.Fatal("oversize label woke") - } - workstreamDrainWakes(b, tasks) - if len(workstreamWakeListForTest(t, b, w.ID)) != 1 { - t.Fatal("invalid event made a row") - } -} - -func TestWakeA_LatestSlotIncludesExactEight(t *testing.T) { - now := time.Date(2026, 11, 3, 8, 0, 0, 0, time.Local) - for _, tc := range []struct{ at, want time.Time }{{now, now}, {now.Add(7 * time.Hour), now}, {now.Add(-time.Minute), time.Time{}}} { - slot, err := workstreamMorningSlot(tc.at) - if err != nil || !slot.Equal(tc.want) { - t.Fatalf("%s: slot %s want %s (%v)", tc.at, slot, tc.want, err) - } - } -} - -func TestWakeA_M3_PreEightOpeningNeverCatchesUpClosedDay(t *testing.T) { - b, _, now, tasks, turns := workstreamWakeBackend(t) - *now = time.Date(2026, 11, 1, 7, 59, 0, 0, time.Local) - w := workstreamCreateForTest(t, b, "Closed day") - if _, err := b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: w.ID, Patch: appwire.WorkstreamPatch{Wake: &appwire.WorkstreamWake{Morning: true}}}); err != nil { - t.Fatal(err) - } - // No tick happens on the intervening day while the app is closed. - *now = time.Date(2026, 11, 3, 7, 0, 0, 0, time.Local) - b.workstreamWakeTick(*now) - if len(*tasks) != 0 || *turns != 0 { - t.Fatal("opening before 08:00 queued a closed day's wake") - } - if runs := workstreamWakeListForTest(t, b, w.ID); len(runs) != 0 { - t.Fatalf("opening before 08:00 created a wake row: %+v", runs) - } - *now = time.Date(2026, 11, 3, 8, 0, 0, 0, time.Local) - b.workstreamWakeTick(*now) - if len(*tasks) != 1 { - t.Fatalf("08:00 queued %d wakes, want one", len(*tasks)) - } - workstreamDrainWakes(b, tasks) - b.workstreamWakeTick(*now) - if *turns != 1 || len(*tasks) != 0 { - t.Fatalf("today did not wake exactly once: %d turns, %d queued", *turns, len(*tasks)) - } - s, err := b.workstreamStore(false) - if err != nil || s == nil { - t.Fatalf("store: %v", err) - } - runs, err := s.Wakes(context.Background(), w.ID, 100) - if err != nil || len(runs) != 1 || runs[0].Event.Kind != "morning" || !runs[0].Event.At.Equal(*now) || !runs[0].StartedAt.Equal(*now) || runs[0].State != "settled" { - t.Fatalf("expected only today's 08:00 wake: %+v %v", runs, err) - } -} - -func TestWakeF_RecordedWakeDoesNotInventUnknownSpend(t *testing.T) { - b, _, now, tasks, _ := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Unknown spend", workstreamJobRef("7")) - b.workstreams.spend = func(string) *float64 { return nil } - b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) - workstreamDrainWakes(b, tasks) - got, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) - if err != nil || got.Workstream.SpendUSD != nil { - t.Fatal(got, err) - } - listed, err := b.WorkstreamList(appwire.WorkstreamListParams{}) - if err != nil || listed.Workstreams[0].SpendUSD != nil { - t.Fatal(listed, err) - } - if runs := workstreamWakeListForTest(t, b, w.ID); runs[0].CostUSD == nil || *runs[0].CostUSD != .04 { - t.Fatal(runs) - } -} - -func TestWakeB_ExpiredQueuedClaimNeverCallsTheModel(t *testing.T) { - b, _, now, tasks, turns := workstreamWakeBackend(t) - w := workstreamCreateForTest(t, b, "Stopped", workstreamJobRef("7")) - b.WorkstreamJobEvent(workstreamJobEvent{Factory: "f", Job: 7, Kind: "finished", At: *now}) - *now = now.Add(workstream.WakeAbandonedAfter + time.Millisecond) - workstreamDrainWakes(b, tasks) - runs := workstreamWakeListForTest(t, b, w.ID) - if *turns != 0 || runs[0].State != "failed" || workstreamValue(runs[0].Error) != workstream.WakeStoppedError || runs[0].CostUSD != nil { - t.Fatal(runs, *turns) - } -} diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index d5eab92341..2197ff6065 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -718,8 +718,6 @@ func v3EmptySession(dir string) bool { return v3EmptyUnassignedSession(dir) } -var v3WorkstreamKeeps = workstreamKeepsConversation - func v3EmptyUnassignedSession(dir string) bool { place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) @@ -733,7 +731,7 @@ func v3EmptyUnassignedSession(dir string) bool { return false } } - return !v3WorkstreamKeeps(dir) && !collectionKeepsConversation(dir) + return !collectionKeepsConversation(dir) } // v3ReapEmpty removes the empty session folders a launch left behind, except diff --git a/cmd/codeaf/chatv3_layout_stab_test.go b/cmd/codeaf/chatv3_layout_stab_test.go deleted file mode 100644 index aa0ae2c616..0000000000 --- a/cmd/codeaf/chatv3_layout_stab_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "reflect" - "testing" - "time" -) - -// E-M3-a: a spoken transcript proves the folder must stay without consulting -// the workstream owner, even when its last-spoken metadata is absent. -func TestStabEM3SpokenSessionsSkipWorkstreamOwnership(t *testing.T) { - dir := writeV3Session(t, t.TempDir(), "spoken", "Keep this conversation", time.Time{}) - previous := v3WorkstreamKeeps - t.Cleanup(func() { v3WorkstreamKeeps = previous }) - lookups := 0 - v3WorkstreamKeeps = func(string) bool { - lookups++ - return false - } - if v3EmptyUnassignedSession(dir) { - t.Fatal("a spoken conversation was considered empty") - } - if lookups != 0 { - t.Fatalf("spoken conversation made %d ownership lookups, want 0", lookups) - } -} - -// E-M3-b: only a provably empty folder needs an ownership lookup, and a kept -// answer must prevent its reuse or removal. -func TestStabEM3EmptySessionsCheckWorkstreamOwnershipOnce(t *testing.T) { - dir := writeV3Session(t, t.TempDir(), "empty", "", time.Time{}) - for _, tc := range []struct { - name string - kept bool - empty bool - }{ - {"kept", true, false}, - {"unowned", false, true}, - } { - t.Run(tc.name, func(t *testing.T) { - previous := v3WorkstreamKeeps - t.Cleanup(func() { v3WorkstreamKeeps = previous }) - lookups := 0 - v3WorkstreamKeeps = func(got string) bool { - lookups++ - if got != dir { - t.Errorf("ownership lookup for %q, want %q", got, dir) - } - return tc.kept - } - if empty := v3EmptyUnassignedSession(dir); empty != tc.empty { - t.Fatalf("empty = %v, want %v when kept = %v", empty, tc.empty, tc.kept) - } - if lookups != 1 { - t.Fatalf("empty conversation made %d ownership lookups, want 1", lookups) - } - }) - } -} - -// E-M3-c: scanning several spoken folders and one empty folder asks about -// workstream ownership only for the empty candidate. -func TestStabEM3BucketScanOnlyLooksUpEmptyCandidates(t *testing.T) { - t.Setenv("CODEAF_HOME", t.TempDir()) - t.Setenv("CODEAF_PROFILE_DIR", t.TempDir()) - bucket := t.TempDir() - for _, id := range []string{"00000000000000a1", "00000000000000a2", "00000000000000a3"} { - writeV3Session(t, bucket, id, "Keep this conversation", time.Time{}) - } - dir := writeV3Session(t, bucket, "00000000000000a4", "", time.Time{}) - previous := v3WorkstreamKeeps - t.Cleanup(func() { v3WorkstreamKeeps = previous }) - var lookups []string - v3WorkstreamKeeps = func(dir string) bool { - lookups = append(lookups, dir) - return false - } - spoken, empty := v3ScanBucket(bucket) - if len(spoken) != 3 || len(empty) != 1 || empty[0].dir != dir { - t.Fatalf("bucket scan returned spoken %v, empty %v", spoken, empty) - } - if !reflect.DeepEqual(lookups, []string{dir}) { - t.Fatalf("ownership lookups = %v, want only %q", lookups, dir) - } -} diff --git a/cmd/codeaf/collections.go b/cmd/codeaf/collections.go index e77b1e9299..f0afef48ba 100644 --- a/cmd/codeaf/collections.go +++ b/cmd/codeaf/collections.go @@ -122,11 +122,7 @@ func runCollectionsTo(args []string, output io.Writer) error { result, err = store.Create(ctx, rest[1]) case "rename": err = store.Rename(ctx, rest[1], rest[2]) - if err == nil { - var summary workspace.Summary - summary, err = store.Describe(ctx, rest[1]) - result = summary.Collection - } + result = workspace.Collection{ID: rest[1], Name: rest[2]} case "duplicate": result, err = store.Duplicate(ctx, rest[1], rest[2]) case "delete": diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 829b3bd308..c13491c837 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1152,7 +1152,6 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // `factory_floor`, now that the hello has settled which conversation this // is: on the foreman's belt and no other (factory_foreman.go). cfg.Floor = foremanFloorDoor(floorStore, workspace, cfg.SessionFile) - cfg.Workstream = appWorkstreamAgentDoor(cfg.SessionFile) // THE THREE ROAD-DEPENDENT CAPABILITIES ARE DECIDED IN ONE PLACE, and this // door no longer keeps its own answer to any of them (chatv3_lanes.go). Each diff --git a/cmd/codeaf/testdata/model_spend_owners.tsv b/cmd/codeaf/testdata/model_spend_owners.tsv index 3eb9f26fdd..0d4c27a918 100644 --- a/cmd/codeaf/testdata/model_spend_owners.tsv +++ b/cmd/codeaf/testdata/model_spend_owners.tsv @@ -76,7 +76,6 @@ internal/session/tools_music.go:Agent.composeMusic:GenerateMusic exception: musi internal/session/tools_sense.go:Agent.audioSense:Transcribe exception: speech internal/session/tools_speak.go:Agent.speakTool:Speak exception: speech internal/session/tools_video.go:Agent.renderVideo:GenerateVideo exception: video -internal/session/workstream_wake.go:workstreamWakeCall:CompleteWithMessages owner: Workstream wake (workstreamWakeCall -> session.RecordUsage; its cost_usd counts in the workstream spend) internal/shaped/prose.go:Prose:CompleteWithMessages exception: untraced shaped helpers internal/shaped/shaped.go:Answer:CompleteWithMessages exception: untraced shaped helpers internal/shaped/shaped.go:repair:CompleteWithMessages exception: untraced shaped helpers diff --git a/cmd/codeaf/testdata/workstreams-wire/README.md b/cmd/codeaf/testdata/workstreams-wire/README.md deleted file mode 100644 index 29bc64a7b9..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# workstreams-wire - -The examples the desk's contract carries; regenerate when the contract changes: - - python3 -I extract.py . - -`contract.json` holds the method index, the event, the error codes, the enums and every -field table (type, required). Each other file holds one method's or event's examples. -`appworkstreams_conformance_test.go` sends the examples through the wire handler and -compares each answer's shape with them. Values may differ; shapes may not. diff --git a/cmd/codeaf/testdata/workstreams-wire/collections.add.json b/cmd/codeaf/testdata/workstreams-wire/collections.add.json deleted file mode 100644 index 767a4e61ed..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/collections.add.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "method": "collections.add", - "examples": { - "params": [ - { - "id": "ws_8f2c1a", - "refs": [ - { - "kind": "factory_job", - "id": "1107", - "factory": "f_local" - } - ] - } - ], - "result": [ - { - "added": [ - { - "kind": "factory_job", - "id": "1107", - "factory": "f_local", - "added_at": "2026-11-02T14:05:00Z" - } - ], - "already": [] - } - ], - "error": [ - { - "code": "in_other_workstream", - "message": "that job belongs to another workstream", - "detail": { - "factory": "f_local", - "job": 1107, - "workstream_id": "ws_92b7", - "workstream_name": "Account migration" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/contract.json b/cmd/codeaf/testdata/workstreams-wire/contract.json deleted file mode 100644 index 59f93b1a4e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/contract.json +++ /dev/null @@ -1,1865 +0,0 @@ -{ - "methods": [ - "workstream.list", - "workstream.get", - "workstream.create", - "workstream.update", - "workstream.mark_done", - "workstream.reopen", - "workstream.plan_get", - "workstream.plan_set", - "page.list", - "page.get", - "page.save", - "page.create", - "page.update", - "page.move", - "page.delete", - "page.history", - "page.restore", - "record.list", - "record.propose", - "record.keep", - "record.drop", - "record.supersede", - "goahead.list", - "goahead.grant", - "goahead.revoke", - "wake.list", - "wake.apply", - "wake.undo", - "wake.not_now", - "suggestion.list", - "suggestion.keep", - "suggestion.drop", - "source.list", - "source.add", - "source.update", - "source.remove" - ], - "events": [ - { - "event": "workstream.changed", - "data_type": "WorkstreamChanged" - } - ], - "errors": [ - { - "code": "bad_params", - "desk_local": false, - "detail_type": null - }, - { - "code": "not_found", - "desk_local": false, - "detail_type": null - }, - { - "code": "conflict", - "desk_local": false, - "detail_type": null - }, - { - "code": "in_other_workstream", - "desk_local": false, - "detail_type": "WorkstreamTaken" - }, - { - "code": "version_conflict", - "desk_local": false, - "detail_type": "WorkstreamVersionConflict" - }, - { - "code": "cycle", - "desk_local": false, - "detail_type": null - }, - { - "code": "too_large", - "desk_local": false, - "detail_type": null - }, - { - "code": "busy", - "desk_local": false, - "detail_type": null - }, - { - "code": "unknown_method", - "desk_local": false, - "detail_type": null - }, - { - "code": "unsupported", - "desk_local": true, - "detail_type": null - } - ], - "enums": { - "WorkstreamStage": [ - "shaping", - "building", - "done" - ], - "WorkstreamAutonomy": [ - "propose", - "small", - "off" - ], - "WorkstreamActor": [ - "you", - "agent" - ], - "WorkstreamRefKind": [ - "collection", - "conversation", - "task", - "artifact", - "crew", - "process", - "factory_job" - ], - "WorkstreamSourceKind": [ - "chat", - "message", - "factory_job", - "page", - "url", - "suggestion" - ], - "WorkstreamPlanItemKind": [ - "job", - "draft" - ], - "WorkstreamDraftSize": [ - "S", - "M", - "L" - ], - "WorkstreamRecordKind": [ - "decision", - "question", - "goahead" - ], - "WorkstreamRecordState": [ - "proposed", - "kept", - "dropped", - "superseded" - ], - "WorkstreamGoAheadScope": [ - "job", - "wave", - "milestone" - ], - "WorkstreamGoAheadState": [ - "active", - "ended" - ], - "WorkstreamGoAheadEndReason": [ - "expired", - "revoked", - "workstream_done" - ], - "WorkstreamChangedPart": [ - "workstream", - "plan", - "pages", - "records", - "goaheads", - "jobs", - "wakes", - "sources" - ], - "WorkstreamWakeKind": [ - "morning", - "finished", - "merged", - "failed", - "needs_you" - ], - "WorkstreamWakeRunState": [ - "running", - "settled", - "failed" - ], - "WorkstreamWakeItemKind": [ - "move", - "draft", - "page", - "decision", - "question" - ], - "WorkstreamWakeItemState": [ - "applied", - "undone", - "proposed", - "dismissed" - ], - "WorkstreamSuggestionEditState": [ - "proposed", - "kept", - "dropped" - ], - "WorkstreamSourceItemKind": [ - "file", - "repo", - "issue", - "link", - "kept" - ], - "WorkstreamKeptKind": [ - "record", - "page", - "message", - "factory_job" - ] - }, - "types": { - "WorkstreamWake": { - "morning": { - "type": "boolean", - "required": true - }, - "related_issues": { - "type": "boolean", - "required": true - } - }, - "WorkstreamRef": { - "kind": { - "type": "WorkstreamRefKind", - "required": true - }, - "id": { - "type": "string", - "required": true - }, - "session_id": { - "type": "string", - "required": false - }, - "factory": { - "type": "string", - "required": false - } - }, - "WorkstreamSource": { - "kind": { - "type": "WorkstreamSourceKind", - "required": true - }, - "id": { - "type": "string", - "required": true - }, - "chat_id": { - "type": "string", - "required": false - }, - "factory": { - "type": "string", - "required": false - }, - "label": { - "type": "string", - "required": false - } - }, - "WorkstreamRow": { - "id": { - "type": "string", - "required": true - }, - "name": { - "type": "string", - "required": true - }, - "stage": { - "type": "WorkstreamStage", - "required": true - }, - "total": { - "type": "integer", - "required": true - }, - "merged": { - "type": "integer", - "required": false - }, - "needs_you": { - "type": "integer", - "required": false - }, - "spend_usd": { - "type": "number (USD)", - "required": false - }, - "budget_usd": { - "type": "number (USD)", - "required": false - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "agent_id": { - "type": "string", - "required": true - }, - "origin_chat_id": { - "type": "string", - "required": false - } - }, - "Workstream": { - "id": { - "type": "string", - "required": true - }, - "name": { - "type": "string", - "required": true - }, - "why": { - "type": "string", - "required": true - }, - "stage": { - "type": "WorkstreamStage", - "required": true - }, - "agent_id": { - "type": "string", - "required": true - }, - "origin_chat_id": { - "type": "string", - "required": false - }, - "autonomy": { - "type": "WorkstreamAutonomy", - "required": true - }, - "wake": { - "type": "WorkstreamWake", - "required": true - }, - "budget_usd": { - "type": "number (USD)", - "required": false - }, - "created_at": { - "type": "string (RFC 3339)", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "started_at": { - "type": "string (RFC 3339)", - "required": false - }, - "done_at": { - "type": "string (RFC 3339)", - "required": false - }, - "how_it_went": { - "type": "string", - "required": false - }, - "total": { - "type": "integer", - "required": true - }, - "merged": { - "type": "integer", - "required": false - }, - "needs_you": { - "type": "integer", - "required": false - }, - "spend_usd": { - "type": "number (USD)", - "required": false - } - }, - "WorkstreamPlan": { - "workstream_id": { - "type": "string", - "required": true - }, - "version": { - "type": "integer", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": false - }, - "milestones": { - "type": "WorkstreamMilestone[]", - "required": true - }, - "waves": { - "type": "WorkstreamWave[]", - "required": true - } - }, - "WorkstreamMilestone": { - "id": { - "type": "string", - "required": true - }, - "title": { - "type": "string", - "required": true - } - }, - "WorkstreamWave": { - "n": { - "type": "integer", - "required": true - }, - "title": { - "type": "string", - "required": false - }, - "milestone_id": { - "type": "string", - "required": false - }, - "auto_start": { - "type": "boolean", - "required": true - }, - "items": { - "type": "WorkstreamPlanItem[]", - "required": true - } - }, - "WorkstreamPlanItem": { - "kind": { - "type": "WorkstreamPlanItemKind", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "draft_id": { - "type": "string", - "required": false - }, - "title": { - "type": "string", - "required": false - }, - "repo": { - "type": "string", - "required": false - }, - "text": { - "type": "string", - "required": false - }, - "size": { - "type": "WorkstreamDraftSize", - "required": false - }, - "budget_usd": { - "type": "number (USD)", - "required": false - }, - "source": { - "type": "WorkstreamSource", - "required": false - } - }, - "WorkstreamPageNode": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "parent_id": { - "type": "string", - "required": false - }, - "title": { - "type": "string", - "required": true - }, - "owner": { - "type": "WorkstreamActor", - "required": true - }, - "read_by_jobs": { - "type": "boolean", - "required": true - }, - "position": { - "type": "integer", - "required": true - }, - "version": { - "type": "integer", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "updated_by": { - "type": "WorkstreamActor", - "required": true - }, - "updated_from": { - "type": "WorkstreamSource", - "required": false - }, - "deleted_at": { - "type": "string (RFC 3339)", - "required": false - } - }, - "WorkstreamPage": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "parent_id": { - "type": "string", - "required": false - }, - "title": { - "type": "string", - "required": true - }, - "owner": { - "type": "WorkstreamActor", - "required": true - }, - "read_by_jobs": { - "type": "boolean", - "required": true - }, - "position": { - "type": "integer", - "required": true - }, - "version": { - "type": "integer", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "updated_by": { - "type": "WorkstreamActor", - "required": true - }, - "updated_from": { - "type": "WorkstreamSource", - "required": false - }, - "deleted_at": { - "type": "string (RFC 3339)", - "required": false - }, - "doc": { - "type": "object", - "required": true - }, - "markdown": { - "type": "string", - "required": false - } - }, - "WorkstreamPageVersion": { - "version": { - "type": "integer", - "required": true - }, - "saved_at": { - "type": "string (RFC 3339)", - "required": true - }, - "saved_by": { - "type": "WorkstreamActor", - "required": true - }, - "from": { - "type": "WorkstreamSource", - "required": false - }, - "title": { - "type": "string", - "required": true - }, - "bytes": { - "type": "integer", - "required": true - } - }, - "WorkstreamRecord": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamRecordKind", - "required": true - }, - "text": { - "type": "string", - "required": true - }, - "state": { - "type": "WorkstreamRecordState", - "required": true - }, - "by": { - "type": "WorkstreamActor", - "required": true - }, - "source": { - "type": "WorkstreamSource", - "required": false - }, - "created_at": { - "type": "string (RFC 3339)", - "required": true - }, - "decided_at": { - "type": "string (RFC 3339)", - "required": false - }, - "supersedes": { - "type": "string", - "required": false - }, - "superseded_by": { - "type": "string", - "required": false - }, - "goahead": { - "type": "WorkstreamGoAheadProposal", - "required": false - }, - "goahead_id": { - "type": "string", - "required": false - } - }, - "WorkstreamGoAhead": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "scope": { - "type": "WorkstreamGoAheadScope", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "wave": { - "type": "integer", - "required": false - }, - "milestone_id": { - "type": "string", - "required": false - }, - "cap_usd": { - "type": "number (USD)", - "required": true - }, - "ends_at": { - "type": "string (RFC 3339)", - "required": true - }, - "state": { - "type": "WorkstreamGoAheadState", - "required": true - }, - "end_reason": { - "type": "WorkstreamGoAheadEndReason", - "required": false - }, - "spent_usd": { - "type": "number (USD)", - "required": false - }, - "granted_at": { - "type": "string (RFC 3339)", - "required": true - }, - "ended_at": { - "type": "string (RFC 3339)", - "required": false - } - }, - "WorkstreamGoAheadProposal": { - "scope": { - "type": "WorkstreamGoAheadScope", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "wave": { - "type": "integer", - "required": false - }, - "milestone_id": { - "type": "string", - "required": false - }, - "cap_usd": { - "type": "number (USD)", - "required": true - }, - "ends_at": { - "type": "string (RFC 3339)", - "required": true - } - }, - "WorkstreamSuggestion": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "page_id": { - "type": "string", - "required": true - }, - "base_version": { - "type": "integer", - "required": true - }, - "why": { - "type": "string", - "required": true - }, - "by": { - "type": "WorkstreamActor", - "required": true - }, - "source": { - "type": "WorkstreamSource", - "required": false - }, - "created_at": { - "type": "string (RFC 3339)", - "required": true - }, - "edits": { - "type": "WorkstreamSuggestionEdit[]", - "required": true - } - }, - "WorkstreamSuggestionEdit": { - "id": { - "type": "string", - "required": true - }, - "block": { - "type": "integer[]", - "required": true - }, - "offset": { - "type": "integer", - "required": true - }, - "delete": { - "type": "string", - "required": true - }, - "insert": { - "type": "string", - "required": true - }, - "block_text": { - "type": "string", - "required": true - }, - "why": { - "type": "string", - "required": true - }, - "state": { - "type": "WorkstreamSuggestionEditState", - "required": true - }, - "applies": { - "type": "boolean", - "required": false - }, - "decided_at": { - "type": "string (RFC 3339)", - "required": false - } - }, - "WorkstreamChanged": { - "id": { - "type": "string", - "required": true - }, - "parts": { - "type": "WorkstreamChangedPart[]", - "required": false - } - }, - "WorkstreamTaken": { - "factory": { - "type": "string", - "required": true - }, - "job": { - "type": "integer", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "workstream_name": { - "type": "string", - "required": true - } - }, - "WorkstreamVersionConflict": { - "id": { - "type": "string", - "required": true - }, - "version": { - "type": "integer", - "required": true - }, - "updated_at": { - "type": "string (RFC 3339)", - "required": true - }, - "updated_by": { - "type": "WorkstreamActor", - "required": true - } - }, - "WorkstreamPatch": { - "name": { - "type": "string", - "required": false - }, - "why": { - "type": "string", - "required": false - }, - "autonomy": { - "type": "WorkstreamAutonomy", - "required": false - }, - "wake": { - "type": "WorkstreamWake", - "required": false - }, - "budget_usd": { - "type": "number (USD)", - "required": false - } - }, - "WorkstreamWakeEvent": { - "kind": { - "type": "WorkstreamWakeKind", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "label": { - "type": "string", - "required": false - } - }, - "WorkstreamWakeItem": { - "id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamWakeItemKind", - "required": true - }, - "state": { - "type": "WorkstreamWakeItemState", - "required": true - }, - "text": { - "type": "string", - "required": true - }, - "why": { - "type": "string", - "required": false - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "draft_id": { - "type": "string", - "required": false - }, - "wave": { - "type": "integer", - "required": false - }, - "page_id": { - "type": "string", - "required": false - }, - "record_id": { - "type": "string", - "required": false - }, - "budget_usd": { - "type": "number (USD)", - "required": false - }, - "note": { - "type": "string", - "required": false - }, - "applied_at": { - "type": "string (RFC 3339)", - "required": false - }, - "undone_at": { - "type": "string (RFC 3339)", - "required": false - } - }, - "WorkstreamWakeRun": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "event": { - "type": "WorkstreamWakeEvent", - "required": true - }, - "state": { - "type": "WorkstreamWakeRunState", - "required": true - }, - "started_at": { - "type": "string (RFC 3339)", - "required": true - }, - "settled_at": { - "type": "string (RFC 3339)", - "required": false - }, - "cost_usd": { - "type": "number (USD)", - "required": false - }, - "summary": { - "type": "string", - "required": false - }, - "error": { - "type": "string", - "required": false - }, - "dismissed_at": { - "type": "string (RFC 3339)", - "required": false - }, - "items": { - "type": "WorkstreamWakeItem[]", - "required": true - } - }, - "WorkstreamKeptRef": { - "kind": { - "type": "WorkstreamKeptKind", - "required": true - }, - "id": { - "type": "string", - "required": true - }, - "chat_id": { - "type": "string", - "required": false - }, - "factory": { - "type": "string", - "required": false - } - }, - "WorkstreamSourceAdd": { - "kind": { - "type": "WorkstreamSourceItemKind", - "required": true - }, - "path": { - "type": "string", - "required": false - }, - "repo": { - "type": "string", - "required": false - }, - "number": { - "type": "integer", - "required": false - }, - "url": { - "type": "string", - "required": false - }, - "ref": { - "type": "WorkstreamKeptRef", - "required": false - }, - "title": { - "type": "string", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - } - }, - "WorkstreamSourceItem": { - "id": { - "type": "string", - "required": true - }, - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamSourceItemKind", - "required": true - }, - "title": { - "type": "string", - "required": true - }, - "path": { - "type": "string", - "required": false - }, - "repo": { - "type": "string", - "required": false - }, - "number": { - "type": "integer", - "required": false - }, - "url": { - "type": "string", - "required": false - }, - "ref": { - "type": "WorkstreamKeptRef", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": true - }, - "proposed": { - "type": "boolean", - "required": true - }, - "why": { - "type": "string", - "required": false - }, - "added_by": { - "type": "WorkstreamActor", - "required": true - }, - "added_at": { - "type": "string (RFC 3339)", - "required": true - }, - "gone": { - "type": "boolean", - "required": false - } - }, - "WorkstreamListParams": {}, - "WorkstreamListResult": { - "workstreams": { - "type": "WorkstreamRow[]", - "required": true - } - }, - "WorkstreamGetParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamGetResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamCreateParams": { - "name": { - "type": "string", - "required": true - }, - "why": { - "type": "string", - "required": false - }, - "origin_chat_id": { - "type": "string", - "required": false - }, - "refs": { - "type": "WorkstreamRef[]", - "required": false - }, - "model": { - "type": "string", - "required": false - }, - "client_id": { - "type": "string", - "required": false - }, - "sources": { - "type": "WorkstreamSourceAdd[]", - "required": false - } - }, - "WorkstreamCreateResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamUpdateParams": { - "id": { - "type": "string", - "required": true - }, - "patch": { - "type": "WorkstreamPatch", - "required": true - } - }, - "WorkstreamUpdateResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamMarkDoneParams": { - "id": { - "type": "string", - "required": true - }, - "how_it_went": { - "type": "string", - "required": false - }, - "archive_chats": { - "type": "boolean", - "required": false - } - }, - "WorkstreamMarkDoneResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamReopenParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamReopenResult": { - "workstream": { - "type": "Workstream", - "required": true - } - }, - "WorkstreamPlanGetParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamPlanGetResult": { - "plan": { - "type": "WorkstreamPlan", - "required": true - } - }, - "WorkstreamPlanSetParams": { - "id": { - "type": "string", - "required": true - }, - "base_version": { - "type": "integer", - "required": true - }, - "milestones": { - "type": "WorkstreamMilestone[]", - "required": true - }, - "waves": { - "type": "WorkstreamWave[]", - "required": true - } - }, - "WorkstreamPlanSetResult": { - "plan": { - "type": "WorkstreamPlan", - "required": true - } - }, - "WorkstreamPageListParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "include_deleted": { - "type": "boolean", - "required": false - } - }, - "WorkstreamPageListResult": { - "pages": { - "type": "WorkstreamPageNode[]", - "required": true - }, - "deleted": { - "type": "WorkstreamPageNode[]", - "required": false - } - }, - "WorkstreamPageGetParams": { - "id": { - "type": "string", - "required": true - }, - "markdown": { - "type": "boolean", - "required": false - }, - "version": { - "type": "integer", - "required": false - } - }, - "WorkstreamPageGetResult": { - "page": { - "type": "WorkstreamPage", - "required": true - } - }, - "WorkstreamPageSaveParams": { - "id": { - "type": "string", - "required": true - }, - "doc": { - "type": "object", - "required": true - }, - "base_version": { - "type": "integer", - "required": true - }, - "source": { - "type": "WorkstreamSource", - "required": false - } - }, - "WorkstreamPageSaveResult": { - "page": { - "type": "WorkstreamPage", - "required": true - } - }, - "WorkstreamPageCreateParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "parent_id": { - "type": "string", - "required": false - }, - "after_id": { - "type": "string", - "required": false - }, - "title": { - "type": "string", - "required": true - }, - "owner": { - "type": "WorkstreamActor", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - }, - "doc": { - "type": "object", - "required": false - }, - "source": { - "type": "WorkstreamSource", - "required": false - } - }, - "WorkstreamPageCreateResult": { - "page": { - "type": "WorkstreamPage", - "required": true - } - }, - "WorkstreamPageUpdateParams": { - "id": { - "type": "string", - "required": true - }, - "title": { - "type": "string", - "required": false - }, - "owner": { - "type": "WorkstreamActor", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - } - }, - "WorkstreamPageUpdateResult": { - "page": { - "type": "WorkstreamPageNode", - "required": true - } - }, - "WorkstreamPageMoveParams": { - "id": { - "type": "string", - "required": true - }, - "parent_id": { - "type": "string", - "required": false - }, - "after_id": { - "type": "string", - "required": false - } - }, - "WorkstreamPageMoveResult": { - "page": { - "type": "WorkstreamPageNode", - "required": true - } - }, - "WorkstreamPageDeleteParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamPageDeleteResult": { - "deleted": { - "type": "string[]", - "required": true - } - }, - "WorkstreamPageHistoryParams": { - "id": { - "type": "string", - "required": true - }, - "limit": { - "type": "integer", - "required": false - } - }, - "WorkstreamPageHistoryResult": { - "versions": { - "type": "WorkstreamPageVersion[]", - "required": true - } - }, - "WorkstreamPageRestoreParams": { - "id": { - "type": "string", - "required": true - }, - "version": { - "type": "integer", - "required": false - }, - "base_version": { - "type": "integer", - "required": false - } - }, - "WorkstreamPageRestoreResult": { - "page": { - "type": "WorkstreamPage", - "required": true - } - }, - "WorkstreamRecordListParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamRecordKind", - "required": false - }, - "state": { - "type": "WorkstreamRecordState", - "required": false - }, - "include_goaheads": { - "type": "boolean", - "required": false - } - }, - "WorkstreamRecordListResult": { - "records": { - "type": "WorkstreamRecord[]", - "required": true - } - }, - "WorkstreamRecordProposeParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamRecordKind", - "required": true - }, - "text": { - "type": "string", - "required": true - }, - "source": { - "type": "WorkstreamSource", - "required": false - }, - "kept": { - "type": "boolean", - "required": false - }, - "goahead": { - "type": "WorkstreamGoAheadProposal", - "required": false - } - }, - "WorkstreamRecordProposeResult": { - "record": { - "type": "WorkstreamRecord", - "required": true - } - }, - "WorkstreamRecordKeepParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamRecordKeepResult": { - "record": { - "type": "WorkstreamRecord", - "required": true - } - }, - "WorkstreamRecordDropParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamRecordDropResult": { - "record": { - "type": "WorkstreamRecord", - "required": true - } - }, - "WorkstreamRecordSupersedeParams": { - "id": { - "type": "string", - "required": true - }, - "text": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamRecordKind", - "required": false - }, - "source": { - "type": "WorkstreamSource", - "required": false - } - }, - "WorkstreamRecordSupersedeResult": { - "record": { - "type": "WorkstreamRecord", - "required": true - }, - "superseded": { - "type": "WorkstreamRecord", - "required": true - } - }, - "WorkstreamGoAheadListParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "ended": { - "type": "boolean", - "required": false - } - }, - "WorkstreamGoAheadListResult": { - "goaheads": { - "type": "WorkstreamGoAhead[]", - "required": true - } - }, - "WorkstreamGoAheadGrantParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "scope": { - "type": "WorkstreamGoAheadScope", - "required": true - }, - "factory": { - "type": "string", - "required": false - }, - "job": { - "type": "integer", - "required": false - }, - "wave": { - "type": "integer", - "required": false - }, - "milestone_id": { - "type": "string", - "required": false - }, - "cap_usd": { - "type": "number (USD)", - "required": true - }, - "ends_at": { - "type": "string (RFC 3339)", - "required": true - } - }, - "WorkstreamGoAheadGrantResult": { - "goahead": { - "type": "WorkstreamGoAhead", - "required": true - } - }, - "WorkstreamGoAheadRevokeParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamGoAheadRevokeResult": { - "goahead": { - "type": "WorkstreamGoAhead", - "required": true - } - }, - "WorkstreamWakeListParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "limit": { - "type": "integer", - "required": false - } - }, - "WorkstreamWakeListResult": { - "wakes": { - "type": "WorkstreamWakeRun[]", - "required": true - } - }, - "WorkstreamWakeApplyParams": { - "id": { - "type": "string", - "required": true - }, - "item_id": { - "type": "string", - "required": false - } - }, - "WorkstreamWakeApplyResult": { - "wake": { - "type": "WorkstreamWakeRun", - "required": true - } - }, - "WorkstreamWakeUndoParams": { - "id": { - "type": "string", - "required": true - }, - "item_id": { - "type": "string", - "required": false - } - }, - "WorkstreamWakeUndoResult": { - "wake": { - "type": "WorkstreamWakeRun", - "required": true - } - }, - "WorkstreamWakeNotNowParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamWakeNotNowResult": { - "wake": { - "type": "WorkstreamWakeRun", - "required": true - } - }, - "WorkstreamSuggestionListParams": { - "page_id": { - "type": "string", - "required": true - } - }, - "WorkstreamSuggestionListResult": { - "suggestions": { - "type": "WorkstreamSuggestion[]", - "required": true - } - }, - "WorkstreamSuggestionKeepParams": { - "id": { - "type": "string", - "required": true - }, - "edit_id": { - "type": "string", - "required": false - } - }, - "WorkstreamSuggestionKeepResult": { - "page": { - "type": "WorkstreamPage", - "required": true - }, - "suggestion": { - "type": "WorkstreamSuggestion", - "required": true - } - }, - "WorkstreamSuggestionDropParams": { - "id": { - "type": "string", - "required": true - }, - "edit_id": { - "type": "string", - "required": false - } - }, - "WorkstreamSuggestionDropResult": { - "suggestion": { - "type": "WorkstreamSuggestion", - "required": true - } - }, - "WorkstreamSourceListParams": { - "workstream_id": { - "type": "string", - "required": true - } - }, - "WorkstreamSourceListResult": { - "sources": { - "type": "WorkstreamSourceItem[]", - "required": true - } - }, - "WorkstreamSourceAddParams": { - "workstream_id": { - "type": "string", - "required": true - }, - "kind": { - "type": "WorkstreamSourceItemKind", - "required": true - }, - "path": { - "type": "string", - "required": false - }, - "repo": { - "type": "string", - "required": false - }, - "number": { - "type": "integer", - "required": false - }, - "url": { - "type": "string", - "required": false - }, - "ref": { - "type": "WorkstreamKeptRef", - "required": false - }, - "title": { - "type": "string", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - } - }, - "WorkstreamSourceAddResult": { - "source": { - "type": "WorkstreamSourceItem", - "required": true - }, - "already": { - "type": "boolean", - "required": true - } - }, - "WorkstreamSourceUpdateParams": { - "id": { - "type": "string", - "required": true - }, - "title": { - "type": "string", - "required": false - }, - "read_by_jobs": { - "type": "boolean", - "required": false - }, - "keep": { - "type": "boolean", - "required": false - } - }, - "WorkstreamSourceUpdateResult": { - "source": { - "type": "WorkstreamSourceItem", - "required": true - } - }, - "WorkstreamSourceRemoveParams": { - "id": { - "type": "string", - "required": true - } - }, - "WorkstreamSourceRemoveResult": { - "source": { - "type": "WorkstreamSourceItem", - "required": true - } - } - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/extract.py b/cmd/codeaf/testdata/workstreams-wire/extract.py deleted file mode 100644 index 546b10b286..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/extract.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Extract wire shapes (types, enums, methods, examples) from the workstreams wire contract. - -usage: python3 -I extract.py -Writes contract.json (index, enums, errors, types) and one .json per method/event -with its examples. No prose is copied. -""" -import json, os, re, sys - -src, out = sys.argv[1], sys.argv[2] -lines = open(src, encoding="utf-8").read().split("\n") -end = next(i for i, l in enumerate(lines) if l.startswith("## Page document format")) -lines = lines[:end] - -TICK = re.compile(r"`([^`]*)`") - -def table_at(i): - """rows of the markdown table starting at or after line i (skip blank lines).""" - while i < len(lines) and not lines[i].startswith("|"): - i += 1 - rows = [] - while i < len(lines) and lines[i].startswith("|"): - cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] - rows.append(cells) - i += 1 - return rows[2:] # drop header + separator - -def fields(rows): - out = {} - for cells in rows: - if cells[0] in ("—", "-"): - continue - name = TICK.fullmatch(cells[0]).group(1) - typ = TICK.fullmatch(cells[1]).group(1) - req = cells[2] - assert req in ("yes", "no"), cells - out[name] = {"type": typ, "required": req == "yes"} - return out - -types, enums, errors, methods, events = {}, {}, [], [], [] -method_info = {} -examples = {} -section = None # H2 -h3 = None -i = 0 -while i < len(lines): - l = lines[i] - if l.startswith("## "): - section, h3 = l[3:].strip(), None - elif l.startswith("### "): - h3 = TICK.search(l).group(1) - if section == "Shared shapes": - types[h3] = fields(table_at(i + 1)) - elif section == "Methods": - methods.append(h3) - method_info[h3] = {"method": h3, "errors": []} - elif section == "Shared shapes" and l.startswith("| type | words |"): - for cells in table_at(i): - enums[TICK.fullmatch(cells[0]).group(1)] = TICK.findall(cells[1]) - elif section == "Method index" and l.startswith("| event | data |"): - for cells in table_at(i): - events.append({"event": TICK.fullmatch(cells[0]).group(1), "data_type": TICK.fullmatch(cells[1]).group(1)}) - elif section == "Method index" and l.startswith("| method | params | result |"): - index = [TICK.fullmatch(c[0]).group(1) for c in table_at(i)] - elif section == "Error codes" and l.startswith("| code |"): - for cells in table_at(i): - m = re.match(r"`(\w+)`( \(desk-local\))?", cells[0]) - det = re.match(r"`(Workstream\w+)`", cells[2]) - errors.append({"code": m.group(1), "desk_local": bool(m.group(2)), "detail_type": det.group(1) if det else None}) - m = re.match(r"^(Params|Result) \(`(\w+)`\):", l) - if m and section == "Methods": - types[m.group(2)] = fields(table_at(i + 1)) - method_info[h3][m.group(1).lower() + "_type"] = m.group(2) - if l.startswith("Errors:") and section == "Methods": - method_info[h3]["errors"] = TICK.findall(l) - m = re.match(r"^```json (\S+) (params|result|error|data)$", l) - if m: - j = i + 1 - while lines[j] != "```": - j += 1 - body = json.loads("\n".join(lines[i + 1:j])) - examples.setdefault(m.group(1), {}).setdefault(m.group(2), []).append(body) - i = j - i += 1 - -assert index == methods, (index, methods) -os.makedirs(out, exist_ok=True) -contract = {"methods": methods, "events": events, "errors": errors, "enums": enums, "types": types} -def dump(path, v): - with open(os.path.join(out, path), "w", encoding="utf-8") as f: - json.dump(v, f, indent=2, ensure_ascii=False) - f.write("\n") -dump("contract.json", contract) -for name in methods: - info = dict(method_info[name]) - info["examples"] = examples.pop(name) - dump(name + ".json", info) -for ev in events: - dump(ev["event"] + ".json", {"event": ev["event"], "data_type": ev["data_type"], "examples": examples.pop(ev["event"])}) -# collection examples the doc carries for the factory_job kind -for name, ex in sorted(examples.items()): - dump(name + ".json", {"method": name, "examples": ex}) - print("extra:", name, sorted(ex)) -print(len(methods), "methods;", len(types), "types;", len(enums), "enums;", len(errors), "error codes") diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json b/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json deleted file mode 100644 index 08fd83ecc5..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/goahead.grant.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "method": "goahead.grant", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamGoAheadGrantParams", - "result_type": "WorkstreamGoAheadGrantResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "scope": "job", - "factory": "f_local", - "job": 1107, - "cap_usd": 40.0, - "ends_at": "2026-11-03T14:05:00Z" - } - ], - "result": [ - { - "goahead": { - "id": "ga_0c44", - "workstream_id": "ws_8f2c1a", - "scope": "job", - "factory": "f_local", - "job": 1107, - "cap_usd": 40.0, - "ends_at": "2026-11-03T14:05:00Z", - "state": "active", - "granted_at": "2026-11-02T14:05:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.list.json b/cmd/codeaf/testdata/workstreams-wire/goahead.list.json deleted file mode 100644 index 7a6fe79d3c..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/goahead.list.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "method": "goahead.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamGoAheadListParams", - "result_type": "WorkstreamGoAheadListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "ended": true - } - ], - "result": [ - { - "goaheads": [ - { - "id": "ga_0c44", - "workstream_id": "ws_8f2c1a", - "scope": "job", - "factory": "f_local", - "job": 1107, - "cap_usd": 40.0, - "ends_at": "2026-11-03T14:05:00Z", - "state": "active", - "granted_at": "2026-11-02T14:05:00Z" - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json b/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json deleted file mode 100644 index db4c6ac2b4..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/goahead.revoke.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "method": "goahead.revoke", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamGoAheadRevokeParams", - "result_type": "WorkstreamGoAheadRevokeResult", - "examples": { - "params": [ - { - "id": "ga_0c44" - } - ], - "result": [ - { - "goahead": { - "id": "ga_0c44", - "workstream_id": "ws_8f2c1a", - "scope": "job", - "factory": "f_local", - "job": 1107, - "cap_usd": 40.0, - "ends_at": "2026-11-03T14:05:00Z", - "state": "ended", - "granted_at": "2026-11-02T14:05:00Z", - "end_reason": "revoked", - "ended_at": "2026-11-02T15:00:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.create.json b/cmd/codeaf/testdata/workstreams-wire/page.create.json deleted file mode 100644 index f9674dcc8e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.create.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "method": "page.create", - "errors": [ - "bad_params", - "not_found", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageCreateParams", - "result_type": "WorkstreamPageCreateResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "parent_id": "pg_31d0", - "title": "Metering decisions", - "owner": "you", - "read_by_jobs": false, - "doc": { - "type": "doc", - "content": [] - }, - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ], - "result": [ - { - "page": { - "id": "pg_32e1", - "workstream_id": "ws_8f2c1a", - "title": "Metering decisions", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "updated_from": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "parent_id": "pg_31d0", - "doc": { - "type": "doc", - "content": [] - } - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.delete.json b/cmd/codeaf/testdata/workstreams-wire/page.delete.json deleted file mode 100644 index f13834bec0..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.delete.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "method": "page.delete", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageDeleteParams", - "result_type": "WorkstreamPageDeleteResult", - "examples": { - "params": [ - { - "id": "pg_32e1" - } - ], - "result": [ - { - "deleted": [ - "pg_32e1" - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.get.json b/cmd/codeaf/testdata/workstreams-wire/page.get.json deleted file mode 100644 index 85cc68240a..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.get.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "method": "page.get", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageGetParams", - "result_type": "WorkstreamPageGetResult", - "examples": { - "params": [ - { - "id": "pg_31d0", - "markdown": true - }, - { - "id": "pg_31d0", - "version": 1 - } - ], - "result": [ - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - }, - "markdown": "" - } - }, - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - } - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.history.json b/cmd/codeaf/testdata/workstreams-wire/page.history.json deleted file mode 100644 index ea5e6a6ce4..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.history.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "method": "page.history", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageHistoryParams", - "result_type": "WorkstreamPageHistoryResult", - "examples": { - "params": [ - { - "id": "pg_31d0", - "limit": 50 - } - ], - "result": [ - { - "versions": [ - { - "version": 2, - "saved_at": "2026-11-02T15:00:00Z", - "saved_by": "agent", - "from": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "title": "Overview", - "bytes": 27 - }, - { - "version": 1, - "saved_at": "2026-11-02T14:05:00Z", - "saved_by": "you", - "title": "Overview", - "bytes": 27 - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.list.json b/cmd/codeaf/testdata/workstreams-wire/page.list.json deleted file mode 100644 index 6238f1105b..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.list.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "method": "page.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageListParams", - "result_type": "WorkstreamPageListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a" - }, - { - "workstream_id": "ws_8f2c1a", - "include_deleted": true - } - ], - "result": [ - { - "pages": [ - { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you" - } - ] - }, - { - "pages": [ - { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you" - } - ], - "deleted": [ - { - "id": "pg_33f2", - "workstream_id": "ws_8f2c1a", - "parent_id": "pg_31d0", - "title": "Old metering notes", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 2, - "updated_at": "2026-11-02T15:10:00Z", - "updated_by": "you", - "deleted_at": "2026-11-02T15:20:00Z" - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.move.json b/cmd/codeaf/testdata/workstreams-wire/page.move.json deleted file mode 100644 index 2079f5087e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.move.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "method": "page.move", - "errors": [ - "bad_params", - "not_found", - "cycle", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageMoveParams", - "result_type": "WorkstreamPageMoveResult", - "examples": { - "params": [ - { - "id": "pg_32e1", - "parent_id": "pg_31d0" - }, - { - "id": "pg_32e1", - "parent_id": "pg_31d0", - "after_id": "pg_548c" - } - ], - "result": [ - { - "page": { - "id": "pg_32e1", - "workstream_id": "ws_8f2c1a", - "title": "Metering decisions", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "parent_id": "pg_31d0" - } - }, - { - "page": { - "id": "pg_32e1", - "workstream_id": "ws_8f2c1a", - "title": "Metering decisions", - "owner": "you", - "read_by_jobs": false, - "position": 1, - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "updated_by": "you", - "parent_id": "pg_31d0" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.restore.json b/cmd/codeaf/testdata/workstreams-wire/page.restore.json deleted file mode 100644 index 37da1c2804..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.restore.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "method": "page.restore", - "errors": [ - "bad_params", - "not_found", - "version_conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageRestoreParams", - "result_type": "WorkstreamPageRestoreResult", - "examples": { - "params": [ - { - "id": "pg_31d0", - "version": 1, - "base_version": 2 - }, - { - "id": "pg_33f2" - } - ], - "result": [ - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 3, - "updated_at": "2026-11-02T15:00:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - } - } - }, - { - "page": { - "id": "pg_33f2", - "workstream_id": "ws_8f2c1a", - "parent_id": "pg_31d0", - "title": "Old metering notes", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 2, - "updated_at": "2026-11-02T15:30:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - } - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.save.json b/cmd/codeaf/testdata/workstreams-wire/page.save.json deleted file mode 100644 index 51a913faff..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.save.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "method": "page.save", - "errors": [ - "bad_params", - "not_found", - "version_conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageSaveParams", - "result_type": "WorkstreamPageSaveResult", - "examples": { - "params": [ - { - "id": "pg_31d0", - "doc": { - "type": "doc", - "content": [] - }, - "base_version": 1 - }, - { - "id": "pg_31d0", - "doc": { - "type": "doc", - "content": [] - }, - "base_version": 2, - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ], - "result": [ - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 2, - "updated_at": "2026-11-02T15:00:00Z", - "updated_by": "you", - "doc": { - "type": "doc", - "content": [] - } - } - }, - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 3, - "updated_at": "2026-11-02T15:10:00Z", - "updated_by": "you", - "updated_from": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "doc": { - "type": "doc", - "content": [] - } - } - } - ], - "error": [ - { - "code": "version_conflict", - "message": "the page changed since it was read", - "detail": { - "id": "pg_31d0", - "version": 2, - "updated_at": "2026-11-02T15:00:00Z", - "updated_by": "agent" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/page.update.json b/cmd/codeaf/testdata/workstreams-wire/page.update.json deleted file mode 100644 index 1a63b0e925..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/page.update.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "method": "page.update", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPageUpdateParams", - "result_type": "WorkstreamPageUpdateResult", - "examples": { - "params": [ - { - "id": "pg_32e1", - "title": "Metering decisions", - "owner": "agent", - "read_by_jobs": true - } - ], - "result": [ - { - "page": { - "id": "pg_32e1", - "workstream_id": "ws_8f2c1a", - "title": "Metering decisions", - "owner": "agent", - "read_by_jobs": true, - "position": 0, - "version": 1, - "updated_at": "2026-11-02T15:00:00Z", - "updated_by": "you", - "parent_id": "pg_31d0" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.drop.json b/cmd/codeaf/testdata/workstreams-wire/record.drop.json deleted file mode 100644 index 4af5590af0..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.drop.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "method": "record.drop", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordDropParams", - "result_type": "WorkstreamRecordDropResult", - "examples": { - "params": [ - { - "id": "rec_7a1e" - } - ], - "result": [ - { - "record": { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "dropped", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "decided_at": "2026-11-02T15:00:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.keep.json b/cmd/codeaf/testdata/workstreams-wire/record.keep.json deleted file mode 100644 index 9d11ab970d..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.keep.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "method": "record.keep", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordKeepParams", - "result_type": "WorkstreamRecordKeepResult", - "examples": { - "params": [ - { - "id": "rec_7a1e" - }, - { - "id": "rec_5c20" - } - ], - "result": [ - { - "record": { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "kept", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "decided_at": "2026-11-02T15:00:00Z" - } - }, - { - "record": { - "id": "rec_5c20", - "workstream_id": "ws_8f2c1a", - "kind": "goahead", - "text": "Wave 1 is small; let it run without asking.", - "state": "kept", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "decided_at": "2026-11-02T15:00:00Z", - "goahead": { - "scope": "wave", - "wave": 1, - "cap_usd": 40.0, - "ends_at": "2026-11-09T23:59:59Z" - }, - "goahead_id": "ga_7d31" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.list.json b/cmd/codeaf/testdata/workstreams-wire/record.list.json deleted file mode 100644 index 5ba63ccd82..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.list.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "method": "record.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordListParams", - "result_type": "WorkstreamRecordListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "kind": "question", - "state": "proposed" - }, - { - "workstream_id": "ws_8f2c1a", - "include_goaheads": true - } - ], - "result": [ - { - "records": [ - { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "proposed", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z" - } - ] - }, - { - "records": [ - { - "id": "rec_5c20", - "workstream_id": "ws_8f2c1a", - "kind": "goahead", - "text": "Wave 1 is small; let it run without asking.", - "state": "proposed", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "goahead": { - "scope": "wave", - "wave": 1, - "cap_usd": 40.0, - "ends_at": "2026-11-09T23:59:59Z" - } - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.propose.json b/cmd/codeaf/testdata/workstreams-wire/record.propose.json deleted file mode 100644 index a5c4c8f2e5..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.propose.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "method": "record.propose", - "errors": [ - "bad_params", - "not_found", - "conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordProposeParams", - "result_type": "WorkstreamRecordProposeResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "kept": false - }, - { - "workstream_id": "ws_8f2c1a", - "kind": "goahead", - "text": "Wave 1 is small; let it run without asking.", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "goahead": { - "scope": "wave", - "wave": 1, - "cap_usd": 40.0, - "ends_at": "2026-11-09T23:59:59Z" - } - } - ], - "result": [ - { - "record": { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "proposed", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z" - } - }, - { - "record": { - "id": "rec_5c20", - "workstream_id": "ws_8f2c1a", - "kind": "goahead", - "text": "Wave 1 is small; let it run without asking.", - "state": "proposed", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "goahead": { - "scope": "wave", - "wave": 1, - "cap_usd": 40.0, - "ends_at": "2026-11-09T23:59:59Z" - } - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/record.supersede.json b/cmd/codeaf/testdata/workstreams-wire/record.supersede.json deleted file mode 100644 index 953a35f799..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/record.supersede.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "method": "record.supersede", - "errors": [ - "bad_params", - "not_found", - "conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamRecordSupersedeParams", - "result_type": "WorkstreamRecordSupersedeResult", - "examples": { - "params": [ - { - "id": "rec_7a1e", - "text": "Retries do not count toward usage.", - "kind": "decision", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ], - "result": [ - { - "record": { - "id": "rec_91bf", - "workstream_id": "ws_8f2c1a", - "kind": "decision", - "text": "Retries do not count toward usage.", - "state": "kept", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T15:00:00Z", - "decided_at": "2026-11-02T15:00:00Z", - "supersedes": "rec_7a1e" - }, - "superseded": { - "id": "rec_7a1e", - "workstream_id": "ws_8f2c1a", - "kind": "question", - "text": "Should retries count toward usage?", - "state": "superseded", - "by": "agent", - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - }, - "created_at": "2026-11-02T14:05:00Z", - "decided_at": "2026-11-02T15:00:00Z", - "superseded_by": "rec_91bf" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.add.json b/cmd/codeaf/testdata/workstreams-wire/source.add.json deleted file mode 100644 index fc70f80f17..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/source.add.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "method": "source.add", - "errors": [ - "bad_params", - "not_found", - "conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSourceAddParams", - "result_type": "WorkstreamSourceAddResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "kind": "file", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true - }, - { - "workstream_id": "ws_8f2c1a", - "kind": "link", - "url": "HTTPS://Docs.Stripe.com/billing/subscriptions/usage-based", - "title": "Usage-based billing guide" - } - ], - "result": [ - { - "source": { - "id": "src_2b71", - "workstream_id": "ws_8f2c1a", - "kind": "file", - "title": "usage-pricing.md", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:05:00Z" - }, - "already": false - }, - { - "source": { - "id": "src_2b75", - "workstream_id": "ws_8f2c1a", - "kind": "link", - "title": "Usage-based billing guide", - "url": "https://docs.stripe.com/billing/subscriptions/usage-based", - "read_by_jobs": false, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:40:00Z" - }, - "already": false - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.list.json b/cmd/codeaf/testdata/workstreams-wire/source.list.json deleted file mode 100644 index 3251c7924e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/source.list.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "method": "source.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSourceListParams", - "result_type": "WorkstreamSourceListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a" - } - ], - "result": [ - { - "sources": [ - { - "id": "src_2b71", - "workstream_id": "ws_8f2c1a", - "kind": "file", - "title": "usage-pricing.md", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:05:00Z" - }, - { - "id": "src_2b72", - "workstream_id": "ws_8f2c1a", - "kind": "issue", - "title": "acme/billing#412", - "repo": "acme/billing", - "number": 412, - "read_by_jobs": true, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:06:00Z" - }, - { - "id": "src_2b73", - "workstream_id": "ws_8f2c1a", - "kind": "kept", - "title": "Show the recorded usage.", - "ref": { - "kind": "record", - "id": "rec_7a1e" - }, - "read_by_jobs": false, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:20:00Z" - }, - { - "id": "src_2b74", - "workstream_id": "ws_8f2c1a", - "kind": "link", - "title": "docs.stripe.com/billing/subscriptions/usage-based", - "url": "https://docs.stripe.com/billing/subscriptions/usage-based", - "read_by_jobs": false, - "proposed": true, - "why": "The metering rules the plan follows.", - "added_by": "agent", - "added_at": "2026-11-02T14:30:00Z" - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.remove.json b/cmd/codeaf/testdata/workstreams-wire/source.remove.json deleted file mode 100644 index 141efdd06b..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/source.remove.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "method": "source.remove", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSourceRemoveParams", - "result_type": "WorkstreamSourceRemoveResult", - "examples": { - "params": [ - { - "id": "src_2b71" - } - ], - "result": [ - { - "source": { - "id": "src_2b71", - "workstream_id": "ws_8f2c1a", - "kind": "file", - "title": "usage-pricing.md", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true, - "proposed": false, - "added_by": "you", - "added_at": "2026-11-02T14:05:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/source.update.json b/cmd/codeaf/testdata/workstreams-wire/source.update.json deleted file mode 100644 index 2946814af3..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/source.update.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "method": "source.update", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSourceUpdateParams", - "result_type": "WorkstreamSourceUpdateResult", - "examples": { - "params": [ - { - "id": "src_2b74", - "keep": true, - "read_by_jobs": true - } - ], - "result": [ - { - "source": { - "id": "src_2b74", - "workstream_id": "ws_8f2c1a", - "kind": "link", - "title": "docs.stripe.com/billing/subscriptions/usage-based", - "url": "https://docs.stripe.com/billing/subscriptions/usage-based", - "read_by_jobs": true, - "proposed": false, - "why": "The metering rules the plan follows.", - "added_by": "agent", - "added_at": "2026-11-02T14:30:00Z" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json b/cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json deleted file mode 100644 index ecc34844f7..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/suggestion.drop.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "method": "suggestion.drop", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSuggestionDropParams", - "result_type": "WorkstreamSuggestionDropResult", - "examples": { - "params": [ - { - "id": "sg_7c41", - "edit_id": "e2" - }, - { - "id": "sg_7c41" - } - ], - "result": [ - { - "suggestion": { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "proposed", - "applies": true - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "dropped", - "decided_at": "2026-11-02T15:32:00Z" - } - ] - } - }, - { - "suggestion": { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "dropped", - "decided_at": "2026-11-02T15:33:00Z" - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "dropped", - "decided_at": "2026-11-02T15:32:00Z" - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json b/cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json deleted file mode 100644 index 4e26e07957..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/suggestion.keep.json +++ /dev/null @@ -1,203 +0,0 @@ -{ - "method": "suggestion.keep", - "errors": [ - "bad_params", - "not_found", - "version_conflict", - "conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSuggestionKeepParams", - "result_type": "WorkstreamSuggestionKeepResult", - "examples": { - "params": [ - { - "id": "sg_7c41", - "edit_id": "e1" - }, - { - "id": "sg_7c41" - } - ], - "result": [ - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 4, - "updated_at": "2026-11-02T15:30:00Z", - "updated_by": "you", - "updated_from": { - "kind": "suggestion", - "id": "sg_7c41", - "label": "The meter runs hourly since api#1107" - }, - "doc": { - "type": "doc", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Meter usage every hour." - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Bill at month end." - } - ] - } - ] - } - }, - "suggestion": { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "kept", - "decided_at": "2026-11-02T15:30:00Z" - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "proposed", - "applies": true - } - ] - } - }, - { - "page": { - "id": "pg_31d0", - "workstream_id": "ws_8f2c1a", - "title": "Overview", - "owner": "you", - "read_by_jobs": false, - "position": 0, - "version": 5, - "updated_at": "2026-11-02T15:31:00Z", - "updated_by": "you", - "updated_from": { - "kind": "suggestion", - "id": "sg_7c41", - "label": "api#1107 moved metering to hourly" - }, - "doc": { - "type": "doc", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Meter usage every hour." - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Invoices stay monthly. Bill at month end." - } - ] - } - ] - } - }, - "suggestion": { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "kept", - "decided_at": "2026-11-02T15:30:00Z" - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "kept", - "decided_at": "2026-11-02T15:31:00Z" - } - ] - } - } - ], - "error": [ - { - "code": "version_conflict", - "message": "the page changed and this edit no longer applies", - "detail": { - "id": "pg_31d0", - "version": 4, - "updated_at": "2026-11-02T15:25:00Z", - "updated_by": "you" - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/suggestion.list.json b/cmd/codeaf/testdata/workstreams-wire/suggestion.list.json deleted file mode 100644 index 42d173d3c1..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/suggestion.list.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "method": "suggestion.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamSuggestionListParams", - "result_type": "WorkstreamSuggestionListResult", - "examples": { - "params": [ - { - "page_id": "pg_31d0" - } - ], - "result": [ - { - "suggestions": [ - { - "id": "sg_7c41", - "workstream_id": "ws_8f2c1a", - "page_id": "pg_31d0", - "base_version": 3, - "why": "api#1107 moved metering to hourly", - "by": "agent", - "source": { - "kind": "chat", - "id": "conv_8d22" - }, - "created_at": "2026-11-02T15:20:00Z", - "edits": [ - { - "id": "e1", - "block": [ - 0 - ], - "offset": 12, - "delete": "nightly", - "insert": "every hour", - "block_text": "Meter usage nightly.", - "why": "The meter runs hourly since api#1107", - "state": "proposed", - "applies": true - }, - { - "id": "e2", - "block": [ - 1 - ], - "offset": 0, - "delete": "", - "insert": "Invoices stay monthly. ", - "block_text": "Bill at month end.", - "why": "Says what did not change", - "state": "proposed", - "applies": true - } - ] - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.apply.json b/cmd/codeaf/testdata/workstreams-wire/wake.apply.json deleted file mode 100644 index 5c1b061376..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/wake.apply.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "method": "wake.apply", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "too_large", - "unknown_method" - ], - "params_type": "WorkstreamWakeApplyParams", - "result_type": "WorkstreamWakeApplyResult", - "examples": { - "params": [ - { - "id": "wake_11", - "item_id": "wi_decision" - } - ], - "result": [ - { - "wake": { - "id": "wake_11", - "workstream_id": "ws_8f2c1a", - "event": { - "kind": "merged", - "factory": "f_local", - "job": 1102, - "label": "api#1102 merged" - }, - "state": "settled", - "started_at": "2026-11-03T14:00:00Z", - "settled_at": "2026-11-03T14:00:02Z", - "cost_usd": 0.04, - "summary": "Review the rollout.", - "items": [ - { - "id": "wi_decision", - "kind": "decision", - "state": "applied", - "text": "Kept decision: Roll out after review.", - "record_id": "rec_wake", - "applied_at": "2026-11-03T14:05:00Z" - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.list.json b/cmd/codeaf/testdata/workstreams-wire/wake.list.json deleted file mode 100644 index bea5259804..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/wake.list.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "method": "wake.list", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamWakeListParams", - "result_type": "WorkstreamWakeListResult", - "examples": { - "params": [ - { - "workstream_id": "ws_8f2c1a", - "limit": 20 - } - ], - "result": [ - { - "wakes": [ - { - "id": "wake_11", - "workstream_id": "ws_8f2c1a", - "event": { - "kind": "merged", - "factory": "f_local", - "job": 1102, - "label": "api#1102 merged" - }, - "state": "settled", - "started_at": "2026-11-03T14:00:00Z", - "settled_at": "2026-11-03T14:00:02Z", - "cost_usd": 0.04, - "summary": "Applied: moved it to wave 3; updated Where it stands. Keep the rollout decision and consider a small follow-up job.", - "items": [ - { - "id": "wi_move", - "kind": "move", - "state": "applied", - "text": "Moved f_local#1102 to wave 3.", - "factory": "f_local", - "job": 1102, - "wave": 3, - "applied_at": "2026-11-03T14:00:02Z" - }, - { - "id": "wi_decision", - "kind": "decision", - "state": "proposed", - "text": "Keep decision: Roll out after review.", - "why": "The merge is ready for review." - }, - { - "id": "wi_draft", - "kind": "draft", - "state": "proposed", - "text": "Add draft: Check rollout.", - "wave": 3, - "budget_usd": 3.0 - } - ] - } - ] - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json b/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json deleted file mode 100644 index dbea46ce44..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/wake.not_now.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "method": "wake.not_now", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamWakeNotNowParams", - "result_type": "WorkstreamWakeNotNowResult", - "examples": { - "params": [ - { - "id": "wake_11" - } - ], - "result": [ - { - "wake": { - "id": "wake_11", - "workstream_id": "ws_8f2c1a", - "event": { - "kind": "merged", - "factory": "f_local", - "job": 1102, - "label": "api#1102 merged" - }, - "state": "settled", - "started_at": "2026-11-03T14:00:00Z", - "settled_at": "2026-11-03T14:00:02Z", - "cost_usd": 0.04, - "summary": "Review the rollout.", - "dismissed_at": "2026-11-03T14:07:00Z", - "items": [ - { - "id": "wi_decision", - "kind": "decision", - "state": "dismissed", - "text": "Keep decision: Roll out after review." - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/wake.undo.json b/cmd/codeaf/testdata/workstreams-wire/wake.undo.json deleted file mode 100644 index 399751245e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/wake.undo.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "method": "wake.undo", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "too_large", - "unknown_method" - ], - "params_type": "WorkstreamWakeUndoParams", - "result_type": "WorkstreamWakeUndoResult", - "examples": { - "params": [ - { - "id": "wake_11", - "item_id": "wi_decision" - } - ], - "result": [ - { - "wake": { - "id": "wake_11", - "workstream_id": "ws_8f2c1a", - "event": { - "kind": "merged", - "factory": "f_local", - "job": 1102, - "label": "api#1102 merged" - }, - "state": "settled", - "started_at": "2026-11-03T14:00:00Z", - "settled_at": "2026-11-03T14:00:02Z", - "cost_usd": 0.04, - "summary": "Review the rollout.", - "items": [ - { - "id": "wi_decision", - "kind": "decision", - "state": "undone", - "text": "Keep decision: Roll out after review.", - "record_id": "rec_wake", - "applied_at": "2026-11-03T14:05:00Z", - "undone_at": "2026-11-03T14:06:00Z" - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json b/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json deleted file mode 100644 index 6709273c39..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.changed.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "event": "workstream.changed", - "data_type": "WorkstreamChanged", - "examples": { - "data": [ - { - "id": "ws_8f2c1a", - "parts": [ - "workstream", - "plan", - "pages", - "records", - "goaheads", - "jobs", - "wakes", - "sources" - ] - }, - { - "id": "ws_8f2c1a" - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.create.json b/cmd/codeaf/testdata/workstreams-wire/workstream.create.json deleted file mode 100644 index 85e5e9e4ac..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.create.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "method": "workstream.create", - "errors": [ - "bad_params", - "not_found", - "conflict", - "in_other_workstream", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamCreateParams", - "result_type": "WorkstreamCreateResult", - "examples": { - "params": [ - { - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "origin_chat_id": "conv_5b9e", - "refs": [ - { - "kind": "factory_job", - "id": "1107", - "factory": "f_local" - } - ], - "model": "openai/gpt-5.2", - "client_id": "create_684a", - "sources": [ - { - "kind": "repo", - "repo": "acme/billing", - "read_by_jobs": true - }, - { - "kind": "file", - "path": "/Users/ada/billing/usage-pricing.md", - "read_by_jobs": true - } - ] - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "stage": "shaping", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "propose", - "wake": { - "morning": true, - "related_issues": false - }, - "created_at": "2026-11-02T14:05:00Z", - "updated_at": "2026-11-02T14:05:00Z", - "total": 1 - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.get.json b/cmd/codeaf/testdata/workstreams-wire/workstream.get.json deleted file mode 100644 index cc8f857e22..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.get.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "method": "workstream.get", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamGetParams", - "result_type": "WorkstreamGetResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a" - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "stage": "building", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "propose", - "wake": { - "morning": true, - "related_issues": false - }, - "budget_usd": 100.0, - "created_at": "2026-11-02T13:00:00Z", - "updated_at": "2026-11-02T14:05:00Z", - "started_at": "2026-11-02T14:05:00Z", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5 - } - }, - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "", - "stage": "shaping", - "agent_id": "conv_8d22", - "autonomy": "propose", - "wake": { - "morning": false, - "related_issues": false - }, - "created_at": "2026-11-02T14:05:00Z", - "updated_at": "2026-11-02T14:05:00Z", - "total": 0, - "merged": 0, - "needs_you": 0, - "spend_usd": 0.0 - } - } - ], - "error": [ - { - "code": "not_found", - "message": "workstream not found" - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.list.json b/cmd/codeaf/testdata/workstreams-wire/workstream.list.json deleted file mode 100644 index 23ca14dc02..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.list.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "method": "workstream.list", - "errors": [ - "busy", - "unknown_method" - ], - "params_type": "WorkstreamListParams", - "result_type": "WorkstreamListResult", - "examples": { - "params": [ - {} - ], - "result": [ - { - "workstreams": [ - { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "stage": "building", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5, - "budget_usd": 100.0, - "updated_at": "2026-11-02T14:05:00Z", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e" - } - ] - }, - { - "workstreams": [ - { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "stage": "shaping", - "total": 1, - "updated_at": "2026-11-02T14:05:00Z", - "agent_id": "conv_8d22" - } - ] - } - ], - "error": [ - { - "code": "unknown_method", - "message": "workstreams: not implemented yet" - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json b/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json deleted file mode 100644 index bae070bfbf..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.mark_done.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "method": "workstream.mark_done", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamMarkDoneParams", - "result_type": "WorkstreamMarkDoneResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a", - "how_it_went": "Metering shipped.", - "archive_chats": true - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "stage": "done", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "propose", - "wake": { - "morning": true, - "related_issues": false - }, - "budget_usd": 100.0, - "created_at": "2026-11-02T13:00:00Z", - "updated_at": "2026-11-02T15:00:00Z", - "started_at": "2026-11-02T14:05:00Z", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5, - "done_at": "2026-11-02T15:00:00Z", - "how_it_went": "Metering shipped." - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json deleted file mode 100644 index b5e457560e..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_get.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "method": "workstream.plan_get", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPlanGetParams", - "result_type": "WorkstreamPlanGetResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a" - } - ], - "result": [ - { - "plan": { - "workstream_id": "ws_8f2c1a", - "version": 0, - "milestones": [], - "waves": [] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json b/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json deleted file mode 100644 index 548f20ff07..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.plan_set.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "method": "workstream.plan_set", - "errors": [ - "bad_params", - "not_found", - "conflict", - "version_conflict", - "too_large", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamPlanSetParams", - "result_type": "WorkstreamPlanSetResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a", - "base_version": 0, - "milestones": [ - { - "id": "ms_usage", - "title": "Recorded usage" - } - ], - "waves": [ - { - "n": 1, - "title": "Metering", - "milestone_id": "ms_usage", - "auto_start": false, - "items": [ - { - "kind": "job", - "factory": "f_local", - "job": 1107 - }, - { - "kind": "draft", - "draft_id": "draft_418c", - "title": "Expose usage totals", - "repo": "acme/billing", - "text": "Show the recorded usage.", - "size": "M", - "budget_usd": 25.0, - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ] - } - ] - } - ], - "result": [ - { - "plan": { - "workstream_id": "ws_8f2c1a", - "version": 1, - "updated_at": "2026-11-02T14:05:00Z", - "milestones": [ - { - "id": "ms_usage", - "title": "Recorded usage" - } - ], - "waves": [ - { - "n": 1, - "title": "Metering", - "milestone_id": "ms_usage", - "auto_start": false, - "items": [ - { - "kind": "job", - "factory": "f_local", - "job": 1107 - }, - { - "kind": "draft", - "draft_id": "draft_418c", - "title": "Expose usage totals", - "repo": "acme/billing", - "text": "Show the recorded usage.", - "size": "M", - "budget_usd": 25.0, - "source": { - "kind": "message", - "id": "msg_19a2", - "chat_id": "conv_5b9e", - "label": "Billing scope" - } - } - ] - } - ] - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json b/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json deleted file mode 100644 index 8ad474a157..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.reopen.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "method": "workstream.reopen", - "errors": [ - "bad_params", - "not_found", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamReopenParams", - "result_type": "WorkstreamReopenResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a" - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "Charge for recorded usage.", - "stage": "building", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "propose", - "wake": { - "morning": true, - "related_issues": false - }, - "budget_usd": 100.0, - "created_at": "2026-11-02T13:00:00Z", - "updated_at": "2026-11-02T15:00:00Z", - "started_at": "2026-11-02T14:05:00Z", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5 - } - } - ] - } -} diff --git a/cmd/codeaf/testdata/workstreams-wire/workstream.update.json b/cmd/codeaf/testdata/workstreams-wire/workstream.update.json deleted file mode 100644 index b9543ee5a1..0000000000 --- a/cmd/codeaf/testdata/workstreams-wire/workstream.update.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "method": "workstream.update", - "errors": [ - "bad_params", - "not_found", - "conflict", - "busy", - "unknown_method" - ], - "params_type": "WorkstreamUpdateParams", - "result_type": "WorkstreamUpdateResult", - "examples": { - "params": [ - { - "id": "ws_8f2c1a", - "patch": { - "why": "", - "autonomy": "small", - "wake": { - "morning": false, - "related_issues": true - }, - "budget_usd": 0.0 - } - } - ], - "result": [ - { - "workstream": { - "id": "ws_8f2c1a", - "name": "Usage-based billing", - "why": "", - "stage": "building", - "agent_id": "conv_8d22", - "origin_chat_id": "conv_5b9e", - "autonomy": "small", - "wake": { - "morning": false, - "related_issues": true - }, - "created_at": "2026-11-02T13:00:00Z", - "updated_at": "2026-11-02T14:05:00Z", - "started_at": "2026-11-02T14:05:00Z", - "total": 1, - "merged": 0, - "needs_you": 1, - "spend_usd": 2.5 - } - } - ] - } -} diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go index 4f0d750963..afb5e92008 100644 --- a/internal/appwire/collections.go +++ b/internal/appwire/collections.go @@ -81,7 +81,6 @@ type Member struct { // about a reference, whether it directly holds it. type Collection struct { ID string `json:"id"` - Workstream bool `json:"workstream,omitempty"` Name string `json:"name"` CreatedAt *time.Time `json:"created_at,omitempty"` Count int `json:"count"` @@ -250,11 +249,7 @@ func withFeature(hello Hello, feature string) Hello { // plainText is non-empty text of at most limit bytes with no control // characters and no surrounding space. func plainText(s string, limit int) bool { - return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && singleLineText(s) -} - -func singleLineText(s string) bool { - return strings.IndexFunc(s, unicode.IsControl) < 0 + return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0 } // CheckRef refuses a malformed reference as bad_params. adding refuses the diff --git a/internal/appwire/collections_factory_job_test.go b/internal/appwire/collections_factory_job_test.go index 1a888f545a..be9fec713d 100644 --- a/internal/appwire/collections_factory_job_test.go +++ b/internal/appwire/collections_factory_job_test.go @@ -32,10 +32,4 @@ func TestPartACollectionFactoryJobRefs(t *testing.T) { if err != nil || !strings.Contains(string(raw), `"factory":"f"`) { t.Fatalf("factory: %s %v", raw, err) } - for _, flag := range []bool{false, true} { - raw, err := json.Marshal(Collection{ID: "c", Name: "Name", Workstream: flag}) - if err != nil || strings.Contains(string(raw), `"workstream":true`) != flag { - t.Fatalf("flag: %s %v", raw, err) - } - } } diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 32669f483d..636a4487dc 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -197,7 +197,6 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello.Contract = Contract hello = withFeature(hello, FeatureAgentEffortRungs) hello = withCollectionsFeature(hello, engine) - hello = withWorkstreamsFeature(hello, engine) hello = withMessageReadsFeature(hello, engine) hello = withWorkGraphFeature(hello, engine) hello = withSlashCatalogFeature(hello, engine) diff --git a/internal/appwire/workstreams.go b/internal/appwire/workstreams.go deleted file mode 100644 index 2d6f277d91..0000000000 --- a/internal/appwire/workstreams.go +++ /dev/null @@ -1,748 +0,0 @@ -// Workstreams v0 (the Workstreams plan, WP-0) is a proposal until reviewed. -// The Suggestions addition (v0.5) adds suggested edits on person-owned pages. -// PROTOCOL.md, "workstream methods", is the wire index. -package appwire - -import ( - "encoding/json" - "reflect" - "time" -) - -const ( - FeatureWorkstreams = "workstreams" - FeatureWorkstreamSources = "workstream_sources" - EventWorkstreamChanged = "workstream.changed" - CodeInOtherWorkstream = "in_other_workstream" - CodeVersionConflict = "version_conflict" -) - -// ErrWorkstreamsNotYet is the capability refusal for a shapes-only engine. -var ErrWorkstreamsNotYet = Errorf(CodeUnknownMethod, "workstreams: not implemented yet") - -// WorkstreamMethods follows the contract index order. -var WorkstreamMethods = []string{ - "workstream.list", - "workstream.get", - "workstream.create", - "workstream.update", - "workstream.mark_done", - "workstream.reopen", - "workstream.plan_get", - "workstream.plan_set", - "page.list", - "page.get", - "page.save", - "page.create", - "page.update", - "page.move", - "page.delete", - "page.history", - "page.restore", - "record.list", - "record.propose", - "record.keep", - "record.drop", - "record.supersede", - "goahead.list", - "goahead.grant", - "goahead.revoke", - "wake.list", - "wake.apply", - "wake.undo", - "wake.not_now", - "suggestion.list", - "suggestion.keep", - "suggestion.drop", - "source.list", - "source.add", - "source.update", - "source.remove", -} - -// Optional numbers and booleans use pointers so known zero and false survive. -// Optional times use omitzero; required lists must be initialized even when empty. -// Numeric counts, versions, positions, job numbers and waves are non-negative. -type WorkstreamWake struct { - Morning bool `json:"morning"` - RelatedIssues bool `json:"related_issues"` -} - -type WorkstreamRef struct { - Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,factory_job"` - ID string `json:"id"` - SessionID *string `json:"session_id,omitempty"` - Factory *string `json:"factory,omitempty"` -} - -type WorkstreamSource struct { - Kind string `json:"kind" enum:"chat,message,factory_job,page,url,suggestion"` - ID string `json:"id"` - ChatID *string `json:"chat_id,omitempty"` - Factory *string `json:"factory,omitempty"` - Label *string `json:"label,omitempty"` -} - -type WorkstreamRow struct { - ID string `json:"id"` - Name string `json:"name"` - Stage string `json:"stage" enum:"shaping,building,done"` - Total int `json:"total"` - Merged *int `json:"merged,omitempty"` - NeedsYou *int `json:"needs_you,omitempty"` - SpendUSD *float64 `json:"spend_usd,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - AgentID string `json:"agent_id"` - OriginChatID *string `json:"origin_chat_id,omitempty"` -} - -type Workstream struct { - ID string `json:"id"` - Name string `json:"name"` - Why string `json:"why"` - Stage string `json:"stage" enum:"shaping,building,done"` - AgentID string `json:"agent_id"` - OriginChatID *string `json:"origin_chat_id,omitempty"` - Autonomy string `json:"autonomy" enum:"propose,small,off"` - Wake WorkstreamWake `json:"wake"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - StartedAt time.Time `json:"started_at,omitzero"` - DoneAt time.Time `json:"done_at,omitzero"` - HowItWent *string `json:"how_it_went,omitempty"` - Total int `json:"total"` - Merged *int `json:"merged,omitempty"` - NeedsYou *int `json:"needs_you,omitempty"` - SpendUSD *float64 `json:"spend_usd,omitempty"` -} - -type WorkstreamPlan struct { - WorkstreamID string `json:"workstream_id"` - Version int `json:"version"` - UpdatedAt time.Time `json:"updated_at,omitzero"` - Milestones []WorkstreamMilestone `json:"milestones"` - Waves []WorkstreamWave `json:"waves"` -} - -type WorkstreamMilestone struct { - ID string `json:"id"` - Title string `json:"title"` -} - -type WorkstreamWave struct { - N int `json:"n"` - Title *string `json:"title,omitempty"` - MilestoneID *string `json:"milestone_id,omitempty"` - AutoStart bool `json:"auto_start"` - Items []WorkstreamPlanItem `json:"items"` -} - -type WorkstreamPlanItem struct { - Kind string `json:"kind" enum:"job,draft"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - DraftID *string `json:"draft_id,omitempty"` - Title *string `json:"title,omitempty"` - Repo *string `json:"repo,omitempty"` - Text *string `json:"text,omitempty"` - Size *string `json:"size,omitempty" enum:"S,M,L"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - Source *WorkstreamSource `json:"source,omitempty"` -} - -type WorkstreamPageNode struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - ParentID *string `json:"parent_id,omitempty"` - Title string `json:"title"` - Owner string `json:"owner" enum:"you,agent"` - ReadByJobs bool `json:"read_by_jobs"` - Position int `json:"position"` - Version int `json:"version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy string `json:"updated_by" enum:"you,agent"` - UpdatedFrom *WorkstreamSource `json:"updated_from,omitempty"` - DeletedAt time.Time `json:"deleted_at,omitzero"` -} - -type WorkstreamPage struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - ParentID *string `json:"parent_id,omitempty"` - Title string `json:"title"` - Owner string `json:"owner" enum:"you,agent"` - ReadByJobs bool `json:"read_by_jobs"` - Position int `json:"position"` - Version int `json:"version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy string `json:"updated_by" enum:"you,agent"` - UpdatedFrom *WorkstreamSource `json:"updated_from,omitempty"` - DeletedAt time.Time `json:"deleted_at,omitzero"` - Doc json.RawMessage `json:"doc"` - Markdown *string `json:"markdown,omitempty"` -} - -type WorkstreamPageVersion struct { - Version int `json:"version"` - SavedAt time.Time `json:"saved_at"` - SavedBy string `json:"saved_by" enum:"you,agent"` - From *WorkstreamSource `json:"from,omitempty"` - Title string `json:"title"` - Bytes int `json:"bytes"` -} - -type WorkstreamRecord struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"decision,question,goahead"` - Text string `json:"text"` - State string `json:"state" enum:"proposed,kept,dropped,superseded"` - By string `json:"by" enum:"you,agent"` - Source *WorkstreamSource `json:"source,omitempty"` - CreatedAt time.Time `json:"created_at"` - DecidedAt time.Time `json:"decided_at,omitzero"` - Supersedes *string `json:"supersedes,omitempty"` - SupersededBy *string `json:"superseded_by,omitempty"` - GoAhead *WorkstreamGoAheadProposal `json:"goahead,omitempty"` - GoAheadID *string `json:"goahead_id,omitempty"` -} - -type WorkstreamGoAheadProposal struct { - Scope string `json:"scope" enum:"job,wave,milestone"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - Wave *int `json:"wave,omitempty"` - MilestoneID *string `json:"milestone_id,omitempty"` - CapUSD float64 `json:"cap_usd"` - EndsAt time.Time `json:"ends_at"` -} - -type WorkstreamGoAhead struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Scope string `json:"scope" enum:"job,wave,milestone"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - Wave *int `json:"wave,omitempty"` - MilestoneID *string `json:"milestone_id,omitempty"` - CapUSD float64 `json:"cap_usd"` - EndsAt time.Time `json:"ends_at"` - State string `json:"state" enum:"active,ended"` - EndReason *string `json:"end_reason,omitempty" enum:"expired,revoked,workstream_done"` - SpentUSD *float64 `json:"spent_usd,omitempty"` - GrantedAt time.Time `json:"granted_at"` - EndedAt time.Time `json:"ended_at,omitzero"` -} - -type WorkstreamChanged struct { - ID string `json:"id"` - Parts []string `json:"parts,omitempty" enum:"workstream,plan,pages,records,goaheads,jobs,wakes,sources"` -} - -type WorkstreamSuggestion struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - PageID string `json:"page_id"` - BaseVersion int `json:"base_version"` - Why string `json:"why"` - By string `json:"by" enum:"you,agent"` - Source *WorkstreamSource `json:"source,omitempty"` - CreatedAt time.Time `json:"created_at"` - Edits []WorkstreamSuggestionEdit `json:"edits"` -} - -type WorkstreamSuggestionEdit struct { - ID string `json:"id"` - Block []int `json:"block"` - Offset int `json:"offset"` - Delete string `json:"delete"` - Insert string `json:"insert"` - BlockText string `json:"block_text"` - Why string `json:"why"` - State string `json:"state" enum:"proposed,kept,dropped"` - Applies *bool `json:"applies,omitempty"` - DecidedAt time.Time `json:"decided_at,omitzero"` -} - -type WorkstreamSuggestionListParams struct { - PageID string `json:"page_id"` -} -type WorkstreamSuggestionListResult struct { - Suggestions []WorkstreamSuggestion `json:"suggestions"` -} -type WorkstreamSuggestionKeepParams struct { - ID string `json:"id"` - EditID *string `json:"edit_id,omitempty"` -} -type WorkstreamSuggestionKeepResult struct { - Page WorkstreamPage `json:"page"` - Suggestion WorkstreamSuggestion `json:"suggestion"` -} -type WorkstreamSuggestionDropParams struct { - ID string `json:"id"` - EditID *string `json:"edit_id,omitempty"` -} -type WorkstreamSuggestionDropResult struct { - Suggestion WorkstreamSuggestion `json:"suggestion"` -} - -type WorkstreamTaken struct { - Factory string `json:"factory"` - Job int `json:"job"` - WorkstreamID string `json:"workstream_id"` - WorkstreamName string `json:"workstream_name"` -} - -type WorkstreamVersionConflict struct { - ID string `json:"id"` - Version int `json:"version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy string `json:"updated_by" enum:"you,agent"` -} - -type WorkstreamPatch struct { - Name *string `json:"name,omitempty"` - Why *string `json:"why,omitempty"` - Autonomy *string `json:"autonomy,omitempty" enum:"propose,small,off"` - Wake *WorkstreamWake `json:"wake,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` -} - -type WorkstreamListParams struct{} - -type WorkstreamListResult struct { - Workstreams []WorkstreamRow `json:"workstreams"` -} - -type WorkstreamGetParams struct { - ID string `json:"id"` -} - -type WorkstreamGetResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamCreateParams struct { - Name string `json:"name"` - Why *string `json:"why,omitempty"` - OriginChatID *string `json:"origin_chat_id,omitempty"` - Refs []WorkstreamRef `json:"refs,omitempty"` - Model *string `json:"model,omitempty"` - ClientID *string `json:"client_id,omitempty"` - Sources []WorkstreamSourceAdd `json:"sources,omitempty"` -} - -type WorkstreamCreateResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamUpdateParams struct { - ID string `json:"id"` - Patch WorkstreamPatch `json:"patch"` -} - -type WorkstreamUpdateResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamMarkDoneParams struct { - ID string `json:"id"` - HowItWent *string `json:"how_it_went,omitempty"` - ArchiveChats *bool `json:"archive_chats,omitempty"` -} - -type WorkstreamMarkDoneResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamReopenParams struct { - ID string `json:"id"` -} - -type WorkstreamReopenResult struct { - Workstream Workstream `json:"workstream"` -} - -type WorkstreamPlanGetParams struct { - ID string `json:"id"` -} - -type WorkstreamPlanGetResult struct { - Plan WorkstreamPlan `json:"plan"` -} - -type WorkstreamPlanSetParams struct { - ID string `json:"id"` - BaseVersion int `json:"base_version"` - Milestones []WorkstreamMilestone `json:"milestones"` - Waves []WorkstreamWave `json:"waves"` -} - -type WorkstreamPlanSetResult struct { - Plan WorkstreamPlan `json:"plan"` -} - -type WorkstreamPageListParams struct { - WorkstreamID string `json:"workstream_id"` - IncludeDeleted *bool `json:"include_deleted,omitempty"` -} - -type WorkstreamPageListResult struct { - Pages []WorkstreamPageNode `json:"pages"` - Deleted []WorkstreamPageNode `json:"deleted,omitzero"` -} - -type WorkstreamPageGetParams struct { - ID string `json:"id"` - Markdown *bool `json:"markdown,omitempty"` - Version *int `json:"version,omitempty"` -} - -type WorkstreamPageGetResult struct { - Page WorkstreamPage `json:"page"` -} - -type WorkstreamPageSaveParams struct { - ID string `json:"id"` - Doc json.RawMessage `json:"doc"` - BaseVersion int `json:"base_version"` - Source *WorkstreamSource `json:"source,omitempty"` -} - -type WorkstreamPageSaveResult struct { - Page WorkstreamPage `json:"page"` -} - -type WorkstreamPageCreateParams struct { - WorkstreamID string `json:"workstream_id"` - ParentID *string `json:"parent_id,omitempty"` - AfterID *string `json:"after_id,omitempty"` - Title string `json:"title"` - Owner *string `json:"owner,omitempty" enum:"you,agent"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` - Doc json.RawMessage `json:"doc,omitempty"` - Source *WorkstreamSource `json:"source,omitempty"` -} - -type WorkstreamPageCreateResult struct { - Page WorkstreamPage `json:"page"` -} - -type WorkstreamPageUpdateParams struct { - ID string `json:"id"` - Title *string `json:"title,omitempty"` - Owner *string `json:"owner,omitempty" enum:"you,agent"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` -} - -type WorkstreamPageUpdateResult struct { - Page WorkstreamPageNode `json:"page"` -} - -type WorkstreamPageMoveParams struct { - ID string `json:"id"` - ParentID *string `json:"parent_id,omitempty"` - AfterID *string `json:"after_id,omitempty"` -} - -type WorkstreamPageMoveResult struct { - Page WorkstreamPageNode `json:"page"` -} - -type WorkstreamPageDeleteParams struct { - ID string `json:"id"` -} - -type WorkstreamPageDeleteResult struct { - Deleted []string `json:"deleted"` -} - -type WorkstreamPageHistoryParams struct { - ID string `json:"id"` - Limit *int `json:"limit,omitempty"` -} - -type WorkstreamPageHistoryResult struct { - Versions []WorkstreamPageVersion `json:"versions"` -} - -type WorkstreamPageRestoreParams struct { - ID string `json:"id"` - Version *int `json:"version,omitempty"` - BaseVersion *int `json:"base_version,omitempty"` -} - -type WorkstreamPageRestoreResult struct { - Page WorkstreamPage `json:"page"` -} - -type WorkstreamRecordListParams struct { - WorkstreamID string `json:"workstream_id"` - Kind *string `json:"kind,omitempty" enum:"decision,question,goahead"` - State *string `json:"state,omitempty" enum:"proposed,kept,dropped,superseded"` - IncludeGoAheads *bool `json:"include_goaheads,omitempty"` -} - -type WorkstreamRecordListResult struct { - Records []WorkstreamRecord `json:"records"` -} - -type WorkstreamRecordProposeParams struct { - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"decision,question,goahead"` - Text string `json:"text"` - Source *WorkstreamSource `json:"source,omitempty"` - Kept *bool `json:"kept,omitempty"` - GoAhead *WorkstreamGoAheadProposal `json:"goahead,omitempty"` -} - -type WorkstreamRecordProposeResult struct { - Record WorkstreamRecord `json:"record"` -} - -type WorkstreamRecordKeepParams struct { - ID string `json:"id"` -} - -type WorkstreamRecordKeepResult struct { - Record WorkstreamRecord `json:"record"` -} - -type WorkstreamRecordDropParams struct { - ID string `json:"id"` -} - -type WorkstreamRecordDropResult struct { - Record WorkstreamRecord `json:"record"` -} - -type WorkstreamRecordSupersedeParams struct { - ID string `json:"id"` - Text string `json:"text"` - Kind *string `json:"kind,omitempty" enum:"decision,question"` - Source *WorkstreamSource `json:"source,omitempty"` -} - -type WorkstreamRecordSupersedeResult struct { - Record WorkstreamRecord `json:"record"` - Superseded WorkstreamRecord `json:"superseded"` -} - -type WorkstreamGoAheadListParams struct { - WorkstreamID string `json:"workstream_id"` - Ended *bool `json:"ended,omitempty"` -} - -type WorkstreamGoAheadListResult struct { - GoAheads []WorkstreamGoAhead `json:"goaheads"` -} - -type WorkstreamGoAheadGrantParams struct { - WorkstreamID string `json:"workstream_id"` - Scope string `json:"scope" enum:"job,wave,milestone"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - Wave *int `json:"wave,omitempty"` - MilestoneID *string `json:"milestone_id,omitempty"` - CapUSD float64 `json:"cap_usd"` - EndsAt time.Time `json:"ends_at"` -} - -type WorkstreamGoAheadGrantResult struct { - GoAhead WorkstreamGoAhead `json:"goahead"` -} - -type WorkstreamGoAheadRevokeParams struct { - ID string `json:"id"` -} - -type WorkstreamGoAheadRevokeResult struct { - GoAhead WorkstreamGoAhead `json:"goahead"` -} - -type WorkstreamWakeEvent struct { - Kind string `json:"kind" enum:"morning,finished,merged,failed,needs_you"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - Label *string `json:"label,omitempty"` -} - -type WorkstreamWakeItem struct { - ID string `json:"id"` - Kind string `json:"kind" enum:"move,draft,page,decision,question"` - State string `json:"state" enum:"applied,undone,proposed,dismissed"` - Text string `json:"text"` - Why *string `json:"why,omitempty"` - Factory *string `json:"factory,omitempty"` - Job *int `json:"job,omitempty"` - DraftID *string `json:"draft_id,omitempty"` - Wave *int `json:"wave,omitempty"` - PageID *string `json:"page_id,omitempty"` - RecordID *string `json:"record_id,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - Note *string `json:"note,omitempty"` - AppliedAt time.Time `json:"applied_at,omitzero"` - UndoneAt time.Time `json:"undone_at,omitzero"` -} - -type WorkstreamWakeRun struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Event WorkstreamWakeEvent `json:"event"` - State string `json:"state" enum:"running,settled,failed"` - StartedAt time.Time `json:"started_at"` - SettledAt time.Time `json:"settled_at,omitzero"` - CostUSD *float64 `json:"cost_usd,omitempty"` - Summary *string `json:"summary,omitempty"` - Error *string `json:"error,omitempty"` - DismissedAt time.Time `json:"dismissed_at,omitzero"` - Items []WorkstreamWakeItem `json:"items"` -} - -type WorkstreamWakeListParams struct { - WorkstreamID string `json:"workstream_id"` - Limit *int `json:"limit,omitempty"` -} -type WorkstreamWakeListResult struct { - Wakes []WorkstreamWakeRun `json:"wakes"` -} -type WorkstreamWakeApplyParams struct { - ID string `json:"id"` - ItemID *string `json:"item_id,omitempty"` -} -type WorkstreamWakeApplyResult struct { - Wake WorkstreamWakeRun `json:"wake"` -} -type WorkstreamWakeUndoParams struct { - ID string `json:"id"` - ItemID *string `json:"item_id,omitempty"` -} -type WorkstreamWakeUndoResult struct { - Wake WorkstreamWakeRun `json:"wake"` -} -type WorkstreamWakeNotNowParams struct { - ID string `json:"id"` -} -type WorkstreamWakeNotNowResult struct { - Wake WorkstreamWakeRun `json:"wake"` -} - -// Workstreamer is the optional engine half of the workstreams contract. -// A stub implements the shapes but returns ErrWorkstreamsNotYet and serves false. -type Workstreamer interface { - ServesWorkstreams() bool - WorkstreamList(WorkstreamListParams) (WorkstreamListResult, error) - WorkstreamGet(WorkstreamGetParams) (WorkstreamGetResult, error) - WorkstreamCreate(WorkstreamCreateParams) (WorkstreamCreateResult, error) - WorkstreamUpdate(WorkstreamUpdateParams) (WorkstreamUpdateResult, error) - WorkstreamMarkDone(WorkstreamMarkDoneParams) (WorkstreamMarkDoneResult, error) - WorkstreamReopen(WorkstreamReopenParams) (WorkstreamReopenResult, error) - WorkstreamPlanGet(WorkstreamPlanGetParams) (WorkstreamPlanGetResult, error) - WorkstreamPlanSet(WorkstreamPlanSetParams) (WorkstreamPlanSetResult, error) - WorkstreamPageList(WorkstreamPageListParams) (WorkstreamPageListResult, error) - WorkstreamPageGet(WorkstreamPageGetParams) (WorkstreamPageGetResult, error) - WorkstreamPageSave(WorkstreamPageSaveParams) (WorkstreamPageSaveResult, error) - WorkstreamPageCreate(WorkstreamPageCreateParams) (WorkstreamPageCreateResult, error) - WorkstreamPageUpdate(WorkstreamPageUpdateParams) (WorkstreamPageUpdateResult, error) - WorkstreamPageMove(WorkstreamPageMoveParams) (WorkstreamPageMoveResult, error) - WorkstreamPageDelete(WorkstreamPageDeleteParams) (WorkstreamPageDeleteResult, error) - WorkstreamPageHistory(WorkstreamPageHistoryParams) (WorkstreamPageHistoryResult, error) - WorkstreamPageRestore(WorkstreamPageRestoreParams) (WorkstreamPageRestoreResult, error) - WorkstreamRecordList(WorkstreamRecordListParams) (WorkstreamRecordListResult, error) - WorkstreamRecordPropose(WorkstreamRecordProposeParams) (WorkstreamRecordProposeResult, error) - WorkstreamRecordKeep(WorkstreamRecordKeepParams) (WorkstreamRecordKeepResult, error) - WorkstreamRecordDrop(WorkstreamRecordDropParams) (WorkstreamRecordDropResult, error) - WorkstreamRecordSupersede(WorkstreamRecordSupersedeParams) (WorkstreamRecordSupersedeResult, error) - WorkstreamGoAheadList(WorkstreamGoAheadListParams) (WorkstreamGoAheadListResult, error) - WorkstreamGoAheadGrant(WorkstreamGoAheadGrantParams) (WorkstreamGoAheadGrantResult, error) - WorkstreamGoAheadRevoke(WorkstreamGoAheadRevokeParams) (WorkstreamGoAheadRevokeResult, error) - WorkstreamWakeList(WorkstreamWakeListParams) (WorkstreamWakeListResult, error) - WorkstreamWakeApply(WorkstreamWakeApplyParams) (WorkstreamWakeApplyResult, error) - WorkstreamWakeUndo(WorkstreamWakeUndoParams) (WorkstreamWakeUndoResult, error) - WorkstreamWakeNotNow(WorkstreamWakeNotNowParams) (WorkstreamWakeNotNowResult, error) - WorkstreamSuggestionList(WorkstreamSuggestionListParams) (WorkstreamSuggestionListResult, error) - WorkstreamSuggestionKeep(WorkstreamSuggestionKeepParams) (WorkstreamSuggestionKeepResult, error) - WorkstreamSuggestionDrop(WorkstreamSuggestionDropParams) (WorkstreamSuggestionDropResult, error) - WorkstreamSourceList(WorkstreamSourceListParams) (WorkstreamSourceListResult, error) - WorkstreamSourceAdd(WorkstreamSourceAddParams) (WorkstreamSourceAddResult, error) - WorkstreamSourceUpdate(WorkstreamSourceUpdateParams) (WorkstreamSourceUpdateResult, error) - WorkstreamSourceRemove(WorkstreamSourceRemoveParams) (WorkstreamSourceRemoveResult, error) -} - -// workstreamCall preserves the params/result types used by the schema generator. -// Unsupported engines retain their capability refusal before parameter checks. -func workstreamCall[P interface{ Check() error }, R any](call func(Workstreamer, P) (R, error)) method { - m := typed(func(e Engine, p P) (*R, error) { - w, ok := e.(Workstreamer) - if !ok { - return nil, Errorf(CodeUnknownMethod, "this engine does not serve workstreams") - } - if !w.ServesWorkstreams() { - return nil, ErrWorkstreamsNotYet - } - if err := p.Check(); err != nil { - return nil, err - } - result, err := call(w, p) - return &result, err - }) - delegate := m.call - m.call = func(e Engine, raw json.RawMessage) (any, error) { - if w, ok := e.(Workstreamer); ok && w.ServesWorkstreams() { - if err := workstreamRequired(raw, m.params); err != nil { - return nil, err - } - } - return delegate(e, raw) - } - return m -} - -func init() { - methods["workstream.list"] = workstreamCall(Workstreamer.WorkstreamList) - methods["workstream.get"] = workstreamCall(Workstreamer.WorkstreamGet) - methods["workstream.create"] = workstreamCall(Workstreamer.WorkstreamCreate) - methods["workstream.update"] = workstreamCall(Workstreamer.WorkstreamUpdate) - methods["workstream.mark_done"] = workstreamCall(Workstreamer.WorkstreamMarkDone) - methods["workstream.reopen"] = workstreamCall(Workstreamer.WorkstreamReopen) - methods["workstream.plan_get"] = workstreamCall(Workstreamer.WorkstreamPlanGet) - methods["workstream.plan_set"] = workstreamCall(Workstreamer.WorkstreamPlanSet) - methods["page.list"] = workstreamCall(Workstreamer.WorkstreamPageList) - methods["page.get"] = workstreamCall(Workstreamer.WorkstreamPageGet) - methods["page.save"] = workstreamCall(Workstreamer.WorkstreamPageSave) - methods["page.create"] = workstreamCall(Workstreamer.WorkstreamPageCreate) - methods["page.update"] = workstreamCall(Workstreamer.WorkstreamPageUpdate) - methods["page.move"] = workstreamCall(Workstreamer.WorkstreamPageMove) - methods["page.delete"] = workstreamCall(Workstreamer.WorkstreamPageDelete) - methods["page.history"] = workstreamCall(Workstreamer.WorkstreamPageHistory) - methods["page.restore"] = workstreamCall(Workstreamer.WorkstreamPageRestore) - methods["record.list"] = workstreamCall(Workstreamer.WorkstreamRecordList) - methods["record.propose"] = workstreamCall(Workstreamer.WorkstreamRecordPropose) - methods["record.keep"] = workstreamCall(Workstreamer.WorkstreamRecordKeep) - methods["record.drop"] = workstreamCall(Workstreamer.WorkstreamRecordDrop) - methods["record.supersede"] = workstreamCall(Workstreamer.WorkstreamRecordSupersede) - methods["goahead.list"] = workstreamCall(Workstreamer.WorkstreamGoAheadList) - methods["goahead.grant"] = workstreamCall(Workstreamer.WorkstreamGoAheadGrant) - methods["goahead.revoke"] = workstreamCall(Workstreamer.WorkstreamGoAheadRevoke) - methods["wake.list"] = workstreamCall(Workstreamer.WorkstreamWakeList) - methods["wake.apply"] = workstreamCall(Workstreamer.WorkstreamWakeApply) - methods["wake.undo"] = workstreamCall(Workstreamer.WorkstreamWakeUndo) - methods["wake.not_now"] = workstreamCall(Workstreamer.WorkstreamWakeNotNow) - methods["suggestion.list"] = workstreamCall(Workstreamer.WorkstreamSuggestionList) - methods["suggestion.keep"] = workstreamCall(Workstreamer.WorkstreamSuggestionKeep) - methods["suggestion.drop"] = workstreamCall(Workstreamer.WorkstreamSuggestionDrop) - methods["source.list"] = workstreamCall(Workstreamer.WorkstreamSourceList) - methods["source.add"] = workstreamCall(Workstreamer.WorkstreamSourceAdd) - methods["source.update"] = workstreamCall(Workstreamer.WorkstreamSourceUpdate) - methods["source.remove"] = workstreamCall(Workstreamer.WorkstreamSourceRemove) - eventShapes[EventWorkstreamChanged] = reflect.TypeFor[WorkstreamChanged]() -} - -func withWorkstreamsFeature(hello Hello, e Engine) Hello { - if w, ok := e.(Workstreamer); ok && w.ServesWorkstreams() { - return withFeature(withFeature(hello, FeatureWorkstreams), FeatureWorkstreamSources) - } - return hello -} diff --git a/internal/appwire/workstreams_check.go b/internal/appwire/workstreams_check.go deleted file mode 100644 index 1991f2ba91..0000000000 --- a/internal/appwire/workstreams_check.go +++ /dev/null @@ -1,450 +0,0 @@ -package appwire - -import ( - "bytes" - "encoding/json" - "math" - "reflect" - "strings" - "time" - "unicode" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// workstreamRequired distinguishes a missing required zero-valued field from -// an explicit zero, including plan version 0 and false wake flags. Editor JSON -// stays opaque; unknown additive fields stay ignored by the typed decoder. -func workstreamRequired(raw json.RawMessage, shape reflect.Type) error { - if shape == reflect.TypeFor[json.RawMessage]() || shape == reflect.TypeFor[time.Time]() { - return nil - } - if shape.Kind() == reflect.Pointer { - shape = shape.Elem() - } - switch shape.Kind() { - case reflect.Struct: - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { - return Errorf(CodeBadParams, "workstream params must be an object") - } - for i := 0; i < shape.NumField(); i++ { - field := shape.Field(i) - tag := field.Tag.Get("json") - name, _, _ := strings.Cut(tag, ",") - value, exists := fields[name] - if !exists { - if !strings.Contains(tag, ",omitempty") { - return Errorf(CodeBadParams, "%s is required", name) - } - continue - } - if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - return Errorf(CodeBadParams, "%s must not be null", name) - } - if err := workstreamRequired(value, field.Type); err != nil { - return err - } - } - case reflect.Slice: - var values []json.RawMessage - if err := json.Unmarshal(raw, &values); err != nil { - return Errorf(CodeBadParams, "expected an array") - } - for _, value := range values { - if err := workstreamRequired(value, shape.Elem()); err != nil { - return err - } - } - } - return nil -} - -func workstreamID(id string) error { - if !utf8.ValidString(id) || !plainText(id, collectionIDMax) { - return Errorf(CodeBadParams, "a workstream target needs a nonempty id without surrounding space or control characters") - } - return nil -} - -func workstreamOptionalID(id *string) error { - if id == nil { - return nil - } - return workstreamID(*id) -} - -func workstreamText(text string, min, max int, field string) error { - if !utf8.ValidString(text) || len(text) < min || len(text) > max || (min > 0 && strings.TrimSpace(text) == "") { - return Errorf(CodeBadParams, "%s is %d to %d bytes", field, min, max) - } - if strings.IndexFunc(text, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\t' }) >= 0 { - return Errorf(CodeBadParams, "%s allows only newline and tab control characters", field) - } - return nil -} - -func workstreamLine(text string, min, max int, field string) error { - if !singleLineText(text) { - return Errorf(CodeBadParams, "%s must not contain control characters", field) - } - return workstreamText(text, min, max, field) -} - -func workstreamOptionalLine(text *string, min, max int, field string) error { - if text == nil { - return nil - } - return workstreamLine(*text, min, max, field) -} - -func workstreamOptionalText(text *string, min, max int, field string) error { - if text == nil { - return nil - } - return workstreamText(*text, min, max, field) -} - -func workstreamEnum(word string, words ...string) error { - for _, allowed := range words { - if word == allowed { - return nil - } - } - return Errorf(CodeBadParams, "invalid workstream value %q", word) -} - -func workstreamOptionalEnum(word *string, words ...string) error { - if word == nil { - return nil - } - return workstreamEnum(*word, words...) -} - -// workstreamChecks keeps each family of checks ordered and returns the first refusal. -func workstreamChecks(checks ...error) error { - for _, err := range checks { - if err != nil { - return err - } - } - return nil -} - -func workstreamDoc(doc json.RawMessage, required bool) error { - if len(doc) > workstream.DocMax { - return Errorf(CodeTooLarge, "encoded document exceeds 1 MiB") - } - if len(doc) == 0 && !required { - return nil - } - trimmed := bytes.TrimSpace(doc) - if !utf8.Valid(doc) || !json.Valid(doc) || len(trimmed) == 0 || trimmed[0] != '{' { - return Errorf(CodeBadParams, "doc must be an editor JSON object") - } - return nil -} - -func (p WorkstreamRef) Check() error { - if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.SessionID), workstreamOptionalID(p.Factory)); err != nil { - return err - } - if p.Factory != nil && p.Kind != RefFactoryJob { - return Errorf(CodeBadParams, "only a factory_job reference has a factory") - } - if p.SessionID != nil && p.Kind != RefTask && p.Kind != RefProcess { - return Errorf(CodeBadParams, "only a task or process reference has a session_id") - } - return CheckRef(Ref{Kind: p.Kind, ID: p.ID, SessionID: workstreamString(p.SessionID), Factory: workstreamString(p.Factory)}, true) -} - -func workstreamString(p *string) string { - if p == nil { - return "" - } - return *p -} - -func (p WorkstreamSource) Check() error { - if err := workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Label, 0, 200, "source label")); err != nil { - return err - } - if p.Kind != "message" && p.ChatID != nil || p.Kind != "factory_job" && p.Factory != nil { - return Errorf(CodeBadParams, "chat_id is only for message and factory is only for factory_job") - } - switch p.Kind { - case "suggestion": - return Errorf(CodeBadParams, "suggestion sources are written only by suggestion.keep") - case "chat", "page", "url": - case "message": - if p.ChatID == nil { - return Errorf(CodeBadParams, "message source requires chat_id") - } - if err := workstreamID(*p.ChatID); err != nil { - return err - } - case "factory_job": - if err := CheckRef(Ref{Kind: RefFactoryJob, ID: p.ID, Factory: workstreamString(p.Factory)}, true); err != nil { - return err - } - default: - return Errorf(CodeBadParams, "unknown source kind %q", p.Kind) - } - return workstreamChecks(workstreamOptionalID(p.ChatID), workstreamOptionalID(p.Factory)) -} - -func workstreamSource(p *WorkstreamSource) error { - if p == nil { - return nil - } - return p.Check() -} - -func (p WorkstreamSuggestionListParams) Check() error { return workstreamID(p.PageID) } -func (p WorkstreamSuggestionKeepParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.EditID)) -} -func (p WorkstreamSuggestionDropParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.EditID)) -} - -func (p WorkstreamListParams) Check() error { return nil } -func (p WorkstreamGetParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamReopenParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamPlanGetParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamPageListParams) Check() error { return workstreamID(p.WorkstreamID) } -func (p WorkstreamPageGetParams) Check() error { - if p.Version != nil && *p.Version < 1 { - return Errorf(CodeBadParams, "version must be positive") - } - return workstreamID(p.ID) -} -func (p WorkstreamPageDeleteParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamRecordKeepParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamRecordDropParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamGoAheadRevokeParams) Check() error { return workstreamID(p.ID) } -func (p WorkstreamGoAheadListParams) Check() error { return workstreamID(p.WorkstreamID) } - -func (p WorkstreamCreateParams) Check() error { - if !utf8.ValidString(p.Name) { - return Errorf(CodeBadParams, "name must be valid UTF-8") - } - if _, err := collectionName(p.Name); err != nil { - return err - } - if err := workstreamChecks(workstreamOptionalText(p.Why, 0, 4000, "why"), workstreamOptionalLine(p.ClientID, 0, 128, "client_id"), workstreamOptionalID(p.OriginChatID), workstreamOptionalID(p.Model)); err != nil { - return err - } - if len(p.Refs) > CollectionRefsMax { - return Errorf(CodeBadParams, "refs takes at most %d references", CollectionRefsMax) - } - for _, r := range p.Refs { - if err := r.Check(); err != nil { - return err - } - } - if len(p.Sources) > workstream.CreateSourcesMax { - return Errorf(CodeBadParams, "create takes at most %d sources", workstream.CreateSourcesMax) - } - return nil -} - -func (p WorkstreamUpdateParams) Check() error { - if p.Patch.Name != nil { - if !utf8.ValidString(*p.Patch.Name) { - return Errorf(CodeBadParams, "name must be valid UTF-8") - } - if _, err := collectionName(*p.Patch.Name); err != nil { - return err - } - } - if p.Patch.BudgetUSD != nil && (math.IsNaN(*p.Patch.BudgetUSD) || math.IsInf(*p.Patch.BudgetUSD, 0) || *p.Patch.BudgetUSD < 0) { - return Errorf(CodeBadParams, "budget_usd must be finite and nonnegative") - } - return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.Patch.Why, 0, 4000, "why"), workstreamOptionalEnum(p.Patch.Autonomy, "propose", "small", "off")) -} -func (p WorkstreamMarkDoneParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalText(p.HowItWent, 0, 4000, "how_it_went")) -} - -func (p WorkstreamPlanSetParams) Check() error { - if err := workstreamID(p.ID); err != nil { - return err - } - if p.BaseVersion < 0 || p.Milestones == nil || p.Waves == nil { - return Errorf(CodeBadParams, "plan requires nonnegative base_version and milestones and waves arrays") - } - if len(p.Milestones) > workstream.PlanMilestonesMax { - return Errorf(CodeTooLarge, "a plan holds at most %d milestones", workstream.PlanMilestonesMax) - } - if len(p.Waves) > workstream.PlanWavesMax { - return Errorf(CodeTooLarge, "a plan holds at most %d waves", workstream.PlanWavesMax) - } - items := 0 - for _, w := range p.Waves { - items += len(w.Items) - if items > workstream.PlanItemsMax { - return Errorf(CodeTooLarge, "a plan holds at most %d items", workstream.PlanItemsMax) - } - } - for _, m := range p.Milestones { - if m.ID != "" { - if err := workstreamID(m.ID); err != nil { - return err - } - } - if err := workstreamLine(m.Title, 1, 200, "milestone title"); err != nil { - return err - } - } - last := 0 - for _, w := range p.Waves { - if w.N <= last || w.Items == nil { - return Errorf(CodeBadParams, "waves must be positive, unique, ascending and have items arrays") - } - last = w.N - if err := workstreamChecks(workstreamOptionalID(w.MilestoneID), workstreamOptionalLine(w.Title, 0, 200, "wave title")); err != nil { - return err - } - for _, item := range w.Items { - if err := workstreamDraftFields(item); err != nil { - return err - } - if item.Job != nil && *item.Job <= 0 { - return Errorf(CodeBadParams, "plan job must be positive") - } - if err := workstreamChecks(workstreamOptionalID(item.Factory), workstreamOptionalID(item.DraftID)); err != nil { - return err - } - switch item.Kind { - case "job": - if item.Job == nil || *item.Job <= 0 || item.Factory == nil { - return Errorf(CodeBadParams, "plan job requires factory and positive job") - } - if err := workstreamID(*item.Factory); err != nil { - return err - } - case "draft": - if item.DraftID == nil || item.Title == nil { - return Errorf(CodeBadParams, "draft requires draft_id and title") - } - if err := workstreamChecks(workstreamID(*item.DraftID), workstreamLine(*item.Title, 1, 200, "draft title")); err != nil { - return err - } - if item.Text != nil { - // Control characters stay bad_params whatever the length. - if err := workstreamText(*item.Text, 0, len(*item.Text), "draft text"); err != nil { - return err - } - if len(*item.Text) > workstream.PlanDraftTextMax { - return Errorf(CodeTooLarge, "draft text must be at most %d bytes", workstream.PlanDraftTextMax) - } - } - default: - return Errorf(CodeBadParams, "unknown plan item kind %q", item.Kind) - } - } - } - return nil -} - -func (p WorkstreamPageSaveParams) Check() error { - if p.BaseVersion < 0 { - return Errorf(CodeBadParams, "base_version must be nonnegative") - } - return workstreamChecks(workstreamID(p.ID), workstreamDoc(p.Doc, true), workstreamSource(p.Source)) -} -func (p WorkstreamPageCreateParams) Check() error { - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID), workstreamLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent"), workstreamDoc(p.Doc, false), workstreamSource(p.Source)) -} -func (p WorkstreamPageUpdateParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalLine(p.Title, 1, 200, "page title"), workstreamOptionalEnum(p.Owner, "you", "agent")) -} -func (p WorkstreamPageMoveParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ParentID), workstreamOptionalID(p.AfterID)) -} -func (p WorkstreamPageHistoryParams) Check() error { - _, err := bounded("limit", p.Limit, 1, 200, 50) - return workstreamChecks(workstreamID(p.ID), err) -} -func (p WorkstreamPageRestoreParams) Check() error { - if (p.Version == nil) != (p.BaseVersion == nil) { - return Errorf(CodeBadParams, "version and base_version must be supplied together") - } - if p.Version != nil && (*p.Version < 1 || *p.BaseVersion < 0) { - return Errorf(CodeBadParams, "restore requires positive version and nonnegative base_version") - } - return workstreamID(p.ID) -} -func (p WorkstreamRecordListParams) Check() error { - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamOptionalEnum(p.Kind, "decision", "question", "goahead"), workstreamOptionalEnum(p.State, "proposed", "kept", "dropped", "superseded")) -} -func (p WorkstreamRecordProposeParams) Check() error { - if (p.Kind == "goahead") != (p.GoAhead != nil) || p.Kind == "goahead" && p.Kept != nil && *p.Kept { - return Errorf(CodeBadParams, "goahead is required only for kind goahead, which cannot be proposed kept") - } - if p.GoAhead != nil { - if err := p.GoAhead.Check(); err != nil { - return err - } - } - return workstreamChecks(workstreamID(p.WorkstreamID), workstreamEnum(p.Kind, "decision", "question", "goahead"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) -} -func (p WorkstreamRecordSupersedeParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalEnum(p.Kind, "decision", "question"), workstreamText(p.Text, 1, 2000, "record text"), workstreamSource(p.Source)) -} -func (p WorkstreamGoAheadGrantParams) Check() error { - if err := workstreamID(p.WorkstreamID); err != nil { - return err - } - return (WorkstreamGoAheadProposal{Scope: p.Scope, Factory: p.Factory, Job: p.Job, Wave: p.Wave, MilestoneID: p.MilestoneID, CapUSD: p.CapUSD, EndsAt: p.EndsAt}).Check() -} - -func (p WorkstreamGoAheadProposal) Check() error { - if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || p.EndsAt.IsZero() { - return Errorf(CodeBadParams, "go-ahead requires cap_usd greater than 0 and at most %d, and ends_at", workspace.WorkstreamGoAheadCapMax) - } - switch p.Scope { - case "job": - if p.Factory == nil || p.Job == nil || *p.Job <= 0 || p.Wave != nil || p.MilestoneID != nil { - return Errorf(CodeBadParams, "job scope requires factory and job only") - } - return workstreamID(*p.Factory) - case "wave": - if p.Wave == nil || *p.Wave <= 0 || p.Factory != nil || p.Job != nil || p.MilestoneID != nil { - return Errorf(CodeBadParams, "wave scope requires wave only") - } - case "milestone": - if p.MilestoneID == nil || p.Factory != nil || p.Job != nil || p.Wave != nil { - return Errorf(CodeBadParams, "milestone scope requires milestone_id only") - } - return workstreamID(*p.MilestoneID) - default: - return Errorf(CodeBadParams, "unknown go-ahead scope %q", p.Scope) - } - return nil -} - -func workstreamDraftFields(item WorkstreamPlanItem) error { - if item.Kind != "draft" && (item.Size != nil || item.BudgetUSD != nil || item.Source != nil) { - return Errorf(CodeBadParams, "size, budget_usd and source are allowed only on drafts") - } - if item.BudgetUSD != nil && (math.IsNaN(*item.BudgetUSD) || math.IsInf(*item.BudgetUSD, 0) || *item.BudgetUSD <= 0 || *item.BudgetUSD > workspace.WorkstreamGoAheadCapMax) { - return Errorf(CodeBadParams, "draft budget_usd must be greater than 0 and at most %d", workspace.WorkstreamGoAheadCapMax) - } - return workstreamChecks(workstreamOptionalEnum(item.Size, "S", "M", "L"), workstreamSource(item.Source)) -} - -func (p WorkstreamWakeListParams) Check() error { - _, err := bounded("limit", p.Limit, 1, 100, 20) - return workstreamChecks(workstreamID(p.WorkstreamID), err) -} -func (p WorkstreamWakeApplyParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ItemID)) -} -func (p WorkstreamWakeUndoParams) Check() error { - return workstreamChecks(workstreamID(p.ID), workstreamOptionalID(p.ItemID)) -} -func (p WorkstreamWakeNotNowParams) Check() error { return workstreamID(p.ID) } diff --git a/internal/appwire/workstreams_check_test.go b/internal/appwire/workstreams_check_test.go deleted file mode 100644 index 0437394f32..0000000000 --- a/internal/appwire/workstreams_check_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package appwire - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func TestStabEM4PlanBoundsBeforeDelegation(t *testing.T) { - draftID, title := "draft", "Draft" - draft := WorkstreamPlanItem{Kind: "draft", DraftID: &draftID, Title: &title} - for _, tc := range []struct { - name string - edit func(*WorkstreamPlanSetParams) - code string - }{ - {"draft text", func(p *WorkstreamPlanSetParams) { - text := strings.Repeat("x", workstream.PlanDraftTextMax+1) - p.Waves[0].Items[0].Text = &text - }, CodeTooLarge}, - {"milestones", func(p *WorkstreamPlanSetParams) { - p.Milestones = make([]WorkstreamMilestone, workstream.PlanMilestonesMax+1) - for i := range p.Milestones { - p.Milestones[i] = WorkstreamMilestone{ID: fmt.Sprintf("m%d", i), Title: "Milestone"} - } - }, CodeTooLarge}, - {"waves", func(p *WorkstreamPlanSetParams) { - p.Waves = make([]WorkstreamWave, workstream.PlanWavesMax+1) - for i := range p.Waves { - p.Waves[i] = WorkstreamWave{N: i + 1, Items: []WorkstreamPlanItem{}} - } - }, CodeTooLarge}, - {"items", func(p *WorkstreamPlanSetParams) { - p.Waves[0].Items = make([]WorkstreamPlanItem, workstream.PlanItemsMax+1) - for i := range p.Waves[0].Items { - p.Waves[0].Items[i] = draft - } - }, CodeTooLarge}, - {"control characters", func(p *WorkstreamPlanSetParams) { - text := "before\x00after" - p.Waves[0].Items[0].Text = &text - }, CodeBadParams}, - {"control characters past the text bound", func(p *WorkstreamPlanSetParams) { - text := strings.Repeat("x", workstream.PlanDraftTextMax) + "\x00" - p.Waves[0].Items[0].Text = &text - }, CodeBadParams}, - {"bad draft id", func(p *WorkstreamPlanSetParams) { - id := " invalid " - p.Waves[0].Items[0].DraftID = &id - }, CodeBadParams}, - {"at text bound", func(p *WorkstreamPlanSetParams) { - text := strings.Repeat("x", workstream.PlanDraftTextMax) - p.Waves[0].Items[0].Text = &text - }, CodeUnknownMethod}, - } { - t.Run(tc.name, func(t *testing.T) { - params := WorkstreamPlanSetParams{ID: "w", Milestones: []WorkstreamMilestone{}, Waves: []WorkstreamWave{{N: 1, Items: []WorkstreamPlanItem{draft}}}} - tc.edit(¶ms) - raw, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - _, err = methods["workstream.plan_set"].call(fake, raw) - wire, ok := err.(*Error) - if !ok || wire.Code != tc.code { - t.Fatalf("got %v, want %s", err, tc.code) - } - }) - } -} - -// B1: malformed requests are refused at the real typed dispatch door, before -// the serving fake (which always answers unknown_method) can receive them. -func TestWorkstreamBoundsBeforeDelegation(t *testing.T) { - cases := []struct{ method, params, code string }{ - {"workstream.get", `{}`, CodeBadParams}, - {"workstream.create", `{"name":""}`, CodeBadParams}, - {"workstream.create", `{"name":"x","client_id":"` + strings.Repeat("a", 129) + `"}`, CodeBadParams}, - {"workstream.create", `{"name":"x","why":"` + strings.Repeat("a", 4001) + `"}`, CodeBadParams}, - {"workstream.create", `{"name":"x","refs":[{"kind":"factory_job","id":"01","factory":"f"}]}`, CodeBadParams}, - {"workstream.create", `{"name":"x","refs":[` + strings.Repeat(`{"kind":"conversation","id":"c"},`, 200) + `{"kind":"conversation","id":"c"}]}`, CodeBadParams}, - {"workstream.update", `{"id":"x"}`, CodeBadParams}, - {"workstream.update", `{"id":"x","patch":{"autonomy":"auto"}}`, CodeBadParams}, - {"workstream.update", `{"id":"x","patch":{"wake":{"morning":false}}}`, CodeBadParams}, - {"workstream.mark_done", `{"id":"x","how_it_went":"` + strings.Repeat("a", 4001) + `"}`, CodeBadParams}, - {"workstream.plan_set", `{"id":"x","milestones":[],"waves":[]}`, CodeBadParams}, - {"workstream.plan_set", `{"id":"x","base_version":-1,"milestones":[],"waves":[]}`, CodeBadParams}, - {"workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":0,"auto_start":false,"items":[]}]}`, CodeBadParams}, - {"page.create", `{"workstream_id":"x","title":"x","owner":"other"}`, CodeBadParams}, - {"page.create", `{"workstream_id":"x","title":"x","doc":[]}`, CodeBadParams}, - {"page.update", `{"id":"x","title":""}`, CodeBadParams}, - {"page.update", `{"id":"x","title":"` + strings.Repeat("t", 201) + `"}`, CodeBadParams}, - {"page.save", `{"id":"x","base_version":1}`, CodeBadParams}, - {"page.save", `{"id":"x","base_version":1,"doc":{"text":"` + strings.Repeat("a", 1<<20) + `"}}`, CodeTooLarge}, - {"page.move", `{"id":"x","parent_id":""}`, CodeBadParams}, - {"page.history", `{"id":"x","limit":0}`, CodeBadParams}, - {"page.history", `{"id":"x","limit":201}`, CodeBadParams}, - {"page.restore", `{"id":"x","version":0,"base_version":1}`, CodeBadParams}, - // WP-F1 (a), (b): version reads are positive and restore versions are paired. - {"page.get", `{"id":"x","version":0}`, CodeBadParams}, - {"page.get", `{"id":"x","version":-1}`, CodeBadParams}, - {"page.restore", `{"id":"x","version":1}`, CodeBadParams}, - {"page.restore", `{"id":"x","base_version":1}`, CodeBadParams}, - {"page.restore", `{"id":"x","version":1,"base_version":-1}`, CodeBadParams}, - {"record.list", `{"workstream_id":"x","state":"active"}`, CodeBadParams}, - {"record.propose", `{"workstream_id":"x","kind":"decision","text":""}`, CodeBadParams}, - {"record.propose", `{"workstream_id":"x","kind":"decision","text":"` + strings.Repeat("t", 2001) + `"}`, CodeBadParams}, - {"record.propose", `{"workstream_id":"x","kind":"question","text":"x","source":{"kind":"message","id":"m"}}`, CodeBadParams}, - {"record.supersede", `{"id":"x","text":"x","kind":"other"}`, CodeBadParams}, - {"goahead.grant", `{"workstream_id":"x","scope":"wave","wave":1,"ends_at":"2099-01-01T00:00:00Z"}`, CodeBadParams}, - {"goahead.grant", `{"workstream_id":"x","scope":"wave","wave":1,"cap_usd":10001,"ends_at":"2099-01-01T00:00:00Z"}`, CodeBadParams}, - } - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for _, tc := range cases { - t.Run(tc.method+tc.params[:min(len(tc.params), 55)], func(t *testing.T) { - _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) - wire, ok := err.(*Error) - if !ok || wire.Code != tc.code { - t.Fatalf("got %v, want %s", err, tc.code) - } - }) - } -} - -func TestWorkstreamBoundsPreserveExplicitZeroAndFalse(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for method, params := range map[string]string{ - "workstream.plan_set": `{"id":"x","base_version":0,"milestones":[],"waves":[]}`, - "workstream.update": `{"id":"x","patch":{"why":"","budget_usd":0,"wake":{"morning":false,"related_issues":false}}}`, - "page.create": `{"workstream_id":"x","title":"x","read_by_jobs":false}`, - // WP-F1 (a), (b): additive params, including id-only restore, reach dispatch. - "page.get": `{"id":"x","version":1}`, - "page.list": `{"workstream_id":"x","include_deleted":true}`, - "page.restore": `{"id":"x"}`, - } { - _, err := methods[method].call(fake, json.RawMessage(params)) - if err != ErrWorkstreamsNotYet { - t.Fatalf("%s did not reach the fake: %v", method, err) - } - } -} - -func TestWorkstreamControlCharactersBeforeDelegation(t *testing.T) { - cases := []struct { - name, method, params string - prose bool - }{ - {"name", "workstream.create", `{"name":$text}`, false}, - {"rename", "workstream.update", `{"id":"x","patch":{"name":$text}}`, false}, - {"client_id", "workstream.create", `{"name":"x","client_id":$text}`, false}, - {"page title", "page.create", `{"workstream_id":"x","title":$text}`, false}, - {"updated page title", "page.update", `{"id":"x","title":$text}`, false}, - {"milestone title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[{"id":"","title":$text}],"waves":[]}`, false}, - {"wave title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"title":$text,"auto_start":false,"items":[]}]}`, false}, - {"draft title", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":$text}]}]}`, false}, - {"source label", "record.propose", `{"workstream_id":"x","kind":"decision","text":"x","source":{"kind":"chat","id":"c","label":$text}}`, false}, - {"replacement source label", "record.supersede", `{"id":"x","text":"x","source":{"kind":"chat","id":"c","label":$text}}`, false}, - {"why", "workstream.create", `{"name":"x","why":$text}`, true}, - {"updated why", "workstream.update", `{"id":"x","patch":{"why":$text}}`, true}, - {"how_it_went", "workstream.mark_done", `{"id":"x","how_it_went":$text}`, true}, - {"record text", "record.propose", `{"workstream_id":"x","kind":"question","text":$text}`, true}, - {"replacement text", "record.supersede", `{"id":"x","text":$text}`, true}, - {"draft text", "workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft","text":$text}]}]}`, true}, - } - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for _, tc := range cases { - for control := rune(0); control <= 0x9f; control++ { - if control > 0x1f && control < 0x7f { - continue - } - t.Run(fmt.Sprintf("%s/U+%04X", tc.name, control), func(t *testing.T) { - value, err := json.Marshal("before" + string(control) + "after") - if err != nil { - t.Fatal(err) - } - params := strings.ReplaceAll(tc.params, "$text", string(value)) - _, err = methods[tc.method].call(fake, json.RawMessage(params)) - if tc.prose && (control == '\n' || control == '\t') { - if err != ErrWorkstreamsNotYet { - t.Fatalf("allowed prose did not reach the fake: %v", err) - } - return - } - wire, ok := err.(*Error) - if !ok || wire.Code != CodeBadParams { - t.Fatalf("got %v, want %s before delegation", err, CodeBadParams) - } - }) - } - } -} - -func TestWorkstreamTextFieldsPreserveAllowedContent(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for _, tc := range []struct{ method, params string }{ - {"workstream.create", `{"name":"Launch","client_id":"","why":""}`}, - {"workstream.create", `{"name":"Launch","client_id":" retry ","why":"first\n\tsecond"}`}, - {"page.create", `{"workstream_id":"x","title":" Résumé 界 "}`}, - {"record.propose", `{"workstream_id":"x","kind":"question","text":"first\n\tsecond","source":{"kind":"chat","id":"c","label":" Label "}}`}, - {"workstream.plan_set", `{"id":"x","base_version":0,"milestones":[],"waves":[{"n":1,"title":"","auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":" Draft ","text":""}]}]}`}, - } { - t.Run(tc.method+tc.params, func(t *testing.T) { - _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) - if err != ErrWorkstreamsNotYet { - t.Fatalf("allowed content did not reach the fake: %v", err) - } - }) - } -} diff --git a/internal/appwire/workstreams_fields_v02_test.go b/internal/appwire/workstreams_fields_v02_test.go deleted file mode 100644 index 3436aaff90..0000000000 --- a/internal/appwire/workstreams_fields_v02_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package appwire - -import ( - "encoding/json" - "math" - "strings" - "testing" -) - -// WP-F2 (a), (b), (d), (f): typed dispatch refuses malformed additive fields. -func TestWorkstreamV02ParamsBeforeDelegation(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - for _, fields := range []string{`"size":"XL"`, `"size":""`, `"budget_usd":0`, `"budget_usd":-1`, `"budget_usd":10000.01`, `"source":{"kind":"message","id":"m"}`} { - params := `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft",` + fields + `}]}]}` - _, err := methods["workstream.plan_set"].call(fake, json.RawMessage(params)) - workstreamV02BadParams(t, err) - } - for _, fields := range []string{`"size":"S"`, `"size":""`, `"budget_usd":1`, `"budget_usd":0`, `"source":{"kind":"chat","id":"c"}`} { - params := `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"job","factory":"f","job":1,` + fields + `}]}]}` - _, err := methods["workstream.plan_set"].call(fake, json.RawMessage(params)) - workstreamV02BadParams(t, err) - } - proposal := `{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"2026-11-04T00:00:00Z"}` - for _, tc := range []struct{ method, params string }{ - {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why"}`}, - {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why","kept":true,"goahead":` + proposal + `}`}, - {"record.propose", `{"workstream_id":"w","kind":"decision","text":"Why","goahead":` + proposal + `}`}, - {"record.propose", `{"workstream_id":"w","kind":"goahead","text":"Why","goahead":{"scope":"wave","wave":1,"cap_usd":1}}`}, - {"record.supersede", `{"id":"r","kind":"goahead","text":"Why"}`}, - {"page.save", `{"id":"p","base_version":1,"doc":{},"source":{"kind":"message","id":"m"}}`}, - {"page.create", `{"workstream_id":"w","title":"Page","source":{"kind":"chat","id":"c","factory":"f"}}`}, - {"page.create", `{"workstream_id":"w","title":"Page","source":{"kind":"page","id":"p","chat_id":"c"}}`}, - } { - _, err := methods[tc.method].call(fake, json.RawMessage(tc.params)) - workstreamV02BadParams(t, err) - } - for _, value := range []float64{math.NaN(), math.Inf(1)} { - id, title := "d", "Draft" - p := WorkstreamPlanSetParams{ID: "w", Milestones: []WorkstreamMilestone{}, Waves: []WorkstreamWave{{N: 1, Items: []WorkstreamPlanItem{{Kind: "draft", DraftID: &id, Title: &title, BudgetUSD: &value}}}}} - workstreamV02BadParams(t, p.Check()) - } -} - -func workstreamV02BadParams(t *testing.T, err error) { - t.Helper() - wire, ok := err.(*Error) - if !ok || wire.Code != CodeBadParams { - t.Fatalf("want bad_params, got %v", err) - } -} - -// WP-F2 (a), (b), (d), (f): each valid additive field reaches the typed delegate. -func TestWorkstreamV02ParamsReachDelegation(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - source := `{"kind":"message","id":"m","chat_id":"c","label":"Scope"}` - for method, params := range map[string]string{ - "workstream.plan_set": `{"id":"w","base_version":0,"milestones":[],"waves":[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"d","title":"Draft","size":"M","budget_usd":10000,"source":` + source + `}]}]}`, - "page.save": `{"id":"p","doc":{},"base_version":1,"source":` + source + `}`, - "page.create": `{"workstream_id":"w","title":"Page","source":` + source + `}`, - "record.list": `{"workstream_id":"w","kind":"goahead","include_goaheads":false}`, - "record.propose": `{"workstream_id":"w","kind":"goahead","text":"Why","kept":false,"goahead":{"scope":"wave","wave":1,"cap_usd":1,"ends_at":"2026-11-04T00:00:00Z"}}`, - } { - _, err := methods[method].call(fake, json.RawMessage(params)) - if err != ErrWorkstreamsNotYet { - t.Fatalf("%s: %v", method, err) - } - } - for _, kind := range []string{"chat", "message", "factory_job", "page", "url"} { - p := WorkstreamSource{Kind: kind, ID: "7"} - chat, factory := "chat", "factory" - if kind == "message" { - p.ChatID = &chat - } - if kind == "factory_job" { - p.Factory = &factory - } - if err := p.Check(); err != nil { - t.Fatalf("%s: %v", kind, err) - } - label := strings.Repeat("x", 201) - p.Label = &label - workstreamV02BadParams(t, p.Check()) - } -} diff --git a/internal/appwire/workstreams_sources.go b/internal/appwire/workstreams_sources.go deleted file mode 100644 index 9fb97e8888..0000000000 --- a/internal/appwire/workstreams_sources.go +++ /dev/null @@ -1,146 +0,0 @@ -package appwire - -import ( - "encoding/json" - "time" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -type WorkstreamKeptRef struct { - Kind string `json:"kind" enum:"record,page,message,factory_job"` - ID string `json:"id"` - ChatID *string `json:"chat_id,omitempty"` - Factory *string `json:"factory,omitempty"` -} - -type WorkstreamSourceAdd struct { - Kind string `json:"kind" enum:"file,repo,issue,link,kept"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *WorkstreamKeptRef `json:"ref,omitempty"` - Title *string `json:"title,omitempty"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` -} - -type WorkstreamSourceItem struct { - ID string `json:"id"` - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"file,repo,issue,link,kept"` - Title string `json:"title"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *WorkstreamKeptRef `json:"ref,omitempty"` - ReadByJobs bool `json:"read_by_jobs"` - Proposed bool `json:"proposed"` - Why *string `json:"why,omitempty"` - AddedBy string `json:"added_by" enum:"you,agent"` - AddedAt time.Time `json:"added_at"` - Gone *bool `json:"gone,omitempty"` -} - -type WorkstreamSourceListParams struct { - WorkstreamID string `json:"workstream_id"` -} -type WorkstreamSourceListResult struct { - Sources []WorkstreamSourceItem `json:"sources"` -} - -type WorkstreamSourceAddParams struct { - WorkstreamID string `json:"workstream_id"` - Kind string `json:"kind" enum:"file,repo,issue,link,kept"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *WorkstreamKeptRef `json:"ref,omitempty"` - Title *string `json:"title,omitempty"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` -} -type WorkstreamSourceAddResult struct { - Source WorkstreamSourceItem `json:"source"` - Already bool `json:"already"` -} - -type WorkstreamSourceUpdateParams struct { - ID string `json:"id"` - Title *string `json:"title,omitempty"` - ReadByJobs *bool `json:"read_by_jobs,omitempty"` - Keep *bool `json:"keep,omitempty"` -} -type WorkstreamSourceUpdateResult struct { - Source WorkstreamSourceItem `json:"source"` -} -type WorkstreamSourceRemoveParams struct { - ID string `json:"id"` -} -type WorkstreamSourceRemoveResult struct { - Source WorkstreamSourceItem `json:"source"` -} - -// Check raw source bytes before the JSON decoder can replace invalid UTF-8. -func workstreamDecodeSource(raw []byte, target any) error { - if !utf8.Valid(raw) { - return Errorf(CodeBadParams, "source fields must be valid UTF-8") - } - return json.Unmarshal(raw, target) -} - -func (p *WorkstreamSourceAdd) UnmarshalJSON(raw []byte) error { - type plain WorkstreamSourceAdd - return workstreamDecodeSource(raw, (*plain)(p)) -} - -func (p *WorkstreamSourceAddParams) UnmarshalJSON(raw []byte) error { - type plain WorkstreamSourceAddParams - return workstreamDecodeSource(raw, (*plain)(p)) -} - -func (p *WorkstreamSourceUpdateParams) UnmarshalJSON(raw []byte) error { - type plain WorkstreamSourceUpdateParams - return workstreamDecodeSource(raw, (*plain)(p)) -} - -// SourceAdd preserves optional field presence for the domain's kind checks. -func (p WorkstreamSourceAdd) SourceAdd() workstream.SourceAdd { - out := workstream.SourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Title: p.Title} - if p.ReadByJobs != nil { - out.ReadByJobs = *p.ReadByJobs - } - if p.Ref != nil { - out.Ref = &workstream.KeptRef{Kind: p.Ref.Kind, ID: p.Ref.ID, ChatID: p.Ref.ChatID, Factory: p.Ref.Factory} - } - return out -} - -func (p WorkstreamSourceAddParams) SourceAdd() workstream.SourceAdd { - return (WorkstreamSourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Ref: p.Ref, Title: p.Title, ReadByJobs: p.ReadByJobs}).SourceAdd() -} - -func (p WorkstreamSourceListParams) Check() error { return workstreamID(p.WorkstreamID) } -func (p WorkstreamSourceRemoveParams) Check() error { return workstreamID(p.ID) } - -func (p WorkstreamSourceAddParams) Check() error { - if err := workstreamID(p.WorkstreamID); err != nil { - return err - } - if err := workstream.ValidateSourceAdd(p.SourceAdd()); err != nil { - return Errorf(CodeBadParams, "%s", err) - } - return nil -} - -func (p WorkstreamSourceUpdateParams) Check() error { - if err := workstreamID(p.ID); err != nil { - return err - } - if err := workstream.ValidateSourcePatch(workstream.SourcePatch{Title: p.Title, ReadByJobs: p.ReadByJobs, Keep: p.Keep}); err != nil { - return Errorf(CodeBadParams, "%s", err) - } - return nil -} diff --git a/internal/appwire/workstreams_sources_test.go b/internal/appwire/workstreams_sources_test.go deleted file mode 100644 index 7804081326..0000000000 --- a/internal/appwire/workstreams_sources_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package appwire - -import ( - "encoding/json" - "reflect" - "testing" -) - -// WP-12 wire capability: both features follow the same hook and remain absent -// for stub/plain engines. Optional fields omit while required false flags stay. -func TestWorkstreamSourcesFeatureAndRequiredBooleans(t *testing.T) { - for _, serves := range []bool{false, true} { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: serves} - hello := withWorkstreamsFeature(withWorkstreamsFeature(Hello{}, fake), fake) - want := []string(nil) - if serves { - want = []string{FeatureWorkstreams, FeatureWorkstreamSources} - } - if !reflect.DeepEqual(hello.Features, want) { - t.Fatalf("features: %v", hello.Features) - } - } - raw, err := json.Marshal(WorkstreamSourceItem{ID: "source", WorkstreamID: "stream", Kind: "link", Title: "Example", AddedBy: "you"}) - if err != nil { - t.Fatal(err) - } - var fields map[string]any - if err := json.Unmarshal(raw, &fields); err != nil { - t.Fatal(err) - } - for _, key := range []string{"read_by_jobs", "proposed"} { - if value, exists := fields[key]; !exists || value != false { - t.Fatalf("required false missing: %s", raw) - } - } - for _, key := range []string{"path", "repo", "number", "url", "ref", "why", "gone"} { - if _, exists := fields[key]; exists { - t.Fatalf("absent optional present: %s", raw) - } - } -} - -// WP-12 (b): typed dispatch rejects missing/null fields before reaching the -// serving engine, including inside a kept ref and create's sources array. -func TestWorkstreamSourcesRequiredFieldsOnWire(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - call := serveOver(t, fake) - for _, tc := range []struct{ method, params string }{ - {"source.list", `{}`}, - {"source.add", `{"workstream_id":"w","kind":"link","url":null}`}, - {"source.add", `{"workstream_id":"w","kind":"kept","ref":{"kind":"message","chat_id":"c"}}`}, - {"source.update", `{"id":"s","keep":null}`}, - {"source.remove", `{"id":null}`}, - {"workstream.create", `{"name":"Sources","sources":[{"path":"x"}]}`}, - {"workstream.create", `{"name":"Sources","sources":null}`}, - } { - frame := call(`{"id":1,"method":"` + tc.method + `","params":` + tc.params + `}`) - refusal, _ := frame["error"].(map[string]any) - if refusal["code"] != CodeBadParams { - t.Fatalf("%s: %v", tc.method, frame) - } - } -} diff --git a/internal/appwire/workstreams_test.go b/internal/appwire/workstreams_test.go deleted file mode 100644 index 1b51487466..0000000000 --- a/internal/appwire/workstreams_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package appwire - -import ( - "encoding/json" - "fmt" - "slices" - "strings" - "testing" -) - -// fakeWorkstreamer exercises V4 without a store, Factory or engine binary. -type fakeWorkstreamer struct { - *fakeEngine - serves bool -} - -var _ Workstreamer = (*fakeWorkstreamer)(nil) - -func (f *fakeWorkstreamer) ServesWorkstreams() bool { return f.serves } - -func (f *fakeWorkstreamer) WorkstreamList(WorkstreamListParams) (WorkstreamListResult, error) { - return WorkstreamListResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamGet(WorkstreamGetParams) (WorkstreamGetResult, error) { - return WorkstreamGetResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamCreate(WorkstreamCreateParams) (WorkstreamCreateResult, error) { - return WorkstreamCreateResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamUpdate(WorkstreamUpdateParams) (WorkstreamUpdateResult, error) { - return WorkstreamUpdateResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamMarkDone(WorkstreamMarkDoneParams) (WorkstreamMarkDoneResult, error) { - return WorkstreamMarkDoneResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamReopen(WorkstreamReopenParams) (WorkstreamReopenResult, error) { - return WorkstreamReopenResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPlanGet(WorkstreamPlanGetParams) (WorkstreamPlanGetResult, error) { - return WorkstreamPlanGetResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPlanSet(WorkstreamPlanSetParams) (WorkstreamPlanSetResult, error) { - return WorkstreamPlanSetResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageList(WorkstreamPageListParams) (WorkstreamPageListResult, error) { - return WorkstreamPageListResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageGet(WorkstreamPageGetParams) (WorkstreamPageGetResult, error) { - return WorkstreamPageGetResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageSave(WorkstreamPageSaveParams) (WorkstreamPageSaveResult, error) { - return WorkstreamPageSaveResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageCreate(WorkstreamPageCreateParams) (WorkstreamPageCreateResult, error) { - return WorkstreamPageCreateResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageUpdate(WorkstreamPageUpdateParams) (WorkstreamPageUpdateResult, error) { - return WorkstreamPageUpdateResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageMove(WorkstreamPageMoveParams) (WorkstreamPageMoveResult, error) { - return WorkstreamPageMoveResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageDelete(WorkstreamPageDeleteParams) (WorkstreamPageDeleteResult, error) { - return WorkstreamPageDeleteResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageHistory(WorkstreamPageHistoryParams) (WorkstreamPageHistoryResult, error) { - return WorkstreamPageHistoryResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamPageRestore(WorkstreamPageRestoreParams) (WorkstreamPageRestoreResult, error) { - return WorkstreamPageRestoreResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordList(WorkstreamRecordListParams) (WorkstreamRecordListResult, error) { - return WorkstreamRecordListResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordPropose(WorkstreamRecordProposeParams) (WorkstreamRecordProposeResult, error) { - return WorkstreamRecordProposeResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordKeep(WorkstreamRecordKeepParams) (WorkstreamRecordKeepResult, error) { - return WorkstreamRecordKeepResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordDrop(WorkstreamRecordDropParams) (WorkstreamRecordDropResult, error) { - return WorkstreamRecordDropResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamRecordSupersede(WorkstreamRecordSupersedeParams) (WorkstreamRecordSupersedeResult, error) { - return WorkstreamRecordSupersedeResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamGoAheadList(WorkstreamGoAheadListParams) (WorkstreamGoAheadListResult, error) { - return WorkstreamGoAheadListResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamGoAheadGrant(WorkstreamGoAheadGrantParams) (WorkstreamGoAheadGrantResult, error) { - return WorkstreamGoAheadGrantResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamGoAheadRevoke(WorkstreamGoAheadRevokeParams) (WorkstreamGoAheadRevokeResult, error) { - return WorkstreamGoAheadRevokeResult{}, ErrWorkstreamsNotYet -} - -func (f *fakeWorkstreamer) WorkstreamSourceList(WorkstreamSourceListParams) (WorkstreamSourceListResult, error) { - return WorkstreamSourceListResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSourceAdd(WorkstreamSourceAddParams) (WorkstreamSourceAddResult, error) { - return WorkstreamSourceAddResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSourceUpdate(WorkstreamSourceUpdateParams) (WorkstreamSourceUpdateResult, error) { - return WorkstreamSourceUpdateResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSourceRemove(WorkstreamSourceRemoveParams) (WorkstreamSourceRemoveResult, error) { - return WorkstreamSourceRemoveResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSuggestionList(WorkstreamSuggestionListParams) (WorkstreamSuggestionListResult, error) { - return WorkstreamSuggestionListResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSuggestionKeep(WorkstreamSuggestionKeepParams) (WorkstreamSuggestionKeepResult, error) { - return WorkstreamSuggestionKeepResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamSuggestionDrop(WorkstreamSuggestionDropParams) (WorkstreamSuggestionDropResult, error) { - return WorkstreamSuggestionDropResult{}, ErrWorkstreamsNotYet -} - -func workstreamHelloHasFeature(hello map[string]any) bool { - for _, feature := range features(hello) { - if feature == FeatureWorkstreams { - return true - } - } - return false -} - -// V1, WP-12, WP-6d: the contract index and dispatch table name the same 36 methods. -func TestEveryWorkstreamMethodIsRegistered(t *testing.T) { - if len(WorkstreamMethods) != 36 { - t.Fatalf("contract has %d methods, want 36", len(WorkstreamMethods)) - } - known := make(map[string]bool, len(WorkstreamMethods)) - served := Methods() - for _, name := range WorkstreamMethods { - if known[name] { - t.Errorf("duplicate contract method %s", name) - } - known[name] = true - if !slices.Contains(served, name) { - t.Errorf("method not registered: %s", name) - } - } - for _, name := range served { - for _, prefix := range []string{"workstream.", "page.", "record.", "goahead.", "wake.", "source.", "suggestion."} { - if strings.HasPrefix(name, prefix) && !known[name] { - t.Errorf("registered method not in contract: %s", name) - } - } - } - if !slices.Contains(Events(), EventWorkstreamChanged) { - t.Error("workstream.changed is not registered") - } -} - -// V4: every registered stub refuses on the wire, without claiming the feature. -func TestWorkstreamMethodsAnswerNotYet(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}} - w := startWireOver(t, fake, fake.fakeEngine) - if workstreamHelloHasFeature(w.next()) { - t.Fatal("the stub claims workstreams") - } - for i, name := range WorkstreamMethods { - t.Run(name, func(t *testing.T) { - w.send(fmt.Sprintf(`{"id":%d,"method":%q,"params":{}}`, i+1, name)) - got := w.next() - if got["id"] != float64(i+1) { - t.Fatal("response id does not match request") - } - err, ok := got["error"].(map[string]any) - if !ok { - t.Fatal("stub response has no error") - } - if err["code"] != CodeUnknownMethod { - t.Errorf("code = %v, want unknown_method", err["code"]) - } - if err["message"] != "workstreams: not implemented yet" { - t.Errorf("message = %v", err["message"]) - } - }) - } -} - -// V4: both interface support and ServesWorkstreams are required for hello. -func TestWorkstreamsFeatureFollowsTheEngine(t *testing.T) { - fake := &fakeWorkstreamer{fakeEngine: &fakeEngine{}, serves: true} - w := startWireOver(t, fake, fake.fakeEngine) - if !workstreamHelloHasFeature(w.next()) { - t.Fatal("serving engine does not announce workstreams") - } - plain := startWire(t, &fakeEngine{}) - if workstreamHelloHasFeature(plain.next()) { - t.Fatal("plain engine claims workstreams") - } - plain.send(`{"id":1,"method":"workstream.list","params":{}}`) - got := plain.next() - err, ok := got["error"].(map[string]any) - if !ok || err["code"] != CodeUnknownMethod { - t.Fatal("plain engine does not answer unknown_method") - } - if err["message"] != "this engine does not serve workstreams" { - t.Errorf("plain engine message = %v", err["message"]) - } - hello := withWorkstreamsFeature(Hello{}, fake) - hello = withWorkstreamsFeature(hello, fake) - if len(hello.Features) != 2 || hello.Features[0] != FeatureWorkstreams || hello.Features[1] != FeatureWorkstreamSources { - t.Fatal("feature hook must add workstreams and sources exactly once") - } -} - -// V1: typed handlers and event registration expose the complete contract schema. -func TestWorkstreamSchemaNamesEveryMethod(t *testing.T) { - out, err := SchemaJSON() - if err != nil { - t.Fatal(err) - } - var doc struct { - Methods map[string]json.RawMessage `json:"methods"` - Events map[string]json.RawMessage `json:"events"` - Defs map[string]struct { - Properties map[string]map[string]any `json:"properties"` - } `json:"$defs"` - } - if err := json.Unmarshal(out, &doc); err != nil { - t.Fatal(err) - } - for _, name := range WorkstreamMethods { - if doc.Methods[name] == nil { - t.Errorf("schema lacks %s", name) - } - } - if doc.Events[EventWorkstreamChanged] == nil { - t.Error("schema lacks workstream.changed") - } - words, _ := doc.Defs["Workstream"].Properties["stage"]["enum"].([]any) - if len(words) != 3 { - t.Fatalf("Workstream.stage has %d words, want 3", len(words)) - } - for i, want := range []string{"shaping", "building", "done"} { - if words[i] != want { - t.Errorf("Workstream.stage word %d = %v, want %s", i, words[i], want) - } - } -} - -func (f *fakeWorkstreamer) WorkstreamWakeList(WorkstreamWakeListParams) (WorkstreamWakeListResult, error) { - return WorkstreamWakeListResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamWakeApply(WorkstreamWakeApplyParams) (WorkstreamWakeApplyResult, error) { - return WorkstreamWakeApplyResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamWakeUndo(WorkstreamWakeUndoParams) (WorkstreamWakeUndoResult, error) { - return WorkstreamWakeUndoResult{}, ErrWorkstreamsNotYet -} -func (f *fakeWorkstreamer) WorkstreamWakeNotNow(WorkstreamWakeNotNowParams) (WorkstreamWakeNotNowResult, error) { - return WorkstreamWakeNotNowResult{}, ErrWorkstreamsNotYet -} - -func TestWakeJ_ParameterBounds(t *testing.T) { - for _, p := range []interface{ Check() error }{WorkstreamWakeListParams{}, WorkstreamWakeApplyParams{}, WorkstreamWakeUndoParams{}, WorkstreamWakeNotNowParams{}, WorkstreamWakeListParams{WorkstreamID: "w", Limit: workstreamWireInt(101)}, WorkstreamWakeApplyParams{ID: "w", ItemID: workstreamWireString("")}} { - if p.Check() == nil { - t.Errorf("accepted %+v", p) - } - } - for _, p := range []interface{ Check() error }{WorkstreamWakeListParams{WorkstreamID: "w"}, WorkstreamWakeApplyParams{ID: "w"}, WorkstreamWakeUndoParams{ID: "w"}, WorkstreamWakeNotNowParams{ID: "w"}} { - if err := p.Check(); err != nil { - t.Error(err) - } - } -} -func workstreamWireInt(n int) *int { return &n } -func workstreamWireString(s string) *string { return &s } diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index c52f36b408..4816a949d2 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -110,11 +110,9 @@ different jobs. Use the same `--factory` with `find` and `remove`. Other kinds refuse a factory ID. `show` prints `factory_job factory `; `--json` includes the `factory` field. -A plain collection can reference a job alongside any other records. A job can -belong to only one workstream collection. Adding a job already in another -workstream answers `that job belongs to another workstream` and leaves every -reference in that add unchanged. These commands retain references without asking -the Factory whether the job is available and never start a job. +A collection can reference a job alongside any other records, and several +collections can hold the same job. These commands retain references without +asking the Factory whether the job is available and never start a job. ## Deleting or duplicating a collection @@ -125,226 +123,12 @@ referenced is deleted, moved or stopped. `codeaf collections duplicate ` makes a new collection holding the same references, in the same order; the new name follows the same uniqueness rule as `create`. -A workstream collection refuses deletion with `mark a workstream done instead`. -Duplicating one makes a plain collection with the same references; it creates no -workstream and claims no jobs. -## Workstreams in an app client: stages and agent chats +## Collections database versions and older engines -An app client can create a workstream backed by a collection with the same ID. -It includes one agent conversation, an optional origin chat, and the references -you choose. Creation opens the agent without sending a first message or running -a model. Repeating a nonempty client ID returns the original workstream. -Names follow collection naming rules; the client ID is at most 128 bytes and a -create takes at most 200 explicit references. - -A workstream starts in Shaping. Observing a member job queued, running, needing -you or done changes it to Building, which persists even if jobs return to Backlog. -Only marking the workstream done gives Done; all jobs merged does not. Reopening -returns it to Building. Marking done can archive its member chats while keeping -the agent's record. Collection rename and membership commands use the same store. - -## Workstream pages, records, plans and go-aheads - -Pages, page history, plans, decisions, questions and settings live in SQLite. -Markdown is an export, not a second store. A new workstream has an Overview page; -it can be renamed but cannot move or be deleted. Other pages can form a tree; -deleting a page deletes its descendants. Saving a stale page or plan refuses -without changing it. Restoring a page creates a new version. Page titles take -1–200 bytes and editor documents at most 1 MiB; record text takes 1–2000 bytes. -Client IDs, titles and source labels refuse control characters. Why, how it went, -record text and draft text allow newline and tab, and refuse other control -characters. Malformed app requests return `bad_params`. - -Decisions and questions can be proposed, kept, dropped and replaced. Keep can undo a drop; -a replaced record cannot change again. Plans store milestones, ordered waves -and job or draft items. Wave auto-start is stored without starting jobs. -A plan holds at most 500 milestones, 500 waves and 5,000 items; its stored -milestones and waves together encode to at most 4 MiB of JSON. -Plans beyond these limits return `too_large` without changing anything or -sending a change event. - -A go-ahead requires a member job, a planned wave or milestone, a positive cap -at most $10,000 and an end in the future at most 90 days away. Expired grants -read as ended. Marking done ends every still-active grant; granting on a done -workstream is refused. These are stored permissions; Factory enforcement is a -separate capability. Workstream numbers use recorded job facts and agent spend. -Updating, marking done and reopening persist the first observed start and return -those numbers using the same reads as getting the workstream. -Unavailable readings stay absent; a measured zero is present. Budgets and autonomy -settings do not themselves start work. - -## What a workstream's agent can do: proposals, pages and suggested edits - -A workstream's own agent in the app has eight tools. Other conversations, tasks -and errands do not receive them. `workstream_read` reads the workstream, pages, -decisions, questions, go-ahead proposals, plan, grants and sources. Counts, costs, states and dates come -from records. An absent number is unknown; the agent must say so and never -estimate it. Page live blocks such as `[live: jobs]` show current facts. - -`propose_decision` and `propose_question` create proposals by the agent. The -agent proposes; the person keeps. `propose_jobs` adds 1–20 draft jobs to the -plan, leaving existing waves and settings intact. Drafts create no Factory -jobs and start nothing; the person launches them. A plan conflict is retried -once against a fresh read, then refused if it changes again. -Each `propose_jobs` draft's text is at most 4,000 bytes; a plan stores draft -text up to 80,000 bytes. A workstream holds at most 5,000 pages, including -deleted pages, and 5,000 records of every kind and state. - -`write_agent_page` creates or updates only the agent's own pages. Every write -carries an AI mark, its written-at time and a named source; history keeps that -attribution. New pages titled Spec or Decisions have Read by jobs on, ignoring -case and surrounding space; other new pages have it off. Updates keep the flag. -The agent refuses the person's pages with “That page is the person's. You write -only your own pages; suggest edits on theirs with suggest_page_edit.” Page -documents are limited to 1 MiB. A page that changed must be read again before -writing. Done workstreams refuse all these writes. - -Tool arguments refuse unknown fields and repeated object keys, including inside -sources and draft jobs, before reading or writing the workstream. The agent keeps -its page titled “Where it stands” current; the Overview shows it with the AI mark. - -## Suggested edits on your workstream pages: keep or drop - -`suggest_page_edit` suggests 1–50 edits on one of your pages, using its version -from `workstream_read`. The agent quotes unique plain text from one paragraph, -heading, list item or code block, without Markdown marks or escapes. Each find -is 1–4,000 bytes; each replacement is at most 4,000 bytes. The suggestion and -each edit carry a reason of 1–200 bytes on one line. Only code blocks accept -line breaks in replacements. A workstream holds at most 5,000 suggestions, -including decided suggestions. Page reads include open suggestions. - -You keep or drop one edit or all proposed edits in a suggestion. Until you keep -an edit, the page is unchanged. Each keep makes one new page version attributed -to you, with the suggestion as its source; keep all still makes only one version. -Drop leaves the page and history untouched. Remaining edits in the same block -move with kept text. An unchanged block can move in the document and still apply; -changing its text makes its edits stale. A keep with a stale edit changes nothing. -Done workstreams allow reading suggestions and refuse creating, keeping or dropping them. -The suggestion source kind is written only by keeping; source parameters refuse it. - -## Workstream draft size, budget and sources - -Draft plan items accept optional size `S`, `M` or `L`, a budget greater than zero -and at most $10,000, and a source. Job plan items refuse these fields. Older -plans without them still read normally. These fields are stored without -starting work. The agent cannot set draft size or budget. `propose_jobs` gives -each added draft the agent conversation as its source, or an explicit page, -member job or HTTP(S) link. Existing items and a conflict retry keep their sources. - -App page creation and saves accept an optional source: chat, message, Factory -job, page or URL. A message source needs its chat ID; a job source needs its -Factory ID. Source labels take at most 200 bytes on one line. The source becomes -the page's `updated_from` and that version's `from` in history. A later save -without a source clears `updated_from` and has no `from` on its version. - -## Workstream go-ahead proposals, keeping and undo - -`propose_goahead` makes a proposed `goahead` record with the reason as its text -and structured scope, target, cap and end. It grants nothing. Keeping it grants -exactly one go-ahead atomically; keeping it again changes nothing. Dropping a -kept proposal revokes its still-active grant. Keeping it after that grants a -new one. Records retain the latest grant ID, including after a drop. Go-ahead -records cannot be replaced with `record.supersede`. - -The proposed cap is greater than zero and at most $10,000, including fractional -dollars such as $12.50. The agent's `ends_on` date must be after today in UTC and -at most 89 days ahead; the end is that day's 23:59:59 UTC. Proposal and keep both -check that the target exists and the end is future and within 90 days. Proposing -or keeping on a done workstream conflicts. A refused keep changes nothing. -Expiry, revocation and marking done leave a kept record kept while ending its grant. - -`record.list` returns decisions and questions by default. Use -`include_goaheads: true` for every kind, or `kind: goahead` for only go-ahead -records. The agent's record reads include go-ahead proposals and their grant IDs. - -## Watching workstreams: morning wakes, costs, Apply, Undo and Not now - -With morning wakes on, a workstream can wake once for today's 08:00 slot in -this machine's zone, while the app is open, once that slot has passed and if it -is at or after creation. Before 08:00 no morning slot is eligible; closed days -are never caught up. -The app first checks after 30 seconds, then every minute; opening late wakes -once, without replaying a backlog of missed days. Related-issue wakes are -stored as a preference but are not available yet. Job-event handling is ready -for finished, merged, failed and needs-you events; automatic delivery awaits -the Factory event source. Nothing else wakes the agent. Done or autonomy Off -stops new wakes. - -Each wake makes one model call, bounded to three minutes, and returns one card -with a short summary and up to ten ranked applied changes and proposals. Propose only applies -nothing. Small changes can move plan items to manual waves without a wave -go-ahead, add follow-up drafts with a budget below $5 to a manual wave, and -write the agent's pages. A draft stays a draft. Decisions and questions remain -proposals. The person's pages are never written by a wake. - -## Applying and undoing wake proposals: Apply, Undo applied and Not now - -Apply chooses one or all proposals, regardless of autonomy; a decision or -question becomes kept. Undo applied restores changes only while their targets -are unchanged. Updated pages restore a previous version as a new agent version; -new pages are restorably deleted. A changed target refuses a single Undo; -Undo applied leaves that item with “Changed since; not undone.” Not now -dismisses the remaining proposals. Repeating an action changes nothing. -Done workstreams refuse Apply and Undo. - -Every recorded wake cost counts toward workstream spend. Unknown costs stay -absent. At most 24 wakes per workstream and 200 per home can run per local day, -with one turn at a time. A running wake left over 15 minutes becomes failed on -the next check or list: “Stopped before it finished.” Its cost is unknown. -An unreadable answer says “The agent's answer could not be read.” Any recorded -cost still counts toward spend. - -## Workstream sources: files, repos, issues, links and kept work - -The app's workstream Sources are pointers kept with `source.add` and returned -oldest first by `source.list`. Five kinds are available: a file or folder on -this computer; a repo named `owner/name` or a local folder; an issue with its -repo and number; an HTTP(S) link; or a kept record, page, message or member -Factory job. They are separate from collection members and canvases. Removing -a source with `source.remove` removes only its pointer and leaves its target -intact. The engine does not copy or index source contents. - -File paths must exist, be absolute and already clean, with no control -characters, dot segments, doubled separators or trailing separator. Local -repos must be folders. Source strings must be valid UTF-8. Paths take at most -4,096 bytes; links at most 2,048 both as sent and after URL normalization, -with a host and no whitespace or credentials. Titles take 1–200 bytes of -trimmed text on one line; absent titles are derived from the target. Issue -numbers run from 1 to 2,147,483,647. Kept reference IDs take 1–128 bytes. -Kept records and live pages must belong to the workstream; a kept job must -be a member. A missing file or local repo gets `gone: true` when listed. - -`workstream.create` accepts up to 200 initial sources, excluding kept work. -All are checked before creation, and a refusal creates no agent or collection. -Repeating a client ID returns its first workstream without rechecking source -paths, even if a file has since disappeared. Duplicate targets keep one source. -Sources never add text to the workstream's -why. A workstream holds at most 2,000 sources, proposals included; the next -addition returns `too_large`. Done workstreams permit listing but refuse -adding, updating and removing until reopened. - -## Agent source proposals, Read by jobs and reading content - -`propose_source` asks the person to keep or drop a source, with a reason of -1–500 bytes. A proposal is marked `added_by: agent`, `proposed: true` and -`read_by_jobs: false`. `source.update` with `keep: true` keeps it; removing it -drops it. Keeping preserves the reason and author. Adding the same proposed -target as the person also keeps it. Repeating an existing target adds nothing. - -`source.update` changes the title or Read by jobs, or keeps a proposal. At -least one field is required. `keep: false` is refused, and enabling Read by -jobs on a proposal requires keeping it in the same request. A change that -alters nothing sends no event. Jobs must exclude proposals; consuming the -Read by jobs selection is the Factory plan step's responsibility. - -The workstream agent reads the list with `workstream_read` (`what: sources`) -as complete JSON, up to the 2,000-source limit. It reads one source with -`what: source` and `source_id`. A regular UTF-8 text file is read within the -existing 60,000-byte answer cap, with “The file was cut.” -when cut. Binary or unreadable files get a one-line explanation. A link in the -final path component answers “The source is a link and was not read.” Links in -intermediate folders remain usable. Folders list up to 200 entry names without -following entries. Kept pages render as Markdown and records as -text; named repos, issues, links, messages and jobs remain pointers. Reading -never writes the target. These tools remain scoped to the workstream's agent. +Opening and reading a collections database never migrate it. The first successful +write upgrades supported versions 1–6 to schema 7; a failed write leaves the file +unchanged. Existing collections and references survive the upgrade, including +Factory job references. Older engines that do not support schema 7 refuse the +database with `unsupported collections database`. Collections are unavailable +in those engines; the data remains intact. Use a compatible engine to read it. diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index 0d5885e5d1..d7a56971a3 100644 --- a/internal/session/actioncategory.go +++ b/internal/session/actioncategory.go @@ -224,7 +224,7 @@ func ActionCategoryForTool(tool string) ActionCategory { // A manager looking at its team: the states, and a member's page. "team_status", "team_read", // The foreman reading the factory floor (a mark is a proposal, not a change). - "factory_floor", "factory_status", "workstream_read": + "factory_floor", "factory_status": return ActionRead // Changing something that exists. @@ -234,8 +234,7 @@ func ActionCategoryForTool(tool string) ActionCategory { // Writing content or generating an artifact. case "write", "generate_image", "generate_music", "generate_video", - "remember", "propose_subharness", "factory_add", "factory_recipe", "factory_item", "build_harness", "calendar_create", - "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "propose_source", "write_agent_page", "suggest_page_edit": + "remember", "propose_subharness", "factory_add", "factory_recipe", "factory_item", "build_harness", "calendar_create": return ActionCreate // Executing and waiting on what it prints. diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 2279266e72..08b56dcc05 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -693,7 +693,7 @@ const ( // role, the call site writes `callPurpose(roles.RoleX)` and no constant is // declared here — restating a role's string would be the same word in two // places, and the point of a small closed block is that what IS in it stands - // out. The purposes below are not roles at all. + // out. The two below are not roles at all. // // purposeAutomationCheck is one automation watch's judgment: does what its // look saw meet the condition? It is the ONE DELIBERATE DIVERGENCE: the call @@ -704,8 +704,6 @@ const ( // tag (issue #1010, whose spine is #928). The standing check this replaced // was tagged `standing-check`, which cmd/codeaf-replay still reads. purposeAutomationCheck callPurpose = "automation-check" - // purposeWorkstreamWake names one workstream's event turn on the sentinel ladder. - purposeWorkstreamWake callPurpose = "workstream-wake" // purposeDocument is a rung of the document reader — the model's own eyes on // a PDF the `read` tool cannot open as text. It is not a role because the // reader resolves its model from the document settings and never from the diff --git a/internal/session/inherit.go b/internal/session/inherit.go index 4128971588..ccbeb8115a 100644 --- a/internal/session/inherit.go +++ b/internal/session/inherit.go @@ -386,7 +386,7 @@ func adoptSeed(child *Agent, quick *quickTaskSpec) { // prompt over the top of it an hour later — the same mark a Config that // carries a System gets at construction (agent.go), said here because this // page arrives after the agent was built. - child.system = promotedSystem(workstreamWithoutPage(quick.system)) + child.system = promotedSystem(quick.system) child.system = clientToolsWithoutSections(child.system) child.systemOwn = false messages[0] = textMessage("system", child.system) diff --git a/internal/session/manual_test.go b/internal/session/manual_test.go index 9e857029f9..933e98ff90 100644 --- a/internal/session/manual_test.go +++ b/internal/session/manual_test.go @@ -132,24 +132,6 @@ func TestTheManualMentionsTheDesignThreadsOwnTool(t *testing.T) { } } -// Contract (h) checks the offered belt, including its eight conditional tools. -func TestTheManualMentionsTheWorkstreamAgentsTools(t *testing.T) { - agent := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}}} - agent.tools = agent.belt() - names := map[string]bool{} - for _, tool := range agent.offeredTools() { - if !manual.Chat().Mentions(tool.Name) { - t.Errorf("the chat manual does not mention %s", tool.Name) - } - names[tool.Name] = true - } - for _, name := range []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "propose_source", "write_agent_page", "suggest_page_edit"} { - if !names[name] { - t.Errorf("the workstream agent does not offer %s", name) - } - } -} - // The manual tool answers out of the CHAT's pages and must never reach the // resident's. Both corpora ship in this binary, and a chat that answered from // the wrong one would describe a product the person is not using — fluently, diff --git a/internal/session/programhold_test.go b/internal/session/programhold_test.go index 21a2b18493..8e580fdc27 100644 --- a/internal/session/programhold_test.go +++ b/internal/session/programhold_test.go @@ -228,8 +228,7 @@ func TestProgramHoldClassifiesEveryRegisteredChatTool(t *testing.T) { "build_harness": true, "list_harnesses": true, "list_subharnesses": true, "propose_subharness": true, "revise_design": true, "web_search": true, "web_fetch": true, - "suggest_page_edit": true, - "gmail_read": true, "gmail_search": true, "gmail_send": true, + "gmail_read": true, "gmail_search": true, "gmail_send": true, "calendar_list": true, "calendar_create": true, "slack_search": true, "slack_read_thread": true, "slack_send": true, "slack_list_channels": true, } diff --git a/internal/session/prompt.go b/internal/session/prompt.go index 0295e27912..9d2ca93d82 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -399,7 +399,6 @@ func renderSystemAt(config Config, now time.Time) string { out.WriteString(catalog) } - out.WriteString(workstreamSection(config)) out.WriteString(workerFooter(config, now)) out.WriteString(clientToolsSection(config)) return out.String() diff --git a/internal/session/questionconversation.go b/internal/session/questionconversation.go index 0533a7e9ef..3d4b1b90b3 100644 --- a/internal/session/questionconversation.go +++ b/internal/session/questionconversation.go @@ -96,7 +96,6 @@ func (a *Agent) clarifyQuestion(answer Answer) error { } a.mu.Lock() cfg := a.config - cfg.Workstream = nil cfg.ClientTools = nil cfg.Model = a.model cfg.SessionFile, cfg.Place = "", Place{} @@ -109,7 +108,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { if cfg.ApprovalPolicy == nil { cfg.ApprovalPolicy = a.config.ApprovalPolicy } - cfg.System = workstreamWithoutPage(a.system) + cfg.System = a.system cfg.System = clientToolsWithoutSections(cfg.System) if a.guardianOverride != nil { cfg.Guardian = *a.guardianOverride diff --git a/internal/session/session.go b/internal/session/session.go index 393e328ff0..9eddab8534 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2525,9 +2525,6 @@ type Config struct { // foremanFloorDoor), on a launch that has a Factory door. Floor FloorDoor - // Workstream is set by the app only on a workstream's own agent. Nil is no door. - Workstream WorkstreamDoor - // ClientTools is the door onto tools an app attached to this conversation. Nil is no door. ClientTools ClientToolDoor diff --git a/internal/session/toolargs_test.go b/internal/session/toolargs_test.go index 0cae4045f1..3a95f60aa6 100644 --- a/internal/session/toolargs_test.go +++ b/internal/session/toolargs_test.go @@ -489,8 +489,6 @@ func TestNoWholeNumberArgumentIsDeclaredANumber(t *testing.T) { // whole-number-shaped anywhere else is still caught. fractionalIn := map[string]map[string]bool{ "tools_factory_item.go": {"cap": true}, - // A GO-AHEAD'S proposed cap is dollars, a float64 where twelve fifty is ordinary. - "workstream_schema.go": {"cap_usd": true}, } for _, path := range toolArgumentSources(t) { body, err := os.ReadFile(path) diff --git a/internal/session/tools.go b/internal/session/tools.go index d9958b616e..40e6597c19 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -273,7 +273,6 @@ func (a *Agent) belt() []bare.Tool { // factory_floor (tools_floor.go) is the foreman's read of the whole floor // and its marks, absent on the same law: no floor behind it, no verb. tools = append(tools, a.floorTools()...) - tools = append(tools, a.workstreamTools()...) tools = append(tools, a.harnessTools()...) // The saved PROGRAMS, and the list that says which ones there are // (tools_subharness.go). They are conditional on the same terms the three diff --git a/internal/session/workstream_contract.go b/internal/session/workstream_contract.go deleted file mode 100644 index 251eab7a94..0000000000 --- a/internal/session/workstream_contract.go +++ /dev/null @@ -1,104 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "errors" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WorkstreamDoor is the workstream agent's one way into its own workstream. -type WorkstreamDoor interface { - // Brief returns a non-blocking snapshot, or false when none is ready. - Brief() (WorkstreamBrief, bool) - Read(context.Context, WorkstreamReadAsk) (json.RawMessage, error) - Propose(context.Context, WorkstreamProposal) (WorkstreamProposed, error) - Source(context.Context, *workstream.Source) (*workstream.Source, error) - Plan(context.Context) (WorkstreamPlanState, error) - SetPlan(context.Context, int, WorkstreamPlanState) (WorkstreamPlanState, error) - WritePage(context.Context, WorkstreamPageWrite) (WorkstreamPageWritten, error) - ProposeSource(context.Context, WorkstreamSourceProposal) (WorkstreamSourceProposed, error) - SuggestEdits(context.Context, WorkstreamSuggestionAsk) (WorkstreamSuggested, error) -} - -var ErrWorkstreamMissing = errors.New("No workstream holds this conversation.") - -var ErrWorkstreamDone = errors.New("This workstream is done; nothing was added or written.") - -var ErrWorkstreamVersion = errors.New("the workstream version changed") - -var ErrWorkstreamNotAgentPage = errors.New("That page is the person's. You write only your own pages; suggest edits on theirs with suggest_page_edit.") - -var ErrWorkstreamTooLarge = errors.New("That page is too large; the encoded document must be at most 1 MiB.") - -// WorkstreamWhereItStandsTitle is the agent page the app shows on the Overview as -// the agent's summary, so the agent keeps the page with exactly this title current. -const WorkstreamWhereItStandsTitle = "Where it stands" - -type WorkstreamBrief struct { - Name string - Why string -} - -type WorkstreamReadAsk struct { - What string `json:"what"` - PageID string `json:"page_id,omitempty"` - SourceID string `json:"source_id,omitempty"` - Kind string `json:"kind,omitempty"` - State string `json:"state,omitempty"` -} - -type WorkstreamProposal struct { - Kind string - Text string - Source *workstream.Source - GoAhead *WorkstreamGoAheadAsk -} - -type WorkstreamProposed struct { - ID string -} - -type WorkstreamPlanState struct { - Version int - Milestones []workstream.Milestone - Waves []workstream.Wave -} - -type WorkstreamGoAheadAsk struct { - Scope string `json:"scope"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - Wave int `json:"wave,omitempty"` - MilestoneID string `json:"milestone_id,omitempty"` - CapUSD float64 `json:"cap_usd"` - EndsOn string `json:"ends_on"` - Why string `json:"why"` -} - -type WorkstreamPageWrite struct { - PageID string `json:"page_id,omitempty"` - Title string `json:"title,omitempty"` - ParentID string `json:"parent_id,omitempty"` - Markdown string `json:"markdown"` - UpdatedFrom string `json:"updated_from"` - Source *workstream.Source `json:"source,omitempty"` -} - -type WorkstreamPageWritten struct { - ID string - Title string - Version int -} - -type WorkstreamSuggestionAsk struct { - PageID string `json:"page_id"` - BaseVersion int `json:"base_version"` - Why string `json:"why"` - Edits []workstream.SuggestionFind `json:"edits"` -} -type WorkstreamSuggested struct { - ID, Title string - Count int -} diff --git a/internal/session/workstream_factory_test.go b/internal/session/workstream_factory_test.go deleted file mode 100644 index 4af91f6511..0000000000 --- a/internal/session/workstream_factory_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "testing" - - "github.com/Agent-Field/codeaf/internal/factory" -) - -type workstreamForbiddenFactory struct { - t *testing.T -} - -func (d workstreamForbiddenFactory) Add(context.Context, factory.Item) (int, error) { - d.t.Fatal("propose_jobs called Factory.Add") - return 0, nil -} - -func (d workstreamForbiddenFactory) Preview(context.Context, int, ItemChange) (factory.Item, factory.Item, error) { - d.t.Fatal("propose_jobs called FactoryItem.Preview") - return factory.Item{}, factory.Item{}, nil -} - -func (d workstreamForbiddenFactory) Apply(context.Context, int, ItemChange) (factory.Item, error) { - d.t.Fatal("propose_jobs called FactoryItem.Apply") - return factory.Item{}, nil -} - -func (d workstreamForbiddenFactory) EditRun(context.Context, string, factory.RunEdit) (factory.Item, []string, error) { - d.t.Fatal("propose_jobs called FactoryRun.EditRun") - return factory.Item{}, nil, nil -} - -func (d workstreamForbiddenFactory) StartRun(context.Context, string) (string, error) { - d.t.Fatal("propose_jobs called FactoryRun.StartRun") - return "", nil -} - -func (d workstreamForbiddenFactory) BankStage(context.Context, string, factory.Kind, string) error { - d.t.Fatal("propose_jobs called Recipe.BankStage") - return nil -} - -func (d workstreamForbiddenFactory) BankPolicy(context.Context, string, string) error { - d.t.Fatal("propose_jobs called Recipe.BankPolicy") - return nil -} - -func (d workstreamForbiddenFactory) BankHabit(context.Context, string, string) error { - d.t.Fatal("propose_jobs called Recipe.BankHabit") - return nil -} - -func (d workstreamForbiddenFactory) Snapshot(context.Context) (factory.Snapshot, error) { - d.t.Fatal("propose_jobs called Floor.Snapshot") - return factory.Snapshot{}, nil -} - -func (d workstreamForbiddenFactory) Mark(context.Context, []int, bool) error { - d.t.Fatal("propose_jobs called Floor.Mark") - return nil -} - -func (d workstreamForbiddenFactory) Chat(string) JobChat { - d.t.Fatal("propose_jobs called JobChat.Chat") - return nil -} - -func (d workstreamForbiddenFactory) Spend(float64) { - d.t.Fatal("propose_jobs called JobBudget.Spend") -} - -func (d workstreamForbiddenFactory) Refusal() string { - d.t.Fatal("propose_jobs called JobBudget.Refusal") - return "" -} - -func (d workstreamForbiddenFactory) Report(context.Context, factory.StageResult) error { - d.t.Fatal("propose_jobs called Stage.Report") - return nil -} - -func (d workstreamForbiddenFactory) Edit(context.Context, factory.PlanEdit) error { - d.t.Fatal("propose_jobs called Stage.Edit") - return nil -} - -// Contract (c) puts failing doors behind every Factory capability during the call. -func TestWorkstreamJobsDoNotCallAnyFactoryDoor(t *testing.T) { - door := &workstreamFakeDoor{} - forbidden := workstreamForbiddenFactory{t: t} - a := &Agent{config: Config{Workstream: door, Factory: forbidden, FactoryItem: forbidden, FactoryRun: forbidden, Recipe: forbidden, Floor: forbidden, JobChat: forbidden, JobBudget: forbidden, Stage: forbidden}} - for _, tool := range a.workstreamTools() { - if tool.Name != "propose_jobs" { - continue - } - got, failed, err := tool.Execute(context.Background(), json.RawMessage(`{"jobs":[{"title":"Draft"}]}`)) - if err != nil || failed || got != "Added 1 draft jobs to wave 1 of the plan. They are drafts: nothing runs until the person launches them." { - t.Fatalf("draft proposal: %q %v %v", got, failed, err) - } - if len(door.saves) != 1 || door.saves[0].Waves[0].Items[0].Kind != "draft" { - t.Fatalf("not a draft: %+v", door.saves) - } - return - } - t.Fatal("missing propose_jobs") -} diff --git a/internal/session/workstream_fields_v02_test.go b/internal/session/workstream_fields_v02_test.go deleted file mode 100644 index 31ec08cf71..0000000000 --- a/internal/session/workstream_fields_v02_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package session - -import ( - "errors" - "reflect" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WP-F2 (e): the last second of day 89 fits every grant clock on today's UTC day. -func TestWorkstreamGoAheadDateEdgeIs89Days(t *testing.T) { - for _, hour := range []int{0, 12, 23} { - now := time.Date(2026, 11, 3, hour, 0, 0, 0, time.UTC) - ask := WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 1, Why: "Review"} - ask.EndsOn = now.AddDate(0, 0, 89).Format("2006-01-02") - if err := WorkstreamValidateGoAhead(ask, now); err != nil { - t.Fatal(err) - } - ask.EndsOn = now.AddDate(0, 0, 90).Format("2006-01-02") - if err := WorkstreamValidateGoAhead(ask, now); err == nil || !strings.Contains(err.Error(), "at most 89 days ahead") { - t.Fatalf("90-day edge: %v", err) - } - } - if !strings.Contains(workstreamGoAheadSchema, "at most 89 days ahead") { - t.Fatal("schema date bound drifted") - } -} - -// WP-F2 (e): resolve once before retry, stamp every added draft, preserve old items. -func TestWorkstreamDraftSourcesSurviveTheSingleRetry(t *testing.T) { - for _, source := range []*workstream.Source{nil, {Kind: "page", ID: "page", Label: "Spec"}} { - d := &workstreamFakeDoor{conflicts: 1, plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 1, Items: []workstream.PlanItem{{Kind: "draft", DraftID: "old", Title: "Old", Size: "L", BudgetUSD: 10, Source: &workstream.Source{Kind: "message", ID: "m", ChatID: "chat"}}}}}}} - before := d.plan.Waves[0] - args := `{"jobs":[{"title":"One"},{"title":"Two"}]}` - want := &workstream.Source{Kind: "chat", ID: "agent-chat"} - if source != nil { - args = `{"jobs":[{"title":"One"},{"title":"Two"}],"source":{"kind":"page","id":"page","label":"Spec"}}` - want = source - } - workstreamCallForTest(t, d, "propose_jobs", args, false) - if d.sourceCalls != 1 || len(d.saves) != 2 { - t.Fatalf("resolve/retry calls: %+v", d) - } - for _, saved := range d.saves { - if !reflect.DeepEqual(saved.Waves[0], before) { - t.Fatal("old item metadata changed") - } - wave := saved.Waves[len(saved.Waves)-1] - for _, item := range wave.Items { - if !reflect.DeepEqual(item.Source, want) || item.Size != "" || item.BudgetUSD != 0 { - t.Fatalf("draft: %+v", item) - } - } - } - } -} - -// WP-F2 (e): invalid explicit source refuses without a plan write. -func TestWorkstreamDraftSourceRefusalAddsNothing(t *testing.T) { - d := &workstreamFakeDoor{sourceError: errors.New("source page does not exist")} - workstreamCallForTest(t, d, "propose_jobs", `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"missing"}}`, true) - if d.sourceCalls != 1 || d.reads != 0 || len(d.saves) != 0 { - t.Fatalf("refusal wrote: %+v", d) - } - for _, args := range []string{ - `{"jobs":[{"title":"Draft","size":"S"}]}`, - `{"jobs":[{"title":"Draft","budget_usd":1}]}`, - `{"jobs":[{"title":"Draft"}],"source":{"kind":"message","id":"m"}}`, - `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"p","extra":true}}`, - `{"jobs":[{"title":"Draft"}],"source":{"kind":"page","id":"p","factory":""}}`, - } { - d := &workstreamFakeDoor{} - workstreamCallForTest(t, d, "propose_jobs", args, true) - if d.sourceCalls != 0 || d.reads != 0 || len(d.saves) != 0 { - t.Fatal("invalid source reached the door") - } - } -} - -// WP-F2 (f): the agent's read grammar accepts the structured proposal kind. -func TestWorkstreamReadAcceptsGoAheadKind(t *testing.T) { - d := &workstreamFakeDoor{read: []byte(`{"records":[]}`)} - workstreamCallForTest(t, d, "workstream_read", `{"what":"records","kind":"goahead"}`, false) - if d.readAsk.Kind != "goahead" { - t.Fatalf("kind: %+v", d.readAsk) - } -} diff --git a/internal/session/workstream_limits.go b/internal/session/workstream_limits.go deleted file mode 100644 index eb58f40c90..0000000000 --- a/internal/session/workstream_limits.go +++ /dev/null @@ -1,22 +0,0 @@ -package session - -import ( - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const workstreamLineMax = 200 - -const workstreamRecordMax = 2000 - -const workstreamDraftTextMax = 4000 - -const workstreamJobsMax = 20 - -const workstreamGoAheadCapMax = workspace.WorkstreamGoAheadCapMax - -// Ending at the UTC day's last second keeps the proposal within the grant limit. -const workstreamGoAheadDaysMax = int(workspace.WorkstreamGoAheadDurationMax/(24*time.Hour)) - 1 - -const workstreamGoAheadWhyMax = 1000 diff --git a/internal/session/workstream_schema.go b/internal/session/workstream_schema.go deleted file mode 100644 index 78d02ef99d..0000000000 --- a/internal/session/workstream_schema.go +++ /dev/null @@ -1,181 +0,0 @@ -package session - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "sort" - "strings" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -var workstreamSourceSchema = fmt.Sprintf(`{"type":"object","properties":{"kind":{"type":"string","enum":["factory_job","page","url"]},"id":{"type":"string"},"factory":{"type":"string"},"label":{"type":"string","maxLength":%d}},"required":["kind","id"],"additionalProperties":false}`, workstreamLineMax) - -const workstreamReadSchema = `{"type":"object","properties":{"what":{"type":"string","enum":["workstream","pages","page","records","plan","goaheads","sources","source"]},"page_id":{"type":"string"},"source_id":{"type":"string"},"kind":{"type":"string","enum":["decision","question","goahead"]},"state":{"type":"string","enum":["proposed","kept","dropped","superseded"]}},"required":["what"],"additionalProperties":false}` - -var workstreamRecordSchema = fmt.Sprintf(`{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":%d},"source":%s},"required":["text"],"additionalProperties":false}`, workstreamRecordMax, workstreamSourceSchema) - -var workstreamJobsSchema = fmt.Sprintf(`{"type":"object","properties":{"jobs":{"type":"array","minItems":1,"maxItems":%d,"items":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":%d},"repo":{"type":"string","maxLength":%d},"text":{"type":"string","maxLength":%d}},"required":["title"],"additionalProperties":false}},"wave":{"type":"integer"},"wave_title":{"type":"string","maxLength":%d},"milestone_id":{"type":"string"},"source":%s},"required":["jobs"],"additionalProperties":false}`, workstreamJobsMax, workstreamLineMax, workstreamLineMax, workstreamDraftTextMax, workstreamLineMax, workstreamSourceSchema) - -var workstreamGoAheadSchema = fmt.Sprintf(`{"type":"object","properties":{"scope":{"type":"string","enum":["job","wave","milestone"]},"factory":{"type":"string"},"job":{"type":"integer"},"wave":{"type":"integer"},"milestone_id":{"type":"string"},"cap_usd":{"type":"number","exclusiveMinimum":0,"maximum":%d},"ends_on":{"type":"string","description":"YYYY-MM-DD, after today in UTC and at most %d days ahead."},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["scope","cap_usd","ends_on","why"],"additionalProperties":false}`, workstreamGoAheadCapMax, workstreamGoAheadDaysMax, workstreamGoAheadWhyMax) - -var workstreamPageSchema = fmt.Sprintf(`{"type":"object","properties":{"page_id":{"type":"string"},"title":{"type":"string","minLength":1,"maxLength":%d},"parent_id":{"type":"string"},"markdown":{"type":"string"},"updated_from":{"type":"string","minLength":1,"maxLength":%d},"source":%s},"required":["markdown","updated_from"],"additionalProperties":false}`, workstreamLineMax, workstreamLineMax, workstreamSourceSchema) - -var workstreamSuggestionSchema = fmt.Sprintf(`{"type":"object","properties":{"page_id":{"type":"string","minLength":1},"base_version":{"type":"integer","minimum":1},"why":{"type":"string","minLength":1,"maxLength":%d},"edits":{"type":"array","minItems":1,"maxItems":%d,"items":{"type":"object","properties":{"find":{"type":"string","minLength":1,"maxLength":%d},"replace":{"type":"string","minLength":0,"maxLength":%d},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["find","replace","why"],"additionalProperties":false}}},"required":["page_id","base_version","why","edits"],"additionalProperties":false}`, workstream.SuggestionWhyMax, workstream.SuggestionEditsMax, workstream.SuggestionTextMax, workstream.SuggestionTextMax, workstream.SuggestionWhyMax) - -type workstreamArgumentSchema struct { - Type string `json:"type"` - Properties map[string]workstreamArgumentSchema `json:"properties"` - Required []string `json:"required"` - Enum []string `json:"enum"` - Items *workstreamArgumentSchema `json:"items"` -} - -func workstreamDecode(args json.RawMessage, schema workstreamArgumentSchema, target any) (map[string]any, error) { - if _, source := target.(*WorkstreamSourceProposal); source && !utf8.Valid(args) { - return nil, fmt.Errorf("source arguments must be valid UTF-8") - } - if err := decodeToolArguments(args, target); err != nil { - return nil, err - } - if err := workstreamUniqueKeys(args); err != nil { - return nil, err - } - var value any - if err := decodeToolArguments(args, &value); err != nil { - return nil, err - } - if err := workstreamCheckArguments(value, schema, "arguments"); err != nil { - return nil, err - } - return value.(map[string]any), nil -} - -// Walk the original token stream so repeated fields survive typed decoding. -func workstreamUniqueKeys(args json.RawMessage) error { - decoder := json.NewDecoder(bytes.NewReader(args)) - decoder.UseNumber() - if err := workstreamUniqueValue(decoder); err != nil { - return err - } - if _, err := decoder.Token(); err != io.EOF { - return fmt.Errorf("arguments must contain one JSON value") - } - return nil -} - -func workstreamUniqueValue(decoder *json.Decoder) error { - token, err := decoder.Token() - if err != nil { - return err - } - delimiter, ok := token.(json.Delim) - if !ok { - return nil - } - switch delimiter { - case '{': - return workstreamUniqueObject(decoder) - case '[': - for decoder.More() { - if err := workstreamUniqueValue(decoder); err != nil { - return err - } - } - _, err := decoder.Token() - return err - } - return fmt.Errorf("unexpected JSON delimiter %q", delimiter) -} - -func workstreamUniqueObject(decoder *json.Decoder) error { - seen := make(map[string]bool) - for decoder.More() { - token, err := decoder.Token() - if err != nil { - return err - } - name, ok := token.(string) - if !ok { - return fmt.Errorf("object keys must be strings") - } - if seen[name] { - return fmt.Errorf("Duplicate field %q is not allowed.", name) - } - seen[name] = true - if err := workstreamUniqueValue(decoder); err != nil { - return err - } - } - _, err := decoder.Token() - return err -} - -func workstreamCheckArguments(value any, schema workstreamArgumentSchema, path string) error { - if value == nil { - return fmt.Errorf("%s must not be null", path) - } - switch schema.Type { - case "object": - return workstreamCheckObject(value, schema, path) - case "array": - items, ok := value.([]any) - if !ok { - return fmt.Errorf("%s must be an array", path) - } - for i, item := range items { - if err := workstreamCheckArguments(item, *schema.Items, fmt.Sprintf("%s[%d]", path, i)); err != nil { - return err - } - } - case "string": - text, ok := value.(string) - if !ok { - return fmt.Errorf("%s must be a string", path) - } - if len(schema.Enum) > 0 && !workstreamHasWord(schema.Enum, text) { - return fmt.Errorf("%s must be one of %s", path, strings.Join(schema.Enum, ", ")) - } - } - return nil -} - -func workstreamCheckObject(value any, schema workstreamArgumentSchema, path string) error { - fields, ok := value.(map[string]any) - if !ok { - return fmt.Errorf("%s must be a JSON object", path) - } - names := make([]string, 0, len(fields)) - for name := range fields { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - if _, ok := schema.Properties[name]; !ok { - return fmt.Errorf("Unknown field %q is not allowed.", name) - } - } - for _, name := range names { - if err := workstreamCheckArguments(fields[name], schema.Properties[name], name); err != nil { - return err - } - } - for _, name := range schema.Required { - if _, ok := fields[name]; !ok { - return fmt.Errorf("%s is required", name) - } - } - return nil -} - -func workstreamHasWord(words []string, word string) bool { - for _, candidate := range words { - if candidate == word { - return true - } - } - return false -} diff --git a/internal/session/workstream_sources.go b/internal/session/workstream_sources.go deleted file mode 100644 index ccb77c3705..0000000000 --- a/internal/session/workstream_sources.go +++ /dev/null @@ -1,53 +0,0 @@ -package session - -import ( - "context" - "fmt" - - "github.com/Agent-Field/codeaf/internal/exec/bare" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WorkstreamSourceReadMax lets the app bound reads before JSON encoding. -const WorkstreamSourceReadMax = workstreamReadMax - -type WorkstreamSourceProposal struct { - Kind string `json:"kind"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *workstream.KeptRef `json:"ref,omitempty"` - Title *string `json:"title,omitempty"` - Why string `json:"why"` -} - -type WorkstreamSourceProposed struct { - ID string - Already bool -} - -func (p WorkstreamSourceProposal) SourceAdd() workstream.SourceAdd { - return workstream.SourceAdd{Kind: p.Kind, Path: p.Path, Repo: p.Repo, Number: p.Number, URL: p.URL, Ref: p.Ref, Title: p.Title} -} - -var workstreamProposeSourceSchema = fmt.Sprintf(`{"type":"object","properties":{"kind":{"type":"string","enum":["file","repo","issue","link","kept"]},"path":{"type":"string","minLength":1,"maxLength":%d},"repo":{"type":"string"},"number":{"type":"integer","minimum":1,"maximum":%d},"url":{"type":"string","minLength":1,"maxLength":%d},"ref":{"type":"object","properties":{"kind":{"type":"string","enum":["record","page","message","factory_job"]},"id":{"type":"string","minLength":1,"maxLength":%d},"chat_id":{"type":"string","minLength":1,"maxLength":%d},"factory":{"type":"string","minLength":1,"maxLength":%d}},"required":["kind","id"],"additionalProperties":false},"title":{"type":"string","minLength":1,"maxLength":%d},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["kind","why"],"additionalProperties":false}`, workstream.SourcePathMax, workstream.SourceIssueMax, workstream.SourceURLMax, workstream.SourceRefMax, workstream.SourceRefMax, workstream.SourceRefMax, workstream.SourceTitleMax, workstream.SourceWhyMax) - -func workstreamSourceTool(door WorkstreamDoor) bare.Tool { - return workstreamTool("propose_source", "Propose a source with a reason. The person keeps or drops it; jobs cannot read a proposal.", workstreamProposeSourceSchema, func(ctx context.Context, ask WorkstreamSourceProposal) (string, error) { - if err := workstream.ValidateSourceWhy(ask.Why); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - if err := workstream.ValidateSourceAdd(ask.SourceAdd()); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - out, err := door.ProposeSource(ctx, ask) - if err != nil { - return "", err - } - if out.Already { - return "Source " + out.ID + " is already present. The person keeps or drops it.", nil - } - return "Proposed source " + out.ID + ". The person keeps or drops it.", nil - }) -} diff --git a/internal/session/workstream_sources_test.go b/internal/session/workstream_sources_test.go deleted file mode 100644 index 040bdcda0f..0000000000 --- a/internal/session/workstream_sources_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package session - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" -) - -// WP-12 (g): the new tool reaches only its proposal door and tells the person -// who keeps or drops it. Its strict schema refuses invented fields and nulls. -func TestWorkstreamProposeSourceToolAndStrictArguments(t *testing.T) { - d := &workstreamFakeDoor{} - got := workstreamCallForTest(t, d, "propose_source", `{"kind":"link","url":"https://example.com","why":"Read the guide."}`, false) - if !strings.Contains(got, "source-1") || !strings.Contains(got, "person keeps or drops") || len(d.sourceProposals) != 1 || len(d.proposals) != 0 || len(d.saves) != 0 || len(d.pageWrites) != 0 { - t.Fatalf("proposal: %q %+v", got, d) - } - for _, args := range []string{ - `{"kind":"link","url":"https://example.com","why":"Read it.","read_by_jobs":true}`, - `{"kind":"link","url":"https://example.com","why":"Read it.","proposed":false}`, - `{"kind":"file","path":"relative","why":"Read it."}`, - `{"kind":"link","url":"file:///etc/passwd","why":"Read it."}`, - `{"kind":"link","url":"https://example.com","why":""}`, - `{"kind":"link","url":"https://example.com","why":"` + strings.Repeat("x", 501) + `"}`, - `{"kind":"link","url":"https://example.com","why":"Read it.","title":null}`, - `{"kind":"kept","ref":{"kind":"message","id":"m","chat_id":"c","extra":true},"why":"Read it."}`, - `{"kind":"kept","ref":{"kind":"message","id":"m","id":"m","chat_id":"c"},"why":"Read it."}`, - `{"kind":"link","url":"https://example.com","url":"https://example.com","why":"Read it."}`, - } { - d := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - text, refused, err := workstreamToolForTest(t, d, "propose_source").Execute(ctx, json.RawMessage(args)) - if err != nil || !refused || !mark.RejectedBeforeRun() || d.otherCalls != 0 { - t.Fatalf("invalid proposal: %s %q %+v %v", args, text, d, err) - } - } -} - -// WP-12 Fix 1 Major 3 (b), (g): a raw invalid path is refused before the -// proposal door, even when its JSON-repaired replacement would be a real file. -func TestWorkstreamMajor3InvalidUTF8BProposeSource(t *testing.T) { - path := filepath.Join(t.TempDir(), "source-�.txt") - if err := os.WriteFile(path, []byte("Existing replacement path."), 0600); err != nil { - t.Fatal(err) - } - args, err := json.Marshal(map[string]any{"kind": "file", "path": path, "why": "Read the source."}) - if err != nil { - t.Fatal(err) - } - d := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - invalid := bytes.ReplaceAll(args, []byte("�"), []byte{0xff}) - if !bytes.Contains(invalid, []byte{0xff}) { - t.Fatal("fixture must contain a literal invalid UTF-8 byte") - } - answer, refused, err := workstreamToolForTest(t, d, "propose_source").Execute(ctx, invalid) - if err != nil || !refused || !mark.RejectedBeforeRun() || !strings.Contains(answer, "UTF-8") || d.otherCalls != 0 || len(d.sourceProposals) != 0 { - t.Fatalf("invalid raw path reached proposal door: %q %+v %v", answer, d, err) - } - workstreamCallForTest(t, d, "propose_source", string(args), false) - if len(d.sourceProposals) != 1 || *d.sourceProposals[0].Path != path { - t.Fatalf("valid replacement character proposal: %+v", d.sourceProposals) - } -} - -// WP-12 (g): sources and source are valid reads; source_id is required only -// for the latter and never permits another part's selector fields. -func TestWorkstreamSourceReadArguments(t *testing.T) { - for _, args := range []string{`{"what":"sources"}`, `{"what":"source","source_id":"source-1"}`} { - d := &workstreamFakeDoor{read: json.RawMessage(`{"sources":[]}`)} - workstreamCallForTest(t, d, "workstream_read", args, false) - if d.otherCalls != 1 { - t.Fatal("source read did not reach the door") - } - } - for _, args := range []string{`{"what":"source"}`, `{"what":"source","source_id":""}`, `{"what":"sources","source_id":"source-1"}`, `{"what":"source","source_id":"source-1","page_id":"page-1"}`, `{"what":"source","source_id":"source-1","kind":"decision"}`} { - d := &workstreamFakeDoor{} - workstreamCallForTest(t, d, "workstream_read", args, true) - if d.otherCalls != 0 { - t.Fatal("invalid source selector reached the door") - } - } -} - -// WP-12 (g): every source remains in the JSON inventory even when it exceeds -// the content-read cap; page and file content retain their existing bounds. -func TestWorkstreamSourceListKeepsEveryRowAsJSON(t *testing.T) { - items := make([]map[string]any, 2000) - for i := range items { - items[i] = map[string]any{"id": fmt.Sprintf("source-%d", i), "workstream_id": "w", "kind": "link", "title": strings.Repeat("x", 100), "url": "https://example.com", "read_by_jobs": false, "proposed": false, "added_by": "you", "added_at": "2026-11-03T12:00:00Z"} - } - raw, err := json.Marshal(map[string]any{"sources": items}) - if err != nil { - t.Fatal(err) - } - d := &workstreamFakeDoor{read: raw} - got := workstreamCallForTest(t, d, "workstream_read", `{"what":"sources"}`, false) - if len(got) <= workstreamReadMax || !json.Valid([]byte(got)) || got != string(raw) { - t.Fatal("source inventory was cut or changed") - } -} diff --git a/internal/session/workstream_suggestions_test.go b/internal/session/workstream_suggestions_test.go deleted file mode 100644 index 1ae44cd46a..0000000000 --- a/internal/session/workstream_suggestions_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WP-6d Tool, (a), (h): exact answers travel as normal tool refusals. -func TestWorkstreamSuggestionToolAnswers(t *testing.T) { - const args = `{"page_id":"page","base_version":3,"why":"Reason","edits":[{"find":"old","replace":"new","why":"Clearer"}]}` - for _, test := range []struct { - err error - want string - }{ - {nil, "Suggested 1 edit(s) on Notes (sg_1). The person keeps or drops each one; the page is unchanged until then."}, - {workstream.ErrNotPersonPage, "That page is yours; change it with write_agent_page. Suggest edits only on the person's pages."}, - {ErrWorkstreamVersion, "The page changed while you were writing. Read it again before writing."}, - {ErrWorkstreamDone, "This workstream is done; nothing was added or written."}, - {errors.New("This workstream holds too many suggestions; nothing was suggested."), "This workstream holds too many suggestions; nothing was suggested."}, - {&workstream.SuggestionTextError{Message: "Edit 1: that text is not on the page. Quote plain text from one paragraph, heading, list item or code block exactly as it reads, without markdown marks."}, "Edit 1: that text is not on the page. Quote plain text from one paragraph, heading, list item or code block exactly as it reads, without markdown marks."}, - {&workstream.SuggestionTextError{Message: "Edit 1: that text appears more than once. Quote more of it so it is unique."}, "Edit 1: that text appears more than once. Quote more of it so it is unique."}, - {&workstream.SuggestionTextError{Message: "Edit 1: replace is the same as find."}, "Edit 1: replace is the same as find."}, - {&workstream.SuggestionTextError{Message: "Edit 1: only a code block's text may contain a line break."}, "Edit 1: only a code block's text may contain a line break."}, - {&workstream.SuggestionTextError{Message: "Edits 1 and 2 touch the same text. Merge them into one edit."}, "Edits 1 and 2 touch the same text. Merge them into one edit."}, - } { - door := &workstreamFakeDoor{suggestionError: test.err} - ctx, mark := withToolRejection(context.Background()) - got, refused, err := workstreamToolForTest(t, door, "suggest_page_edit").Execute(ctx, json.RawMessage(args)) - if err != nil || refused != (test.err != nil) || got != test.want || mark.RejectedBeforeRun() || len(door.suggestionAsks) != 1 { - t.Fatalf("answer: %q refused=%v %v", got, refused, err) - } - } -} - -// WP-6d Tool: strict nested schema, nulls, required fields, integer and byte -// bounds are refused before any door. Boundary values are accepted unchanged. -func TestWorkstreamSuggestionSchemaBeforeDoor(t *testing.T) { - valid := func() map[string]any { - return map[string]any{"page_id": "page", "base_version": 1, "why": "Reason", "edits": []any{map[string]any{"find": "old", "replace": "new", "why": "Clearer"}}} - } - for _, mutate := range []func(map[string]any){ - func(p map[string]any) { p["extra"] = true }, - func(p map[string]any) { delete(p, "page_id") }, - func(p map[string]any) { p["page_id"] = "" }, - func(p map[string]any) { p["page_id"] = nil }, - func(p map[string]any) { delete(p, "base_version") }, - func(p map[string]any) { p["base_version"] = 0 }, - func(p map[string]any) { p["base_version"] = -1 }, - func(p map[string]any) { p["base_version"] = 1.5 }, - func(p map[string]any) { p["base_version"] = "1" }, - func(p map[string]any) { p["base_version"] = nil }, - func(p map[string]any) { p["why"] = "" }, - func(p map[string]any) { p["why"] = strings.Repeat("é", 101) }, - func(p map[string]any) { p["why"] = "two\nlines" }, - func(p map[string]any) { p["why"] = nil }, - func(p map[string]any) { p["edits"] = nil }, - func(p map[string]any) { p["edits"] = []any{} }, - func(p map[string]any) { p["edits"] = []any{nil} }, - func(p map[string]any) { - p["edits"] = []any{map[string]any{"find": "old", "replace": "new", "why": "Why", "source": true}} - }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["find"] = "" }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["find"] = strings.Repeat("é", 2001) }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["replace"] = strings.Repeat("é", 2001) }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["replace"] = nil }, - func(p map[string]any) { delete(p["edits"].([]any)[0].(map[string]any), "replace") }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["why"] = "" }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["why"] = strings.Repeat("x", 201) }, - func(p map[string]any) { p["edits"].([]any)[0].(map[string]any)["why"] = "two\nlines" }, - func(p map[string]any) { - one := p["edits"].([]any)[0] - p["edits"] = make([]any, 51) - for i := range p["edits"].([]any) { - p["edits"].([]any)[i] = one - } - }, - } { - params := valid() - mutate(params) - raw, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - door := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - text, refused, err := workstreamToolForTest(t, door, "suggest_page_edit").Execute(ctx, raw) - if err != nil || !refused || !mark.RejectedBeforeRun() || door.otherCalls != 0 { - t.Fatalf("invalid params reached door: %s; %q %v", raw, text, err) - } - } - params := valid() - params["why"] = strings.Repeat("x", 200) - params["edits"] = make([]any, 50) - for i := range params["edits"].([]any) { - params["edits"].([]any)[i] = map[string]any{"find": strings.Repeat("é", 2000), "replace": "", "why": strings.Repeat("x", 200)} - } - raw, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - door := &workstreamFakeDoor{} - workstreamCallForTest(t, door, "suggest_page_edit", string(raw), false) - if len(door.suggestionAsks) != 1 || len(door.suggestionAsks[0].Edits) != 50 { - t.Fatal("boundary refused") - } - var schema map[string]any - if err := json.Unmarshal(workstreamToolForTest(t, door, "suggest_page_edit").Schema, &schema); err != nil { - t.Fatal(err) - } - edits := schema["properties"].(map[string]any)["edits"].(map[string]any) - if schema["additionalProperties"] != false || edits["items"].(map[string]any)["additionalProperties"] != false { - t.Fatal("schema permits invented properties") - } -} - -// WP-6d Tool: suggestions belong to the same create family as proposals. -func TestWorkstreamSuggestionActionCategory(t *testing.T) { - if category := ActionCategoryForTool("suggest_page_edit"); category != ActionCreate { - t.Fatalf("category: %q", category) - } -} diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go deleted file mode 100644 index 93c8733d54..0000000000 --- a/internal/session/workstream_tools.go +++ /dev/null @@ -1,545 +0,0 @@ -package session - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - "unicode" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/exec/bare" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -const workstreamReadMax = 60000 - -const workstreamPlanChanged = "The plan changed while you were proposing; nothing was added. Read the plan and propose again." - -const workstreamPageChanged = "The page changed while you were writing. Read it again before writing." - -const workstreamPromptRules = "\n- You propose; the person keeps. Decisions, questions, jobs and go-aheads you raise with propose_decision, propose_question, propose_jobs and propose_goahead are proposals until the person keeps them.\n" + - "- Write your own pages with write_agent_page; on the person's, suggest edits with suggest_page_edit.\n" + - "- Keep your page titled \"" + WorkstreamWhereItStandsTitle + "\" current with write_agent_page, saying what it was updated from.\n" + - "- You never invent numbers. Counts, costs, states and dates come from workstream_read; on a page, use a live block such as [live: jobs] instead of writing a number.\n" + - "- Keeping a go-ahead proposal grants it. You start nothing; the person launches jobs.\n" + - "- Sources: workstream_read (sources, source) reads; propose_source proposes. The person keeps them." - -func (c Config) mayWorkstream() bool { - return c.Workstream != nil && !c.readingTask() && !c.InTask && !c.Errand -} - -func workstreamSection(config Config) string { - if !config.mayWorkstream() { - return "" - } - return "\n\n" + workstreamPage(config) -} - -func workstreamPage(config Config) string { - brief, ok := config.Workstream.Brief() - intro := "You are the agent of a workstream. Read it with workstream_read before you answer." - if ok && strings.TrimSpace(brief.Name) != "" { - intro = fmt.Sprintf("You are the agent of the workstream %q.", workstreamCut(strings.Join(strings.Fields(brief.Name), " "), 48)) - if why := strings.Join(strings.Fields(brief.Why), " "); why != "" { - intro += " Why it exists: " + workstreamCut(why, 64) - } - } - return "WORKSTREAM\n" + intro + workstreamPromptRules -} - -func workstreamWithoutPage(page string) string { - start := strings.Index(page, "\n\nWORKSTREAM\n") - if start < 0 { - return page - } - end := strings.Index(page[start:], workstreamPromptRules) - if end < 0 { - return page - } - return page[:start] + page[start+end+len(workstreamPromptRules):] -} - -func (a *Agent) workstreamTools() []bare.Tool { - return a.workstreamToolsAt(time.Now) -} - -func (a *Agent) workstreamToolsAt(now func() time.Time) []bare.Tool { - if !a.config.mayWorkstream() { - return nil - } - door := a.config.Workstream - return []bare.Tool{ - workstreamTool("workstream_read", "Read this workstream's recorded facts. Absent numbers are unknown; never estimate them.", workstreamReadSchema, func(ctx context.Context, ask WorkstreamReadAsk) (string, error) { - return workstreamRead(ctx, door, ask) - }), - workstreamRecordTool(door, "decision"), - workstreamRecordTool(door, "question"), - workstreamSourceTool(door), - workstreamTool("propose_jobs", "Append draft jobs to the plan. Nothing is created in the Factory or started; the person launches drafts.", workstreamJobsSchema, func(ctx context.Context, ask workstreamJobsAsk) (string, error) { - return workstreamProposeJobs(ctx, door, ask) - }), - workstreamTool("propose_goahead", "Propose a go-ahead record. It grants nothing until the person keeps it.", workstreamGoAheadSchema, func(ctx context.Context, ask WorkstreamGoAheadAsk) (string, error) { - return workstreamProposeGoAhead(ctx, door, ask, now()) - }), - workstreamTool("write_agent_page", "Write only your own pages, marked as agent-written with their source and time. The person's pages are refused. Keep the page titled \""+WorkstreamWhereItStandsTitle+"\" current; the app shows it on the Overview.", workstreamPageSchema, func(ctx context.Context, ask WorkstreamPageWrite) (string, error) { - return workstreamWritePage(ctx, door, ask) - }), - workstreamTool("suggest_page_edit", "Suggest edits on one of the person's pages. find is plain text exactly as it reads on the page inside one paragraph, heading, list item or code block, without markdown marks or escapes; quote it uniquely. base_version is the page's version from workstream_read. Nothing changes until the person keeps an edit.", workstreamSuggestionSchema, func(ctx context.Context, ask WorkstreamSuggestionAsk) (string, error) { - return workstreamSuggestEdits(ctx, door, ask) - }), - } -} - -func workstreamTool[T any](name, description, schemaJSON string, run func(context.Context, T) (string, error)) bare.Tool { - var schema workstreamArgumentSchema - _ = json.Unmarshal([]byte(schemaJSON), &schema) - return bare.Tool{ - Name: name, - Description: description, - Schema: json.RawMessage(schemaJSON), - Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { - var ask T - fields, err := workstreamDecode(args, schema, &ask) - if err != nil { - return rejectToolArguments(ctx, invalidArgumentsPrefix+err.Error()), true, nil - } - if err := workstreamFieldRules(fields, ask); err != nil { - return rejectToolArguments(ctx, invalidArgumentsPrefix+err.Error()), true, nil - } - text, err := run(ctx, ask) - if err != nil { - var argument *toolArgumentError - if errors.As(err, &argument) { - return rejectToolArguments(ctx, err.Error()), true, nil - } - return err.Error(), true, nil - } - return text, false, nil - }, - } -} - -func workstreamFieldRules(fields map[string]any, ask any) error { - if err := workstreamReferenceFields(fields, ask); err != nil { - return err - } - _, page := fields["page_id"] - switch ask := ask.(type) { - case WorkstreamReadAsk: - if page != (ask.What == "page") { - return errors.New("page_id is required for and only for page") - } - _, source := fields["source_id"] - if source != (ask.What == "source") || source && strings.TrimSpace(ask.SourceID) == "" { - return errors.New("source_id is required for and only for source") - } - if ask.What != "records" { - return workstreamRefuseFields(fields, "kind", "state") - } - case workstreamJobsAsk: - if ask.Wave != nil { - return workstreamRefuseFields(fields, "wave_title", "milestone_id") - } - case WorkstreamGoAheadAsk: - return workstreamGoAheadFields(fields, ask) - case WorkstreamPageWrite: - if page { - return workstreamRefuseFields(fields, "title", "parent_id") - } - } - return nil -} - -func workstreamReferenceFields(fields map[string]any, ask any) error { - ids := make(map[string]string) - var source *workstream.Source - switch ask := ask.(type) { - case WorkstreamReadAsk: - ids["page_id"] = ask.PageID - case WorkstreamSuggestionAsk: - ids["page_id"] = ask.PageID - case workstreamJobsAsk: - ids["milestone_id"] = ask.MilestoneID - source = ask.Source - case WorkstreamGoAheadAsk: - ids["milestone_id"] = ask.MilestoneID - case WorkstreamPageWrite: - ids["page_id"] = ask.PageID - ids["parent_id"] = ask.ParentID - source = ask.Source - case workstreamRecordAsk: - source = ask.Source - } - for _, name := range []string{"page_id", "parent_id", "milestone_id"} { - if _, exists := fields[name]; exists && strings.TrimSpace(ids[name]) == "" { - return fmt.Errorf("%s must name an existing record", name) - } - } - if source != nil && source.Kind != "factory_job" { - fields, _ := fields["source"].(map[string]any) - return workstreamRefuseFields(fields, "factory") - } - return nil -} - -func workstreamRefuseFields(fields map[string]any, names ...string) error { - for _, name := range names { - if _, exists := fields[name]; exists { - return fmt.Errorf("%s is not allowed for this request", name) - } - } - return nil -} - -func workstreamGoAheadFields(fields map[string]any, ask WorkstreamGoAheadAsk) error { - switch ask.Scope { - case "job": - return workstreamRefuseFields(fields, "wave", "milestone_id") - case "wave": - return workstreamRefuseFields(fields, "factory", "job", "milestone_id") - case "milestone": - return workstreamRefuseFields(fields, "factory", "job", "wave") - } - return nil -} - -func workstreamRead(ctx context.Context, door WorkstreamDoor, ask WorkstreamReadAsk) (string, error) { - if ask.What == "page" && strings.TrimSpace(ask.PageID) == "" { - return "", errors.New("page_id is required for page") - } - raw, err := door.Read(ctx, ask) - if err != nil { - return "", err - } - text := string(raw) - if ask.What == "workstream" { - text = "Numbers absent below are unknown; say so and never estimate them.\n" + text - } - // Source inventories have a row bound and must remain complete JSON. - if len(text) > workstreamReadMax && ask.What != "sources" { - const tail = "\nThe workstream read was cut." - text = workstreamCut(text, workstreamReadMax-len(tail)) + tail - } - return text, nil -} - -func workstreamCut(text string, limit int) string { - if len(text) <= limit { - return text - } - for limit > 0 && !utf8.RuneStart(text[limit]) { - limit-- - } - return text[:limit] -} - -type workstreamRecordAsk struct { - Text string `json:"text"` - Source *workstream.Source `json:"source,omitempty"` -} - -func workstreamRecordTool(door WorkstreamDoor, kind string) bare.Tool { - return workstreamTool("propose_"+kind, "Propose a "+kind+". Only the person can keep it; it is always proposed by the agent.", workstreamRecordSchema, func(ctx context.Context, ask workstreamRecordAsk) (string, error) { - if err := workstreamText(ask.Text, "text", workstreamRecordMax, true, false); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - if err := workstreamSourceText(ask.Source); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - p, err := door.Propose(ctx, WorkstreamProposal{Kind: kind, Text: ask.Text, Source: ask.Source}) - if err != nil { - return "", err - } - return "Proposed " + kind + " " + p.ID + ". Nothing is kept until the person keeps it.", nil - }) -} - -func workstreamText(text, field string, limit int, required, line bool) error { - if !utf8.ValidString(text) || len(text) > limit || required && strings.TrimSpace(text) == "" { - return fmt.Errorf("%s must be %smost %d bytes", field, workstreamTextBound(required), limit) - } - for _, r := range text { - if unicode.IsControl(r) && (line || r != '\n' && r != '\t') { - return fmt.Errorf("%s has a control character or more than one line", field) - } - } - return nil -} - -func workstreamTextBound(required bool) string { - if required { - return "non-empty and at " - } - return "at " -} - -func workstreamSourceText(source *workstream.Source) error { - if source == nil { - return nil - } - if err := workstreamText(source.Label, "source.label", workstreamLineMax, false, true); err != nil { - return err - } - if source.Factory != "" && source.Kind != "factory_job" { - return errors.New("source.factory is allowed only for factory_job") - } - return workstreamText(source.ID, "source.id", 4096, true, false) -} - -type workstreamDraftAsk struct { - Title string `json:"title"` - Repo string `json:"repo,omitempty"` - Text string `json:"text,omitempty"` -} - -type workstreamJobsAsk struct { - Jobs []workstreamDraftAsk `json:"jobs"` - Wave *int `json:"wave,omitempty"` - WaveTitle string `json:"wave_title,omitempty"` - MilestoneID string `json:"milestone_id,omitempty"` - Source *workstream.Source `json:"source,omitempty"` -} - -func workstreamValidateJobs(ask workstreamJobsAsk) error { - if len(ask.Jobs) < 1 || len(ask.Jobs) > workstreamJobsMax { - return fmt.Errorf("jobs must contain 1–%d drafts", workstreamJobsMax) - } - if ask.Wave != nil && *ask.Wave <= 0 { - return errors.New("wave must be an existing positive wave number") - } - if err := workstreamText(ask.WaveTitle, "wave_title", workstreamLineMax, false, true); err != nil { - return err - } - for _, job := range ask.Jobs { - if err := workstreamValidateDraft(job); err != nil { - return err - } - } - return workstreamSourceText(ask.Source) -} - -func workstreamValidateDraft(job workstreamDraftAsk) error { - if err := workstreamText(job.Title, "title", workstreamLineMax, true, true); err != nil { - return err - } - if err := workstreamText(job.Repo, "repo", workstreamLineMax, false, true); err != nil { - return err - } - return workstreamText(job.Text, "text", workstreamDraftTextMax, false, false) -} - -func workstreamProposeJobs(ctx context.Context, door WorkstreamDoor, ask workstreamJobsAsk) (string, error) { - if err := workstreamValidateJobs(ask); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - source, err := door.Source(ctx, ask.Source) - if err != nil { - return "", err - } - drafts, err := workstreamDrafts(ask.Jobs, source) - if err != nil { - return "", err - } - plan, err := door.Plan(ctx) - if err != nil { - return "", err - } - wave, err := workstreamSaveDrafts(ctx, door, plan, ask, drafts) - if errors.Is(err, ErrWorkstreamVersion) { - plan, err = door.Plan(ctx) - if err != nil { - return "", err - } - wave, err = workstreamSaveDrafts(ctx, door, plan, ask, drafts) - } - if errors.Is(err, ErrWorkstreamVersion) { - return "", errors.New(workstreamPlanChanged) - } - if err != nil { - return "", err - } - return fmt.Sprintf("Added %d draft jobs to wave %d of the plan. They are drafts: nothing runs until the person launches them.", len(drafts), wave), nil -} - -func workstreamSaveDrafts(ctx context.Context, door WorkstreamDoor, plan WorkstreamPlanState, ask workstreamJobsAsk, drafts []workstream.PlanItem) (int, error) { - next, wave, err := workstreamAppendDrafts(plan, ask, drafts) - if err != nil { - return 0, err - } - _, err = door.SetPlan(ctx, plan.Version, next) - return wave, err -} - -func workstreamDrafts(jobs []workstreamDraftAsk, source *workstream.Source) ([]workstream.PlanItem, error) { - out := make([]workstream.PlanItem, 0, len(jobs)) - for _, job := range jobs { - var id [8]byte - if _, err := rand.Read(id[:]); err != nil { - return nil, err - } - out = append(out, workstream.PlanItem{Kind: "draft", DraftID: "draft_" + hex.EncodeToString(id[:]), Title: job.Title, Repo: job.Repo, Text: job.Text, Source: source}) - } - return out, nil -} - -func workstreamAppendDrafts(plan WorkstreamPlanState, ask workstreamJobsAsk, drafts []workstream.PlanItem) (WorkstreamPlanState, int, error) { - plan.Waves = append([]workstream.Wave(nil), plan.Waves...) - if ask.Wave != nil { - for i, wave := range plan.Waves { - if wave.N == *ask.Wave { - plan.Waves[i].Items = append(append([]workstream.PlanItem(nil), wave.Items...), drafts...) - return plan, wave.N, nil - } - } - return plan, 0, errors.New("wave must name an existing plan wave") - } - if ask.MilestoneID != "" && !workstreamHasMilestone(plan, ask.MilestoneID) { - return plan, 0, errors.New("milestone_id must name an existing plan milestone") - } - n := 1 - if len(plan.Waves) > 0 { - n = plan.Waves[len(plan.Waves)-1].N + 1 - } - plan.Waves = append(plan.Waves, workstream.Wave{N: n, Title: ask.WaveTitle, MilestoneID: ask.MilestoneID, AutoStart: false, Items: append([]workstream.PlanItem(nil), drafts...)}) - return plan, n, nil -} - -func workstreamHasMilestone(plan WorkstreamPlanState, id string) bool { - for _, milestone := range plan.Milestones { - if milestone.ID == id { - return true - } - } - return false -} - -func workstreamProposeGoAhead(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk, now time.Time) (string, error) { - if err := WorkstreamValidateGoAhead(ask, now); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - err := workstreamGoAheadScope(ctx, door, ask) - if err != nil { - return "", err - } - p, err := door.Propose(ctx, WorkstreamProposal{Kind: "goahead", Text: ask.Why, GoAhead: &ask}) - if err != nil { - return "", err - } - return "Proposed go-ahead " + p.ID + ". It grants nothing; the person grants it by keeping it.", nil -} - -// WorkstreamValidateGoAhead is shared with the app so its clock validates proposals too. -func WorkstreamValidateGoAhead(ask WorkstreamGoAheadAsk, now time.Time) error { - if !(ask.CapUSD > 0 && ask.CapUSD <= workstreamGoAheadCapMax) { - return fmt.Errorf("cap_usd must be greater than 0 and at most %d", workstreamGoAheadCapMax) - } - end, err := time.Parse("2006-01-02", ask.EndsOn) - today := now.UTC().Truncate(24 * time.Hour) - if err != nil || !end.After(today) || end.After(today.AddDate(0, 0, workstreamGoAheadDaysMax)) { - return fmt.Errorf("ends_on must be YYYY-MM-DD, after today in UTC and at most %d days ahead", workstreamGoAheadDaysMax) - } - if err := workstreamText(ask.Why, "why", workstreamGoAheadWhyMax, true, false); err != nil { - return err - } - return workstreamValidateGoAheadTarget(ask) -} - -func workstreamValidateGoAheadTarget(ask WorkstreamGoAheadAsk) error { - if workstreamGoAheadOtherFields(ask) { - return errors.New("The go-ahead has fields for another scope.") - } - switch ask.Scope { - case "job": - if ask.Factory == "" || ask.Job <= 0 { - return errors.New("job scope requires only factory and a positive job") - } - case "wave": - if ask.Wave <= 0 { - return errors.New("wave scope requires only a positive wave") - } - case "milestone": - if ask.MilestoneID == "" { - return errors.New("milestone scope requires only milestone_id") - } - default: - return errors.New("scope must be job, wave or milestone") - } - return nil -} - -func workstreamGoAheadOtherFields(ask WorkstreamGoAheadAsk) bool { - switch ask.Scope { - case "job": - return ask.Wave != 0 || ask.MilestoneID != "" - case "wave": - return ask.Factory != "" || ask.Job != 0 || ask.MilestoneID != "" - case "milestone": - return ask.Factory != "" || ask.Job != 0 || ask.Wave != 0 - } - return false -} - -func workstreamGoAheadScope(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk) error { - if ask.Scope == "job" { - return nil - } - plan, err := door.Plan(ctx) - if err != nil { - return err - } - if ask.Scope == "wave" { - for _, wave := range plan.Waves { - if wave.N == ask.Wave { - return nil - } - } - return errors.New("wave must name an existing plan wave") - } - for _, milestone := range plan.Milestones { - if milestone.ID == ask.MilestoneID { - return nil - } - } - return errors.New("milestone_id must name an existing plan milestone") -} - -func workstreamWritePage(ctx context.Context, door WorkstreamDoor, ask WorkstreamPageWrite) (string, error) { - if ask.PageID == "" { - if err := workstreamText(ask.Title, "title", workstreamLineMax, true, true); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - } - if err := workstreamText(ask.UpdatedFrom, "updated_from", workstreamLineMax, true, true); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - if err := workstreamSourceText(ask.Source); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - p, err := door.WritePage(ctx, ask) - if errors.Is(err, ErrWorkstreamVersion) { - return "", errors.New(workstreamPageChanged) - } - if err != nil { - return "", err - } - return fmt.Sprintf("Wrote %s (%s, version %d), marked as written by the agent from %q.", p.Title, p.ID, p.Version, ask.UpdatedFrom), nil -} - -func workstreamSuggestEdits(ctx context.Context, door WorkstreamDoor, ask WorkstreamSuggestionAsk) (string, error) { - if err := workstream.ValidateSuggestion(ask.PageID, ask.BaseVersion, ask.Why, ask.Edits); err != nil { - return "", &toolArgumentError{repair: err.Error()} - } - out, err := door.SuggestEdits(ctx, ask) - if errors.Is(err, ErrWorkstreamVersion) { - return "", errors.New(workstreamPageChanged) - } - if err != nil { - return "", err - } - return fmt.Sprintf("Suggested %d edit(s) on %s (%s). The person keeps or drops each one; the page is unchanged until then.", out.Count, out.Title, out.ID), nil -} diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go deleted file mode 100644 index 12f65ad8ba..0000000000 --- a/internal/session/workstream_tools_test.go +++ /dev/null @@ -1,458 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "reflect" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/exec/bare" - "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -type workstreamFakeDoor struct { - brief WorkstreamBrief - briefReady bool - read json.RawMessage - readAsk WorkstreamReadAsk - proposals []WorkstreamProposal - plan WorkstreamPlanState - reads int - saves []WorkstreamPlanState - bases []int - conflicts int - pageWrites []WorkstreamPageWrite - pageError error - otherCalls int - sourceCalls int - sourceError error - sourceProposals []WorkstreamSourceProposal - suggestionAsks []WorkstreamSuggestionAsk - suggestionError error -} - -func (d *workstreamFakeDoor) Source(_ context.Context, source *workstream.Source) (*workstream.Source, error) { - d.sourceCalls++ - if source == nil { - source = &workstream.Source{Kind: "chat", ID: "agent-chat"} - } - return source, d.sourceError -} - -func (d *workstreamFakeDoor) Brief() (WorkstreamBrief, bool) { - return d.brief, d.briefReady -} - -func (d *workstreamFakeDoor) Read(_ context.Context, ask WorkstreamReadAsk) (json.RawMessage, error) { - d.otherCalls++ - d.readAsk = ask - return d.read, nil -} - -func (d *workstreamFakeDoor) Propose(_ context.Context, p WorkstreamProposal) (WorkstreamProposed, error) { - d.otherCalls++ - d.proposals = append(d.proposals, p) - return WorkstreamProposed{ID: "record-1"}, nil -} - -func (d *workstreamFakeDoor) ProposeSource(_ context.Context, p WorkstreamSourceProposal) (WorkstreamSourceProposed, error) { - d.otherCalls++ - d.sourceProposals = append(d.sourceProposals, p) - return WorkstreamSourceProposed{ID: "source-1"}, d.sourceError -} - -func (d *workstreamFakeDoor) Plan(context.Context) (WorkstreamPlanState, error) { - d.reads++ - return d.plan, nil -} - -func (d *workstreamFakeDoor) SetPlan(_ context.Context, base int, p WorkstreamPlanState) (WorkstreamPlanState, error) { - d.saves = append(d.saves, p) - d.bases = append(d.bases, base) - if len(d.saves) <= d.conflicts { - d.plan.Version++ - d.plan.Waves = append(d.plan.Waves, workstream.Wave{N: 9, Title: "Person's wave", AutoStart: true, Items: []workstream.PlanItem{}}) - return WorkstreamPlanState{}, ErrWorkstreamVersion - } - d.plan = p - d.plan.Version++ - return d.plan, nil -} - -func (d *workstreamFakeDoor) WritePage(_ context.Context, p WorkstreamPageWrite) (WorkstreamPageWritten, error) { - d.otherCalls++ - d.pageWrites = append(d.pageWrites, p) - return WorkstreamPageWritten{ID: "page-1", Title: "Notes", Version: 2}, d.pageError -} - -func (d *workstreamFakeDoor) SuggestEdits(_ context.Context, ask WorkstreamSuggestionAsk) (WorkstreamSuggested, error) { - d.otherCalls++ - d.suggestionAsks = append(d.suggestionAsks, ask) - return WorkstreamSuggested{ID: "sg_1", Title: "Notes", Count: len(ask.Edits)}, d.suggestionError -} - -func workstreamToolForTest(t *testing.T, d *workstreamFakeDoor, name string) bare.Tool { - t.Helper() - a := &Agent{config: Config{Workstream: d}} - now := func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) } - for _, tool := range a.workstreamToolsAt(now) { - if tool.Name == name { - return tool - } - } - t.Fatalf("missing %s", name) - return bare.Tool{} -} - -func workstreamCallForTest(t *testing.T, d *workstreamFakeDoor, name, args string, refused bool) string { - t.Helper() - got, failed, err := workstreamToolForTest(t, d, name).Execute(context.Background(), json.RawMessage(args)) - if err != nil || failed != refused { - t.Fatalf("%s: %q failed=%v err=%v", name, got, failed, err) - } - return got -} - -// Contract (a) preserves the ordinary page and belt byte for byte. -func TestWorkstreamAgentToolsAndPromptAreScoped(t *testing.T) { - cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()} - now := time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC) - plain := &Agent{config: cfg} - belt := plain.belt() - page := renderSystemAt(cfg, now) - d := &workstreamFakeDoor{brief: WorkstreamBrief{Name: "Usage", Why: "Explain recorded usage"}, briefReady: true} - cfg.Workstream = d - with := &Agent{config: cfg} - if len(with.belt()) != len(belt)+8 || len(with.workstreamTools()) != 8 { - t.Fatal("the workstream must add exactly eight tools") - } - if !reflect.DeepEqual(workstreamOrdinaryBelt(belt), workstreamOrdinaryBelt(with.belt())) { - t.Fatal("the workstream changed the ordinary belt's bytes") - } - section := "\n\n" + workstreamPage(cfg) - rendered := renderSystemAt(cfg, now) - if !strings.Contains(rendered, `workstream "Usage"`) || !strings.Contains(rendered, "Explain recorded usage") || strings.Replace(rendered, section, "", 1) != page { - t.Fatal("the gated section changed the ordinary page") - } - if workstreamWithoutPage(rendered) != page { - t.Fatal("a child prompt retained the workstream section") - } - workstreamCheckPromotedPrompt(t, rendered, page) -} - -func TestWorkstreamAgentToolsAndPromptStayOffChildren(t *testing.T) { - cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}} - now := time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC) - for _, mutate := range []func(*Config){ - func(c *Config) { c.InTask = true }, - func(c *Config) { c.Errand = true }, - func(c *Config) { c.taskControl = &TaskNode{spec: taskSpec{quick: &quickTaskSpec{reading: true}}} }, - func(c *Config) { c.Workstream = nil }, - } { - child := cfg - mutate(&child) - a := &Agent{config: child} - if len(a.workstreamTools()) != 0 || strings.Contains(renderSystemAt(child, now), "WORKSTREAM\n") { - t.Fatal("a child or plain agent inherited the workstream") - } - } -} - -func workstreamCheckPromotedPrompt(t *testing.T, rendered, page string) { - t.Helper() - quick := &quickTaskSpec{system: rendered} - quick.seed = append(quick.seed, textMessage("system", rendered)) - child := &Agent{} - adoptSeed(child, quick) - if child.system != promotedSystem(page) || messageContentText(quick.seed[0]) != rendered { - t.Fatal("a promoted worker retained the workstream section or changed its parent's seed") - } -} - -func TestWorkstreamPromptUsesSnapshotOrFallback(t *testing.T) { - d := &workstreamFakeDoor{} - cfg := Config{Workstream: d} - if !strings.Contains(workstreamPage(cfg), "Read it with workstream_read before you answer.") { - t.Fatal("the early prompt must remain usable") - } - d.briefReady = true - d.brief = WorkstreamBrief{Name: strings.Repeat(`"`, 256), Why: strings.Repeat("why ", 1000)} - if len(workstreamPage(cfg)) > 1000 { - t.Fatal("the workstream prompt section exceeded its budget") - } -} - -func TestWorkstreamPromptFallbackRendersImmediatelyUnderTheSessionLock(t *testing.T) { - a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}}} - finished := make(chan string, 1) - guard.Go("session/workstream-prompt-test", func() { - finished <- workstreamPromptUnderLock(a) - }) - select { - case page := <-finished: - if !strings.Contains(page, "Read it with workstream_read before you answer.") { - t.Fatal("a missing snapshot must render the fallback") - } - if !a.mu.TryLock() { - t.Fatal("prompt rendering retained the session lock") - } - defer a.mu.Unlock() - case <-time.After(3 * time.Second): - t.Fatal("the prompt did not render promptly without a store") - } -} - -func workstreamPromptUnderLock(a *Agent) string { - a.mu.Lock() - defer a.mu.Unlock() - return renderSystemAt(a.config, time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC)) -} - -func workstreamOrdinaryBelt(tools []bare.Tool) []string { - names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "propose_source", "write_agent_page", "suggest_page_edit"} - var out []string - for _, tool := range tools { - if !workstreamHasWord(names, tool.Name) { - out = append(out, tool.Name+"\n"+tool.Description+"\n"+string(tool.Schema)) - } - } - return out -} - -// Contract (b) appends only drafts and preserves an existing wave's settings. -func TestWorkstreamJobsAppendToExistingWave(t *testing.T) { - d := &workstreamFakeDoor{plan: WorkstreamPlanState{Version: 3, Waves: []workstream.Wave{{N: 2, AutoStart: true, Items: []workstream.PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}}} - got := workstreamCallForTest(t, d, "propose_jobs", `{"wave":2,"jobs":[{"title":"One"},{"title":"Two"}]}`, false) - wave := d.saves[0].Waves[0] - if got != "Added 2 draft jobs to wave 2 of the plan. They are drafts: nothing runs until the person launches them." || !wave.AutoStart || len(wave.Items) != 3 || wave.Items[0].Job != 7 || wave.Items[1].DraftID == wave.Items[2].DraftID { - t.Fatalf("appended wave: %q %+v", got, wave) - } -} - -// Contract (g) labels unknown readings and caps text without splitting UTF-8. -func TestWorkstreamReadCapsTextAndNamesUnknownNumbers(t *testing.T) { - d := &workstreamFakeDoor{read: json.RawMessage(`{"name":"Usage"}`)} - got := workstreamCallForTest(t, d, "workstream_read", `{"what":"workstream"}`, false) - if got != "Numbers absent below are unknown; say so and never estimate them.\n"+string(d.read) { - t.Fatalf("unknown numbers: %q", got) - } - d.read = json.RawMessage(`{"markdown":"` + strings.Repeat("é", 40000) + `"}`) - got = workstreamCallForTest(t, d, "workstream_read", `{"what":"page","page_id":"p1"}`, false) - if len(got) > 60000 || !strings.HasSuffix(got, "\nThe workstream read was cut.") || d.readAsk.PageID != "p1" { - t.Fatalf("capped read: %d %+v", len(got), d.readAsk) - } -} - -// Contract (e) returns attribution; a page version conflict is refused without retry. -func TestWorkstreamPageWritesAndConflictsHaveExactAnswers(t *testing.T) { - d := &workstreamFakeDoor{} - got := workstreamCallForTest(t, d, "write_agent_page", `{"title":"Notes","markdown":"Words","updated_from":"Discussion"}`, false) - if got != `Wrote Notes (page-1, version 2), marked as written by the agent from "Discussion".` || len(d.pageWrites) != 1 || d.pageWrites[0].Source != nil { - t.Fatalf("write receipt: %q %+v", got, d.pageWrites) - } - d.pageError = ErrWorkstreamVersion - got = workstreamCallForTest(t, d, "write_agent_page", `{"page_id":"p1","markdown":"Words","updated_from":"Discussion"}`, true) - if got != "The page changed while you were writing. Read it again before writing." || len(d.pageWrites) != 2 { - t.Fatalf("conflict receipt: %q %+v", got, d.pageWrites) - } -} - -// Contract (g) also applies to nested objects and fields for another operation. -func TestWorkstreamArgumentsRefuseNestedFactsAndScopeMixing(t *testing.T) { - cases := []struct { - name string - args string - key string - }{ - {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":true}}`, "kept"}, - {"propose_jobs", `{"jobs":[{"title":"Draft","job":7}]}`, "job"}, - {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":1,"wave_title":"New"}`, "wave_title"}, - {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":1,"milestone_id":"m1"}`, "milestone_id"}, - {"write_agent_page", `{"page_id":"p1","title":"Rename","markdown":"Words","updated_from":"Discussion"}`, "title"}, - {"write_agent_page", `{"page_id":"p1","parent_id":"p2","markdown":"Words","updated_from":"Discussion"}`, "parent_id"}, - {"propose_goahead", `{"scope":"wave","wave":1,"job":0,"cap_usd":1,"ends_on":"2026-11-04","why":"Words"}`, "job"}, - {"workstream_read", `{"what":"pages","page_id":"p1"}`, "page_id"}, - {"workstream_read", `{"what":"plan","state":"kept"}`, "state"}, - {"workstream_read", `{"what":"plan","kind":"decision"}`, "kind"}, - } - for _, tc := range cases { - d := &workstreamFakeDoor{} - got := workstreamCallForTest(t, d, tc.name, tc.args, true) - if !strings.Contains(got, tc.key) || d.otherCalls+d.reads+len(d.saves) != 0 { - t.Fatalf("%s: %q %+v", tc.name, got, d) - } - } -} - -// Contracts (b) and (g) bound proposed permission by the UTC day and dollar cap. -func TestWorkstreamGoAheadDatesAndCapsAreOnlyProposals(t *testing.T) { - now := time.Date(2026, 11, 3, 23, 0, 0, 0, time.FixedZone("west", -5*60*60)) - base := WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 10000, EndsOn: "2026-11-05", Why: "Review"} - if err := WorkstreamValidateGoAhead(base, now); err != nil { - t.Fatal(err) - } - for _, mutate := range []func(*WorkstreamGoAheadAsk){ - func(a *WorkstreamGoAheadAsk) { a.CapUSD = 0 }, - func(a *WorkstreamGoAheadAsk) { a.CapUSD = 10000.01 }, - func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-04" }, - func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2027-02-02" }, - func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-05T00:00:00Z" }, - func(a *WorkstreamGoAheadAsk) { a.Why = "" }, - } { - ask := base - mutate(&ask) - if err := WorkstreamValidateGoAhead(ask, now); err == nil { - t.Fatalf("accepted %+v", ask) - } - } -} - -// Contract (b) exposes no way to keep a record or grant a go-ahead. -func TestWorkstreamProposalsAreOnlyWords(t *testing.T) { - d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} - for _, kind := range []string{"decision", "question"} { - got := workstreamCallForTest(t, d, "propose_"+kind, `{"text":"Keep this?"}`, false) - want := "Proposed " + kind + " record-1. Nothing is kept until the person keeps it." - if got != want || d.proposals[len(d.proposals)-1].Kind != kind || d.proposals[len(d.proposals)-1].Source != nil { - t.Fatalf("proposal: %q %+v", got, d.proposals) - } - } - got := workstreamCallForTest(t, d, "propose_goahead", `{"scope":"wave","wave":2,"cap_usd":12.5,"ends_on":"2026-11-04","why":"Review the result"}`, false) - p := d.proposals[len(d.proposals)-1] - if got != "Proposed go-ahead record-1. It grants nothing; the person grants it by keeping it." || p.Kind != "goahead" || p.Text != "Review the result" || p.GoAhead == nil || p.GoAhead.Scope != "wave" || p.GoAhead.Wave != 2 || p.GoAhead.CapUSD != 12.5 || p.GoAhead.EndsOn != "2026-11-04" { - t.Fatalf("go-ahead: %q %+v", got, p) - } -} - -// Contract (f) rereads and reapplies once, preserving every existing plan field. -func TestWorkstreamJobsRetryOnlyOneVersionConflict(t *testing.T) { - for _, conflicts := range []int{0, 1, 2} { - d := &workstreamFakeDoor{conflicts: conflicts, plan: WorkstreamPlanState{Version: 4, Milestones: []workstream.Milestone{{ID: "m1", Title: "Ship"}}, Waves: []workstream.Wave{{N: 3, Title: "Existing", MilestoneID: "m1", AutoStart: true, Items: []workstream.PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}}} - before := d.plan - got := workstreamCallForTest(t, d, "propose_jobs", `{"jobs":[{"title":"Draft","repo":"api","text":"Read first"}]}`, conflicts == 2) - wantCalls := min(conflicts+1, 2) - if len(d.saves) != wantCalls || d.reads != wantCalls || d.otherCalls != 0 { - t.Fatalf("conflicts=%d calls=%+v", conflicts, d) - } - if !reflect.DeepEqual(d.saves[0].Waves[0], before.Waves[0]) || !reflect.DeepEqual(d.saves[0].Milestones, before.Milestones) { - t.Fatal("existing plan changed") - } - workstreamCheckRetriedDrafts(t, d, conflicts, got) - } -} - -func workstreamCheckRetriedDrafts(t *testing.T, d *workstreamFakeDoor, conflicts int, got string) { - t.Helper() - last := d.saves[len(d.saves)-1] - wave := last.Waves[len(last.Waves)-1] - if wave.AutoStart || len(wave.Items) != 1 || wave.Items[0].Kind != "draft" || !strings.HasPrefix(wave.Items[0].DraftID, "draft_") { - t.Fatalf("not drafts: %+v", wave) - } - if conflicts == 2 && got != "The plan changed while you were proposing; nothing was added. Read the plan and propose again." { - t.Fatalf("second conflict: %q", got) - } - if conflicts == 1 && (wave.N != 10 || d.bases[1] != 5 || last.Waves[1].Title != "Person's wave") { - t.Fatalf("did not reapply to the new plan: %+v", last) - } - if conflicts > 0 && d.saves[0].Waves[1].Items[0].DraftID != wave.Items[0].DraftID { - t.Fatal("retry changed the draft identity") - } -} - -func TestWorkstreamArgumentsRefuseDuplicateKeysBeforeTheDoor(t *testing.T) { - cases := []struct { - name string - args string - key string - }{ - {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":true},"source":{"kind":"url","id":"https://example.com"}}`, "source"}, - {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","id":"https://example.com"}}`, "id"}, - {"write_agent_page", `{"title":"Notes","markdown":"First","markdown":"Second","updated_from":"Discussion"}`, "markdown"}, - {"propose_jobs", `{"jobs":[{"title":"First","title":"Second"}]}`, "title"}, - {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com"},"sour\u0063e":{"kind":"url","id":"https://example.com"}}`, "source"}, - } - for _, tc := range cases { - d := &workstreamFakeDoor{} - got := workstreamCallForTest(t, d, tc.name, tc.args, true) - if !strings.Contains(got, tc.key) || d.otherCalls+d.reads+len(d.saves) != 0 { - t.Fatalf("%s: %q %+v", tc.name, got, d) - } - } -} - -// Contract (d) surfaces an ownership refusal without pretending to write. -func TestWorkstreamPageOwnershipRefusal(t *testing.T) { - d := &workstreamFakeDoor{pageError: ErrWorkstreamNotAgentPage} - got := workstreamCallForTest(t, d, "write_agent_page", `{"page_id":"page-1","markdown":"Words","updated_from":"Discussion"}`, true) - if got != "That page is the person's. You write only your own pages; suggest edits on theirs with suggest_page_edit." { - t.Fatalf("refusal: %q", got) - } -} - -// Contract (g) refuses unknown fields before touching any door. -func TestWorkstreamToolsRefuseInventedFacts(t *testing.T) { - names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "propose_source", "write_agent_page", "suggest_page_edit"} - fields := []string{"kept", "state", "cost_usd", "spend_usd", "count", "merged", "budget_usd", "auto_start", "version"} - for _, name := range names { - for _, field := range fields { - if name == "workstream_read" && field == "state" { - continue - } - d := &workstreamFakeDoor{} - got := workstreamCallForTest(t, d, name, `{"`+field+`":true}`, true) - if !strings.Contains(got, field) || d.otherCalls+d.reads+len(d.saves) != 0 { - t.Fatalf("%s accepted %s: %q", name, field, got) - } - } - var schema map[string]any - if err := json.Unmarshal(workstreamToolForTest(t, &workstreamFakeDoor{}, name).Schema, &schema); err != nil || schema["additionalProperties"] != false { - t.Fatalf("%s schema: %+v %v", name, schema, err) - } - properties := schema["properties"].(map[string]any) - for _, field := range fields { - if name == "workstream_read" && field == "state" { - continue - } - if _, exists := properties[field]; exists { - t.Fatalf("%s schema accepts invented %s", name, field) - } - } - } -} - -// Contract (h) assigns an action family to every workstream tool. -func TestWorkstreamToolActionCategories(t *testing.T) { - for _, tool := range (&Agent{config: Config{Workstream: &workstreamFakeDoor{}}}).workstreamTools() { - want := ActionCreate - if tool.Name == "workstream_read" { - want = ActionRead - } - if got := ActionCategoryForTool(tool.Name); got != want { - t.Errorf("%s: %s, want %s", tool.Name, got, want) - } - } -} - -// The app draws the agent's summary from the agent page titled exactly -// "Where it stands", so the prompt and write_agent_page both name that title. -func TestWorkstreamAgentKeepsWhereItStandsCurrent(t *testing.T) { - if WorkstreamWhereItStandsTitle != "Where it stands" { - t.Fatalf("the Overview summary page title moved: %q", WorkstreamWhereItStandsTitle) - } - cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}} - if !strings.Contains(workstreamPage(cfg), `titled "Where it stands" current with write_agent_page`) { - t.Fatal("the workstream prompt does not ask the agent to keep Where it stands current") - } - described := false - for _, tool := range (&Agent{config: cfg}).workstreamTools() { - if tool.Name == "write_agent_page" { - described = strings.Contains(tool.Description, `"Where it stands"`) - } - } - if !described { - t.Fatal("write_agent_page does not name the Where it stands page") - } -} diff --git a/internal/session/workstream_validation_test.go b/internal/session/workstream_validation_test.go deleted file mode 100644 index e5542f52b7..0000000000 --- a/internal/session/workstream_validation_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// Contract (g) validates byte bounds and JSON shapes before any read or write. -func TestWorkstreamToolBoundsRefuseBeforeTheDoor(t *testing.T) { - cases := []struct { - name string - args string - }{ - {"propose_decision", `{"text":""}`}, - {"propose_decision", `{"text":"` + strings.Repeat("é", 1001) + `"}`}, - {"propose_question", `{"text":"Words","source":{"kind":"url","id":"https://example.com","label":"` + strings.Repeat("x", 201) + `"}}`}, - {"propose_jobs", `{"jobs":[]}`}, - {"propose_jobs", `{"jobs":[{"title":"` + strings.Repeat("x", 201) + `"}]}`}, - {"propose_jobs", `{"jobs":[{"title":"Draft","repo":"two\nlines"}]}`}, - {"propose_jobs", `{"jobs":[{"title":"Draft","text":"` + strings.Repeat("x", 4001) + `"}]}`}, - {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":"1"}`}, - {"propose_goahead", `{"scope":"job","factory":"f","job":7,"cap_usd":"1","ends_on":"2026-11-04","why":"Words"}`}, - {"write_agent_page", `{"markdown":"Words","updated_from":"Discussion"}`}, - {"write_agent_page", `{"title":"Notes","markdown":"Words","updated_from":"two\nlines"}`}, - {"workstream_read", `[]`}, - {"workstream_read", `null`}, - {"workstream_read", `{"what":null}`}, - {"workstream_read", `{"what":"page"}`}, - } - for _, tc := range cases { - d := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - text, bad, err := workstreamToolForTest(t, d, tc.name).Execute(ctx, json.RawMessage(tc.args)) - if err != nil || !bad || !mark.RejectedBeforeRun() { - t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) - } - if d.otherCalls+d.reads+len(d.saves) != 0 { - t.Fatalf("invalid %s reached the door: %+v", tc.name, d) - } - } -} - -func TestWorkstreamArgumentRefusalsAreMarked(t *testing.T) { - for _, tc := range []struct { - name string - args string - key string - }{ - {"workstream_read", `{"what":`, "valid JSON"}, - {"workstream_read", `{"what":"page"}`, "page_id"}, - {"workstream_read", `{"what":"pages","page_id":""}`, "page_id"}, - {"workstream_read", `{"what":"plan","kind":"decision"}`, "kind"}, - {"workstream_read", `"{\"what\":\"pages\"}"`, "JSON object"}, - {"propose_decision", `{"text":"Words","kept":false}`, "kept"}, - {"propose_question", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":false}}`, "kept"}, - {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","id":"https://example.com"}}`, "id"}, - {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","factory":""}}`, "factory"}, - {"propose_decision", `{"text":"Words","source":null}`, "source"}, - {"propose_decision", `{"text":"Words","source":"{\"kind\":\"url\",\"id\":\"https://example.com\",\"kept\":true}"}`, "source"}, - {"propose_jobs", `{"jobs":[{"title":"Draft","kept":false}]}`, "kept"}, - {"propose_jobs", `{"jobs":[null]}`, "jobs[0]"}, - {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":null}`, "wave"}, - {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":2,"wave_title":""}`, "wave_title"}, - {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":2,"milestone_id":""}`, "milestone_id"}, - {"propose_goahead", `{"scope":"wave","wave":2,"factory":"","cap_usd":12.5,"ends_on":"2026-11-04","why":"Review"}`, "factory"}, - {"write_agent_page", `{"page_id":"p1","title":"","markdown":"Words","updated_from":"Discussion"}`, "title"}, - {"write_agent_page", `{"page_id":"p1","parent_id":"","markdown":"Words","updated_from":"Discussion"}`, "parent_id"}, - } { - d := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - text, bad, err := workstreamToolForTest(t, d, tc.name).Execute(ctx, json.RawMessage(tc.args)) - if err != nil || !bad || !mark.RejectedBeforeRun() || !strings.Contains(text, tc.key) { - t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) - } - if d.otherCalls+d.reads+len(d.saves) != 0 { - t.Fatalf("invalid %s reached the door: %+v", tc.name, d) - } - } -} - -func TestWorkstreamDoorErrorsAreNotArgumentRejections(t *testing.T) { - for _, problem := range []error{ErrWorkstreamVersion, ErrWorkstreamNotAgentPage, ErrWorkstreamDone, errors.New("Invalid arguments: from the page store")} { - d := &workstreamFakeDoor{pageError: problem} - ctx, mark := withToolRejection(context.Background()) - text, bad, err := workstreamToolForTest(t, d, "write_agent_page").Execute(ctx, json.RawMessage(`{"page_id":"p1","markdown":"Words","updated_from":"Discussion"}`)) - if err != nil || !bad || mark.RejectedBeforeRun() || len(d.pageWrites) != 1 { - t.Fatalf("%v: %q bad=%v rejected=%v err=%v", problem, text, bad, mark.RejectedBeforeRun(), err) - } - } -} - -func TestWorkstreamSchemasAreWellFormedAndCarryNoReference(t *testing.T) { - for _, tool := range (&Agent{config: Config{Workstream: &workstreamFakeDoor{}}}).workstreamTools() { - if !json.Valid(tool.Schema) { - t.Fatalf("%s has a malformed schema", tool.Name) - } - for _, mark := range []string{`"$ref"`, `"$defs"`, `"definitions"`} { - if strings.Contains(string(tool.Schema), mark) { - t.Fatalf("%s schema carries %s", tool.Name, mark) - } - } - } -} - -func TestWorkstreamWholeNumbersUseTheSharedArgumentGrammar(t *testing.T) { - for _, number := range []string{"2.0", "2e0"} { - d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} - workstreamCallForTest(t, d, "propose_jobs", `{"wave":`+number+`,"jobs":[{"title":"Draft"}]}`, false) - if len(d.saves) != 1 || len(d.saves[0].Waves[0].Items) != 1 { - t.Fatalf("whole-valued wave %s did not append one draft: %+v", number, d) - } - } - for _, tc := range []struct { - number string - want string - }{ - {"2.5", `Invalid arguments: wave takes a whole number: send {"wave":2}, not 2.5`}, - {`"2"`, `Invalid arguments: wave takes a whole number: send {"wave":2}, not "2"`}, - } { - d := &workstreamFakeDoor{} - ctx, mark := withToolRejection(context.Background()) - text, bad, err := workstreamToolForTest(t, d, "propose_jobs").Execute(ctx, json.RawMessage(`{"wave":`+tc.number+`,"jobs":[{"title":"Draft"}]}`)) - if err != nil || !bad || !mark.RejectedBeforeRun() || text != tc.want { - t.Fatalf("wave %s: %q bad=%v rejected=%v err=%v", tc.number, text, bad, mark.RejectedBeforeRun(), err) - } - if d.otherCalls+d.reads+len(d.saves) != 0 { - t.Fatal("an invalid wave reached the door") - } - } -} - -func TestWorkstreamGoAheadDecodesFractionalDollarsAndWholeTargets(t *testing.T) { - for _, tc := range []struct { - name string - target string - scope string - }{ - {"job", `"factory":"f","job":7.0`, "job f#7"}, - {"wave", `"wave":2e0`, "wave 2"}, - } { - d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} - ctx, mark := withToolRejection(context.Background()) - args := `{"scope":"` + tc.name + `",` + tc.target + `,"cap_usd":12.50,"ends_on":"2026-11-04","why":"Review"}` - text, bad, err := workstreamToolForTest(t, d, "propose_goahead").Execute(ctx, json.RawMessage(args)) - if err != nil || bad || mark.RejectedBeforeRun() || len(d.proposals) != 1 { - t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) - } - proposal := d.proposals[0] - if proposal.GoAhead == nil || proposal.GoAhead.CapUSD != 12.5 || proposal.Text != "Review" || proposal.Kind != "goahead" || proposal.GoAhead.Scope != tc.name || (tc.name == "job" && (proposal.GoAhead.Factory != "f" || proposal.GoAhead.Job != 7)) || (tc.name == "wave" && proposal.GoAhead.Wave != 2) { - t.Fatalf("fractional dollars changed: %+v", proposal) - } - } -} diff --git a/internal/session/workstream_wake.go b/internal/session/workstream_wake.go deleted file mode 100644 index 057e9a2971..0000000000 --- a/internal/session/workstream_wake.go +++ /dev/null @@ -1,89 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "errors" - - "github.com/Agent-Field/agentfield/sdk/go/ai" - - "github.com/Agent-Field/codeaf/internal/lane" - "github.com/Agent-Field/codeaf/internal/provider" - "github.com/Agent-Field/codeaf/internal/roles" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -// WorkstreamWakeAsk is a bounded snapshot for one unattended turn, with no tools. -type WorkstreamWakeAsk struct { - ID string - AgentID string - Event workstream.WakeEvent - Snapshot json.RawMessage -} - -type WorkstreamWakeAnswer struct { - Text string - CostUSD *float64 - Model string -} - -const workstreamWakeRules = `You are the workstream's agent. You propose; the person keeps. You write only your own pages; the person's pages are theirs. Keep your agent page "` + WorkstreamWhereItStandsTitle + `" current, saying what it was updated from. Never invent numbers: counts, costs, states and dates come from the snapshot; absent numbers are unknown. On a page use a live block such as [live: jobs] instead of writing a number. You start nothing; the person launches jobs. -For this wake, answer one JSON object. Summarise in at most two sentences, rank the most useful items first, at most 10 items. The engine decides what applies under autonomy; decisions and questions are always proposed. You cannot start jobs or write the person's pages. There are no tools.` - -const workstreamWakeFormat = `Answer format: -{"summary":"...","items":[{"kind":"move","factory":"f1","job":1102,"to_wave":3,"why":"..."},{"kind":"move","draft_id":"d_x","to_wave":2,"why":"..."},{"kind":"draft","title":"...","repo":"api","text":"...","size":"S","budget_usd":3,"wave":3,"why":"..."},{"kind":"page","page_id":"existing agent page id; omit for new","title":"...","markdown":"...","why":"..."},{"kind":"decision","text":"...","why":"..."},{"kind":"question","text":"...","why":"..."}]}` - -func workstreamWakeQuestion(ask WorkstreamWakeAsk) string { - event, _ := json.Marshal(ask.Event) - snapshot := "Numbers absent below are unknown; say so and never estimate them.\n" + string(ask.Snapshot) - if len(snapshot) > workstreamReadMax { - const tail = "\nThe workstream read was cut." - snapshot = workstreamCut(snapshot, workstreamReadMax-len(tail)) + tail - } - return "Event:\n" + string(event) + "\nWorkstream snapshot:\n" + snapshot + "\n" + workstreamWakeFormat -} - -// RunWorkstreamWake resolves the credentialed sentinel ladder down to the person's model. -// It constructs call state only: no agent, transcript, tool belt or turn loop. -func RunWorkstreamWake(ctx context.Context, settings TeamProposerConfig, ask WorkstreamWakeAsk) (WorkstreamWakeAnswer, error) { - cfg := Config{Model: settings.Model, APIKey: settings.APIKey, BaseURL: settings.BaseURL, ProfileDir: settings.ProfileDir, Sources: settings.Sources, RolesSource: settings.RolesSource} - models, err := automationJudgeModels(cfg) - if err != nil { - return WorkstreamWakeAnswer{}, err - } - model := models[0] - client, err := newProviderClient(cfg, model) - if err != nil { - return WorkstreamWakeAnswer{}, err - } - return workstreamWakeCall(ctx, client, model, ask, func(line UsageLine) { RecordUsage(UsageLedgerPath(), line) }) -} - -func workstreamWakeCall(ctx context.Context, client Completer, model string, ask WorkstreamWakeAsk, record func(UsageLine)) (WorkstreamWakeAnswer, error) { - ctx = provider.WithRole(provider.WithRoutingIntent(provider.WithoutStream(withPurpose(ctx, purposeWorkstreamWake)), provider.IntentBackground), lane.RoleStanding) - response, err := client.CompleteWithMessages(ctx, []ai.Message{textMessage("system", workstreamWakeRules), textMessage("user", workstreamWakeQuestion(ask))}) - answer := WorkstreamWakeAnswer{Model: model} - line := UsageLine{Model: model, Role: string(roles.RoleSentinel), Calls: 1, Session: ask.AgentID, Run: ask.ID, Unbilled: true} - if response == nil { - record(line) - return answer, workstreamWakeResponseError(err) - } - answer.Text = response.Text() - if response.Usage != nil { - line.Input, line.Output = response.Usage.PromptTokens, response.Usage.CompletionTokens - answer.CostUSD = response.Usage.Cost - if answer.CostUSD != nil { - line.USD = *answer.CostUSD - line.Price = usagePrice(line.USD, usagePriceProvider) - line.Unbilled = false - } - } - record(line) - return answer, err -} -func workstreamWakeResponseError(err error) error { - if err != nil { - return err - } - return errors.New("the model answered nothing") -} diff --git a/internal/session/workstream_wake_test.go b/internal/session/workstream_wake_test.go deleted file mode 100644 index 0bc04cb14e..0000000000 --- a/internal/session/workstream_wake_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "net/http" - "strings" - "testing" - "unicode/utf8" - - "github.com/Agent-Field/agentfield/sdk/go/ai" - "github.com/Agent-Field/codeaf/internal/lane" - "github.com/Agent-Field/codeaf/internal/provider" - "github.com/Agent-Field/codeaf/internal/workstream" -) - -func TestWakeB_OneModelCallWithCostAndNoTools(t *testing.T) { - for _, known := range []bool{true, false} { - t.Run(map[bool]string{true: "known", false: "unknown"}[known], func(t *testing.T) { - usd := .04 - client := &scriptedCompleter{steps: []step{func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { - if len(messages) != 2 || provider.RoleFrom(ctx) != lane.RoleStanding || provider.RoutingIntentFrom(ctx) != provider.IntentBackground { - t.Fatalf("wake call: %+v", messages) - } - if !strings.Contains(messages[0].Content[0].Text, "person's pages") || !strings.Contains(messages[1].Content[0].Text, "Answer format:") { - t.Fatal("wake rules absent") - } - r := textResponse(`{"summary":"Next steps","items":[]}`) - if known { - r.Usage = &ai.Usage{Cost: &usd, PromptTokens: 10, CompletionTokens: 5} - } else { - r.Usage = nil - } - return r, nil - }}} - var lines []UsageLine - answer, err := workstreamWakeCall(context.Background(), client, "cheap/model", WorkstreamWakeAsk{ID: "wake", AgentID: "agent", Event: workstream.WakeEvent{Kind: "morning"}, Snapshot: json.RawMessage(`{}`)}, func(line UsageLine) { lines = append(lines, line) }) - if err != nil || client.requests() != 1 || len(lines) != 1 || answer.Model != "cheap/model" { - t.Fatal(answer, lines, err) - } - if known && (answer.CostUSD == nil || *answer.CostUSD != .04 || lines[0].USD != .04) { - t.Fatal("cost lost") - } - if !known && (answer.CostUSD != nil || !lines[0].Unbilled) { - t.Fatal("unknown cost fabricated") - } - }) - } -} - -func TestWakeB_m2_ModelCallCarriesWakePurpose(t *testing.T) { - checks := 0 - adapter, err := provider.NewClient(provider.Config{ - BaseURL: "http://provider.test", Model: "test/model", APIKey: "test-key", - HTTPClient: fakeHTTP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`)) - })), - RouteGate: func(ctx context.Context, model, tag string) error { - checks++ - if tag != "workstream-wake" { - t.Fatalf("wake purpose: %q", tag) - } - return nil - }, - }) - if err != nil { - t.Fatal(err) - } - client := &scriptedCompleter{steps: []step{func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { - return adapter.CompleteWithMessages(ctx, messages) - }}} - lines := 0 - _, err = workstreamWakeCall(context.Background(), client, "test/model", WorkstreamWakeAsk{ID: "wake", AgentID: "agent", Event: workstream.WakeEvent{Kind: "morning"}, Snapshot: json.RawMessage(`{}`)}, func(UsageLine) { lines++ }) - if err != nil || client.requests() != 1 || checks != 1 || lines != 1 { - t.Fatalf("wake call: requests %d, purpose checks %d, usage lines %d (%v)", client.requests(), checks, lines, err) - } -} - -func TestWakeB_SnapshotUsesReadCutRule(t *testing.T) { - text := workstreamWakeQuestion(WorkstreamWakeAsk{Snapshot: json.RawMessage(`{"words":"` + strings.Repeat("é", 40000) + `"}`)}) - start := strings.Index(text, "Numbers absent below") - end := strings.Index(text, "\nAnswer format:") - if start < 0 || end < start || end-start > 60000 || !utf8.ValidString(text) || !strings.HasSuffix(text[start:end], "\nThe workstream read was cut.") { - t.Fatal("snapshot cut drifted") - } -} diff --git a/internal/workstream/goaheads.go b/internal/workstream/goaheads.go deleted file mode 100644 index 8dd562bfdf..0000000000 --- a/internal/workstream/goaheads.go +++ /dev/null @@ -1,232 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "math" - "strconv" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const goaheadSelect = `SELECT id,workstream_id,scope,coalesce(factory,''),coalesce(job,0),coalesce(wave,0),coalesce(milestone_id,''),cap_usd,ends_at,state,coalesce(end_reason,''),granted_at,ended_at FROM workstream_goaheads WHERE id=?` - -func goaheadIn(ctx context.Context, tx *sql.Tx, id string) (GoAhead, error) { - var g GoAhead - var ends, granted int64 - var ended sql.NullInt64 - err := tx.QueryRowContext(ctx, goaheadSelect, id).Scan(&g.ID, &g.WorkstreamID, &g.Scope, &g.Factory, &g.Job, &g.Wave, &g.MilestoneID, &g.CapUSD, &ends, &g.State, &g.EndReason, &granted, &ended) - if err != nil { - return g, found(err) - } - g.EndsAt, g.GrantedAt, g.EndedAt = instant(ends), instant(granted), optionalInstant(ended) - return g, nil -} - -// Expiry is derived on read, so listing never writes or emits a change. -func expire(g GoAhead, now time.Time) GoAhead { - if g.State == "active" && !g.EndsAt.After(now) { - g.State = "ended" - g.EndReason = "expired" - g.EndedAt = g.EndsAt - } - return g -} -func (s *Store) GoAheads(ctx context.Context, id string, ended bool) ([]GoAhead, error) { - out := []GoAhead{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, "SELECT id FROM workstream_goaheads WHERE workstream_id=? ORDER BY granted_at DESC,rowid DESC", id) - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err - } - now := s.now() - for _, id := range ids { - g, err := goaheadIn(ctx, tx, id) - if err != nil { - return err - } - g = expire(g, now) - if ended || g.State == "active" { - out = append(out, g) - } - } - return nil - }) - return out, err -} -func workstreamGoAheadFields(p GoAheadGrant, now time.Time) error { - // Store millisecond precision in the validation too: an end within the same - // stored millisecond as the grant is not a future authorization. - ends := p.EndsAt - if math.IsNaN(p.CapUSD) || math.IsInf(p.CapUSD, 0) || p.CapUSD <= 0 || p.CapUSD > workspace.WorkstreamGoAheadCapMax || ends.IsZero() || !ends.After(now) || ends.After(now.Add(workspace.WorkstreamGoAheadDurationMax)) || ends.UnixMilli() <= now.UnixMilli() { - return invalid("go-ahead needs a cap greater than 0 and at most 10000, and a future end within 90 days") - } - switch p.Scope { - case "job": - if p.Job <= 0 || p.Wave != 0 || p.MilestoneID != "" { - return invalid("job scope needs only factory and job") - } - if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: strconv.Itoa(p.Job), Factory: p.Factory}).Validate(); err != nil { - return err - } - case "wave": - if p.Wave <= 0 || p.Factory != "" || p.Job != 0 || p.MilestoneID != "" { - return invalid("wave scope needs only wave") - } - case "milestone": - if p.MilestoneID == "" || p.Factory != "" || p.Job != 0 || p.Wave != 0 { - return invalid("milestone scope needs only milestone_id") - } - default: - return invalid("go-ahead needs a scope") - } - return nil -} - -func workstreamGoAheadAllowedIn(ctx context.Context, tx *sql.Tx, p GoAheadGrant, at time.Time) error { - w, err := getIn(ctx, tx, p.WorkstreamID) - if err != nil { - return err - } - if !w.DoneAt.IsZero() { - return ErrConflict - } - if err := workstreamGoAheadFields(p, at); err != nil { - return err - } - if p.Scope == "job" { - for _, j := range w.Jobs() { - if j.Factory == p.Factory && j.Number == p.Job { - return nil - } - } - } else { - plan, err := planIn(ctx, tx, p.WorkstreamID) - if err != nil { - return err - } - if workstreamGoAheadInPlan(p, plan) { - return nil - } - } - return invalid("scope target is not a member or does not exist in the plan") -} - -func workstreamGoAheadInPlan(p GoAheadGrant, plan Plan) bool { - for _, wave := range plan.Waves { - if p.Scope == "wave" && wave.N == p.Wave { - return true - } - } - for _, milestone := range plan.Milestones { - if p.Scope == "milestone" && milestone.ID == p.MilestoneID { - return true - } - } - return false -} - -// Both direct grants and kept proposals validate and insert under the writer. -func (s *Store) grantGoAheadIn(ctx context.Context, tx *sql.Tx, p GoAheadGrant, at time.Time) (GoAhead, error) { - if err := workstreamGoAheadAllowedIn(ctx, tx, p, at); err != nil { - return GoAhead{}, err - } - id, err := s.newID() - if err != nil { - return GoAhead{}, err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_goaheads(id,workstream_id,scope,factory,job,wave,milestone_id,cap_usd,ends_at,state,granted_at) VALUES (?,?,?,?,?,?,?,?,?,'active',?)`, id, p.WorkstreamID, p.Scope, nullString(p.Factory), nullInt(p.Job), nullInt(p.Wave), nullString(p.MilestoneID), p.CapUSD, p.EndsAt.UnixMilli(), at.UnixMilli()); err != nil { - return GoAhead{}, err - } - return goaheadIn(ctx, tx, id) -} - -func (s *Store) GrantGoAhead(ctx context.Context, p GoAheadGrant) (GoAhead, Change, error) { - if err := workstreamGoAheadFields(p, s.now()); err != nil { - return GoAhead{}, Change{}, err - } - var out GoAhead - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - at := s.now() - var err error - out, err = s.grantGoAheadIn(ctx, tx, p, at) - if err != nil { - return err - } - if err := touch(ctx, tx, p.WorkstreamID, at.UnixMilli()); err != nil { - return err - } - c = changed(p.WorkstreamID, PartGoAheads) - return nil - }) - if err != nil { - return GoAhead{}, Change{}, err - } - return out, c, nil -} - -func revokeGoAheadIn(ctx context.Context, tx *sql.Tx, id string, now time.Time) (GoAhead, bool, error) { - g, err := goaheadIn(ctx, tx, id) - if err != nil { - return GoAhead{}, false, err - } - g = expire(g, now) - if g.State == "ended" { - return g, false, nil - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_goaheads SET state='ended',end_reason='revoked',ended_at=? WHERE id=?", now.UnixMilli(), id); err != nil { - return GoAhead{}, false, err - } - g, err = goaheadIn(ctx, tx, id) - return g, true, err -} - -func (s *Store) RevokeGoAhead(ctx context.Context, id string) (GoAhead, Change, error) { - var out GoAhead - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - now := s.now() - g, ended, err := revokeGoAheadIn(ctx, tx, id, now) - if err != nil { - return err - } - out = g - if !ended { - return nil - } - at := now.UnixMilli() - if err := touch(ctx, tx, g.WorkstreamID, at); err != nil { - return err - } - c = changed(g.WorkstreamID, PartGoAheads) - return nil - }) - if err != nil { - return GoAhead{}, Change{}, err - } - return out, c, nil -} diff --git a/internal/workstream/legacy_test.go b/internal/workstream/legacy_test.go deleted file mode 100644 index ade79780cd..0000000000 --- a/internal/workstream/legacy_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -// Part A (a): domain reads through older schemas are empty, never migrations. -func TestPartALegacyWorkstreamReadsDoNotWrite(t *testing.T) { - for _, version := range []int{1, 2} { - t.Run(fmt.Sprint(version), func(t *testing.T) { - path := filepath.Join(t.TempDir(), "legacy.db") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - created, added := "", "" - if version == 2 { - created = ", created_at INTEGER NOT NULL DEFAULT 0" - added = ", added_at INTEGER NOT NULL DEFAULT 0" - } - _, err = db.Exec(fmt.Sprintf(`CREATE TABLE collections(seq INTEGER PRIMARY KEY,id TEXT UNIQUE,name TEXT%s); -CREATE TABLE memberships(seq INTEGER PRIMARY KEY,collection_id TEXT,kind TEXT,ref_id TEXT,session_id TEXT,target_collection TEXT%s); -PRAGMA application_id=1095123788; PRAGMA user_version=%d; -INSERT INTO collections(seq,id,name) VALUES (1,'collection','Existing');`, created, added, version)) - if err != nil { - t.Fatal(err) - } - if err := db.Close(); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - w, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer w.Close() - s := New(w, Options{}) - ctx := context.Background() - all, err := s.List(ctx) - if err != nil || all == nil || len(all) != 0 { - t.Fatalf("legacy list: %+v %v", all, err) - } - for name, read := range map[string]func() error{ - "get": func() error { _, err := s.Get(ctx, "collection"); return err }, - "plan": func() error { _, err := s.Plan(ctx, "collection"); return err }, - "pages": func() error { _, err := s.Pages(ctx, "collection"); return err }, - "page": func() error { _, err := s.Page(ctx, "missing", true); return err }, - "history": func() error { _, err := s.PageHistory(ctx, "missing", 50); return err }, - "records": func() error { _, err := s.Records(ctx, "collection", "", ""); return err }, - "goaheads": func() error { _, err := s.GoAheads(ctx, "collection", true); return err }, - } { - if err := read(); !errors.Is(err, ErrNotFound) { - t.Fatalf("legacy %s: %v", name, err) - } - } - after, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(before) != string(after) { - t.Fatal("domain read changed legacy file") - } - }) - } -} diff --git a/internal/workstream/limits_test.go b/internal/workstream/limits_test.go deleted file mode 100644 index 05bb676d98..0000000000 --- a/internal/workstream/limits_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" - "testing" - "time" -) - -func TestStabEM4PlanLimitsAreAtomic(t *testing.T) { - for _, name := range []string{"draft text", "milestones", "waves", "items", "encoded JSON"} { - t.Run(name, func(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Limits") - milestones := []Milestone{} - waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d", Title: "Draft"}}}} - switch name { - case "draft text": - waves[0].Items[0].Text = strings.Repeat("x", PlanDraftTextMax) - case "milestones": - for i := range PlanMilestonesMax { - milestones = append(milestones, Milestone{ID: fmt.Sprintf("m%d", i), Title: "Milestone"}) - } - case "waves": - waves = []Wave{} - for i := range PlanWavesMax { - waves = append(waves, Wave{N: i + 1, Items: []PlanItem{}}) - } - case "items": - waves[0].Items = []PlanItem{} - for i := range PlanItemsMax { - waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: fmt.Sprintf("d%d", i), Title: "Draft"}) - } - case "encoded JSON": - waves[0].Items = []PlanItem{} - for i := range 53 { - waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: fmt.Sprintf("d%d", i), Title: "Draft", Text: strings.Repeat("x", PlanDraftTextMax)}) - } - last := &waves[0].Items[52] - last.Text = "x" - m, err := json.Marshal(milestones) - if err != nil { - t.Fatal(err) - } - v, err := json.Marshal(waves) - if err != nil { - t.Fatal(err) - } - last.Text = strings.Repeat("x", 1+PlanEncodedMax-len(m)-len(v)) - if len(last.Text) > PlanDraftTextMax { - t.Fatal("encoded fixture exceeds the draft text bound") - } - } - before, c, err := s.SetPlan(ctx, w.ID, 0, milestones, waves, Attribution{}) - if err != nil || before.Version != 1 || len(c.Parts) == 0 || !reflect.DeepEqual(before.Milestones, milestones) || !reflect.DeepEqual(before.Waves, waves) { - t.Fatalf("at bound: version=%d change=%+v: %v", before.Version, c, err) - } - row, err := s.Get(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - switch name { - case "draft text": - waves[0].Items[0].Text += "x" - case "milestones": - milestones = append(milestones, Milestone{ID: "extra", Title: "Extra"}) - case "waves": - waves = append(waves, Wave{N: PlanWavesMax + 1, Items: []PlanItem{}}) - case "items": - waves[0].Items = append(waves[0].Items, PlanItem{Kind: "draft", DraftID: "extra", Title: "Extra"}) - case "encoded JSON": - waves[0].Items[52].Text += "x" - } - *now = now.Add(time.Minute) - _, c, err = s.SetPlan(ctx, w.ID, before.Version, milestones, waves, Attribution{}) - if !errors.Is(err, ErrLimit) || len(c.Parts) != 0 || !strings.Contains(err.Error(), "at most") { - t.Fatalf("over bound: change=%+v: %v", c, err) - } - after, err := s.Plan(ctx, w.ID) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("refused plan changed: version=%d: %v", after.Version, err) - } - afterRow, err := s.Get(ctx, w.ID) - if err != nil || !reflect.DeepEqual(row, afterRow) { - t.Fatalf("refused plan touched the workstream: %v", err) - } - }) - } -} - -func TestStabEM4DraftControlCharactersRemainInvalid(t *testing.T) { - s, _, _ := testStore(t) - w := createStream(t, s, "Control characters") - for _, text := range []string{"bad\x00text", strings.Repeat("x", PlanDraftTextMax) + "\x00"} { - _, c, err := s.SetPlan(context.Background(), w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d", Title: "Draft", Text: text}}}}, Attribution{}) - if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("control character in %d bytes: change=%+v: %v", len(text), c, err) - } - } -} - -func TestStabEM4RestoreChecksSavedDocumentSize(t *testing.T) { - s, db, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Restore") - pages, err := s.Pages(ctx, w.ID) - if err != nil || len(pages) != 1 { - t.Fatalf("Overview: %v", err) - } - before, err := s.Page(ctx, pages[0].ID, false) - if err != nil { - t.Fatal(err) - } - if err := db.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `UPDATE workstream_page_versions SET doc=? WHERE page_id=? AND version=1`, `{"text":"`+strings.Repeat("x", DocMax)+`"}`, before.ID) - return err - }); err != nil { - t.Fatal(err) - } - _, c, err := s.RestorePage(ctx, before.ID, 1, before.Version, Attribution{}) - if !errors.Is(err, ErrTooLarge) || len(c.Parts) != 0 { - t.Fatalf("restore oversized saved document: change=%+v: %v", c, err) - } - after, err := s.Page(ctx, before.ID, false) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("refused restore changed the page: %v", err) - } -} diff --git a/internal/workstream/pagedoc/frommarkdown.go b/internal/workstream/pagedoc/frommarkdown.go deleted file mode 100644 index acb836c9d5..0000000000 --- a/internal/workstream/pagedoc/frommarkdown.go +++ /dev/null @@ -1,359 +0,0 @@ -package pagedoc - -import ( - "encoding/json" - "regexp" - "strconv" - "strings" -) - -var workstreamMarkdownHeading = regexp.MustCompile(`^(#+) +(.*)$`) - -var workstreamMarkdownList = regexp.MustCompile(`^( *)([-*+] |([0-9]+)[.)] )(.*)$`) - -var workstreamMarkdownLive = regexp.MustCompile(`^\[live: (jobs|waves)( milestone=([^\s\]]+))?\]$`) - -var workstreamMarkdownJob = regexp.MustCompile(`^\[live: job ([^\s#]*)#([0-9]+)\]$`) - -var workstreamMarkdownDecision = regexp.MustCompile(`^\[live: decision ([^\s\]]+)\]$`) - -// FromMarkdown accepts prose without requiring the agent to construct editor JSON. -func FromMarkdown(md string) json.RawMessage { - md = strings.ToValidUTF8(md, "�") - md = strings.ReplaceAll(strings.ReplaceAll(md, "\r\n", "\n"), "\r", "\n") - doc := map[string]any{"type": "doc", "content": workstreamMarkdownBlocks(strings.Split(md, "\n"), 0)} - out, _ := json.Marshal(doc) - return out -} - -func workstreamMarkdownNode(kind string, content []map[string]any) map[string]any { - return map[string]any{"type": kind, "content": content} -} - -func workstreamMarkdownBlocks(lines []string, depth int) []map[string]any { - out := make([]map[string]any, 0) - for i := 0; i < len(lines); { - line := strings.TrimSpace(lines[i]) - if line == "" || line == "---" { - i++ - continue - } - if fence, language := workstreamMarkdownFence(line); fence != "" { - node, next := workstreamMarkdownCode(lines, i, fence, language) - out = append(out, node) - i = next - continue - } - if match := workstreamMarkdownHeading.FindStringSubmatch(line); match != nil { - node := workstreamMarkdownNode("heading", workstreamMarkdownInline(match[2])) - node["attrs"] = map[string]any{"level": min(3, len(match[1]))} - out = append(out, node) - i++ - continue - } - if node := workstreamMarkdownLiveNode(line); node != nil { - out = append(out, node) - i++ - continue - } - if depth < 16 && workstreamMarkdownList.MatchString(lines[i]) { - node, next := workstreamMarkdownListNode(lines, i, depth) - out = append(out, node) - i = next - continue - } - parts := []string{workstreamMarkdownProse(line)} - i++ - for i < len(lines) && !workstreamMarkdownBoundary(lines[i], depth) { - parts = append(parts, workstreamMarkdownProse(lines[i])) - i++ - } - text := strings.Join(parts, " ") - out = append(out, workstreamMarkdownNode("paragraph", workstreamMarkdownInline(text))) - } - return out -} - -func workstreamMarkdownProse(line string) string { - return strings.TrimPrefix(strings.TrimSpace(line), "> ") -} - -func workstreamMarkdownBoundary(line string, depth int) bool { - line = strings.TrimSpace(line) - fence, _ := workstreamMarkdownFence(line) - return line == "" || line == "---" || fence != "" || workstreamMarkdownHeading.MatchString(line) || workstreamMarkdownLiveNode(line) != nil || depth < 16 && workstreamMarkdownList.MatchString(line) -} - -func workstreamMarkdownFence(line string) (string, string) { - if !strings.HasPrefix(line, "```") && !strings.HasPrefix(line, "~~~") { - return "", "" - } - i := 0 - for i < len(line) && line[i] == line[0] { - i++ - } - return line[:i], strings.TrimSpace(line[i:]) -} - -func workstreamMarkdownCode(lines []string, at int, fence, language string) (map[string]any, int) { - i := at + 1 - for i < len(lines) { - line := strings.TrimSpace(lines[i]) - if strings.HasPrefix(line, fence) && strings.Trim(line, string(fence[0])) == "" { - break - } - i++ - } - text := strings.Join(lines[at+1:i], "\n") - node := workstreamMarkdownNode("codeBlock", []map[string]any{{"type": "text", "text": text}}) - var lang any - if language != "" { - lang = language - } - node["attrs"] = map[string]any{"language": lang} - return node, min(i+1, len(lines)) -} - -func workstreamMarkdownListNode(lines []string, at, depth int) (map[string]any, int) { - first := workstreamMarkdownList.FindStringSubmatch(lines[at]) - indent, ordered := len(first[1]), first[3] != "" - items := make([]map[string]any, 0) - i := at - for i < len(lines) { - match := workstreamMarkdownList.FindStringSubmatch(lines[i]) - if match == nil || len(match[1]) != indent || (match[3] != "") != ordered { - break - } - body, next := workstreamMarkdownListBody(lines, i, indent, match) - items = append(items, workstreamMarkdownListItem(body, depth+1)) - i = next - } - kind := "bulletList" - if ordered { - kind = "orderedList" - } - node := workstreamMarkdownNode(kind, items) - if ordered { - start, err := strconv.Atoi(first[3]) - if err != nil { - start = 1 - } - node["attrs"] = map[string]any{"start": start} - } - return node, i -} - -func workstreamMarkdownListBody(lines []string, at, indent int, match []string) ([]string, int) { - width := len(match[1]) + len(match[2]) - body := []string{match[4]} - i := at + 1 - for i < len(lines) { - line := lines[i] - if strings.TrimSpace(line) == "" { - if i+1 >= len(lines) || len(lines[i+1])-len(strings.TrimLeft(lines[i+1], " ")) <= indent { - break - } - } else if len(line)-len(strings.TrimLeft(line, " ")) <= indent { - break - } - cut := min(width, len(line)-len(strings.TrimLeft(line, " "))) - body = append(body, line[cut:]) - i++ - } - return body, i -} - -func workstreamMarkdownListItem(lines []string, depth int) map[string]any { - content := workstreamMarkdownBlocks(lines, depth) - if len(content) == 0 || content[0]["type"] != "paragraph" { - content = append([]map[string]any{workstreamMarkdownNode("paragraph", []map[string]any{})}, content...) - } - return workstreamMarkdownNode("listItem", content) -} - -func workstreamMarkdownLiveNode(line string) map[string]any { - attrs := map[string]any{"kind": nil, "milestone_id": nil, "factory": nil, "job": nil, "record_id": nil} - switch { - case workstreamMarkdownLive.MatchString(line): - match := workstreamMarkdownLive.FindStringSubmatch(line) - attrs["kind"] = match[1] - if match[3] != "" { - attrs["milestone_id"] = match[3] - } - case workstreamMarkdownJob.MatchString(line): - match := workstreamMarkdownJob.FindStringSubmatch(line) - n, err := strconv.Atoi(match[2]) - if err != nil { - return nil - } - attrs["kind"], attrs["factory"], attrs["job"] = "job", match[1], n - case workstreamMarkdownDecision.MatchString(line): - match := workstreamMarkdownDecision.FindStringSubmatch(line) - attrs["kind"], attrs["record_id"] = "decision", match[1] - default: - return nil - } - return map[string]any{"type": "liveBlock", "attrs": attrs} -} - -func workstreamMarkdownInline(text string) []map[string]any { - index := workstreamMarkdownInlineIndex{ - code: workstreamMarkdownCodeRuns(text), - parens: workstreamMarkdownParens(text), - linkClose: strings.Index(text, "]("), - } - out := make([]map[string]any, 0) - var plain strings.Builder - flush := func() { - if plain.Len() > 0 { - out = append(out, map[string]any{"type": "text", "text": plain.String()}) - plain.Reset() - } - } - for i := 0; i < len(text); { - if text[i] == '\\' && i+1 < len(text) { - plain.WriteByte(text[i+1]) - i += 2 - continue - } - if strings.HasPrefix(text[i:], "**") || strings.HasPrefix(text[i:], "__") { - i += 2 - continue - } - nodes, used := index.special(text, i) - if used > 0 { - if nodes == nil { - plain.WriteString(text[i : i+used]) - } else { - flush() - out = append(out, nodes...) - } - i += used - continue - } - plain.WriteByte(text[i]) - i++ - } - flush() - return out -} - -type workstreamMarkdownRun struct { - end int - close int -} - -type workstreamMarkdownInlineIndex struct { - code map[int]workstreamMarkdownRun - parens map[int]int - linkClose int -} - -// Indexing runs once lets an unmatched opener consume its entire run as text. -func workstreamMarkdownCodeRuns(text string) map[int]workstreamMarkdownRun { - out := make(map[int]workstreamMarkdownRun) - next := make(map[int]int) - for i := len(text) - 1; i >= 0; { - if text[i] != '`' { - i-- - continue - } - end := i + 1 - for i >= 0 && text[i] == '`' { - i-- - } - start := i + 1 - out[start] = workstreamMarkdownRun{end: end, close: workstreamMarkdownNextRun(next, end-start)} - if end-start > 1 { - // An escaped first backtick leaves the suffix available as an opener. - out[start+1] = workstreamMarkdownRun{end: end, close: workstreamMarkdownNextRun(next, end-start-1)} - } - next[end-start] = start - } - return out -} - -func workstreamMarkdownNextRun(next map[int]int, width int) int { - if at, ok := next[width]; ok { - return at - } - return -1 -} - -// Balanced destinations are indexed once, including failures, for every link. -func workstreamMarkdownParens(text string) map[int]int { - out := make(map[int]int) - var stack []int - for i := 0; i < len(text); i++ { - if text[i] == '\\' && i+1 < len(text) { - i++ - continue - } - switch text[i] { - case '(': - stack = append(stack, i) - case ')': - if len(stack) > 0 { - last := len(stack) - 1 - out[stack[last]] = i - stack = stack[:last] - } - } - } - return out -} - -func (index *workstreamMarkdownInlineIndex) special(text string, at int) ([]map[string]any, int) { - if text[at] == '`' { - run := index.code[at] - if run.close < 0 { - return nil, run.end - at - } - body := text[run.end:run.close] - if strings.HasPrefix(body, " ") && strings.HasSuffix(body, " ") && strings.TrimSpace(body) != "" { - body = body[1 : len(body)-1] - } - width := run.end - at - return []map[string]any{{"type": "text", "text": body, "marks": []map[string]any{{"type": "code"}}}}, run.close + width - at - } - if text[at] == '[' { - return index.link(text, at) - } - return nil, 0 -} - -func (index *workstreamMarkdownInlineIndex) link(text string, at int) ([]map[string]any, int) { - if index.linkClose >= 0 && index.linkClose < at { - index.linkClose = strings.Index(text[at:], "](") - if index.linkClose >= 0 { - index.linkClose += at - } - } - close := index.linkClose - if close < 0 { - return nil, 0 - } - end, ok := index.parens[close+1] - if !ok { - return nil, 0 - } - href := workstreamMarkdownUnescape(text[close+2 : end]) - // The first ]( bounds this label, so parsing it cannot recurse into links. - nodes := workstreamMarkdownInline(text[at+1 : close]) - for _, node := range nodes { - marks, _ := node["marks"].([]map[string]any) - node["marks"] = append(marks, map[string]any{"type": "link", "attrs": map[string]any{"href": href}}) - } - return nodes, end - at + 1 -} - -func workstreamMarkdownUnescape(text string) string { - var out strings.Builder - for i := 0; i < len(text); i++ { - if text[i] == '\\' && i+1 < len(text) { - i++ - } - out.WriteByte(text[i]) - } - return out.String() -} diff --git a/internal/workstream/pagedoc/frommarkdown_test.go b/internal/workstream/pagedoc/frommarkdown_test.go deleted file mode 100644 index 49a46a2466..0000000000 --- a/internal/workstream/pagedoc/frommarkdown_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package pagedoc - -import ( - "encoding/json" - "os" - "reflect" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/guard" -) - -func TestWorkstreamFromMarkdownContractRoundTrip(t *testing.T) { - md, err := os.ReadFile("testdata/example.md") - if err != nil { - t.Fatal(err) - } - workstreamMarkdownRoundTrip(t, string(md), string(md)) - for _, md := range []string{ - "# One\n\n## Two\n\n### Three\n", - "- Parent\n - Child\n- Next\n", - "3. Parent\n 4. Child\n4. Next\n", - "Keep \\*literal\\* and `a_b` and [a `code`](https://example.com).\n", - "````go\na := \"```\"\n````\n", - "[live: jobs]\n\n[live: waves milestone=m2]\n\n[live: job api#0]\n\n[live: decision r2]\n", - } { - workstreamMarkdownRoundTrip(t, md, md) - } -} - -func TestWorkstreamFromMarkdownDegradesUnsupportedMarkup(t *testing.T) { - workstreamMarkdownRoundTrip(t, "See [guide](https://example.com/a(b)) and [more](https://example.com/a\\(b\\)).\n", "See [guide](https://example.com/a%28b%29) and [more](https://example.com/a%28b%29).\n") - workstreamMarkdownRoundTrip(t, "#### Deep\n\n> **Plain** __words__\ncontinued\n\n---\n\n+ First\n* Second\n\n7) Ordered\n\n~~~js\ncall()\n~~~\n", "### Deep\n\nPlain words continued\n\n- First\n- Second\n\n7. Ordered\n\n```js\ncall()\n```\n") - for _, md := range []string{"", "\n", "```\n", "[unfinished", "`unfinished", "text\\", string([]byte{255})} { - if !json.Valid(FromMarkdown(md)) { - t.Fatalf("invalid doc for %q", md) - } - } -} - -func TestWorkstreamFromMarkdownLiveAttributes(t *testing.T) { - var doc map[string]any - if err := json.Unmarshal(FromMarkdown("[live: jobs milestone=m1]"), &doc); err != nil { - t.Fatal(err) - } - node := doc["content"].([]any)[0].(map[string]any) - want := map[string]any{"kind": "jobs", "milestone_id": "m1", "factory": nil, "job": nil, "record_id": nil} - if node["type"] != "liveBlock" || !reflect.DeepEqual(node["attrs"], want) { - t.Fatalf("live block: %+v", node) - } -} - -func workstreamMarkdownRoundTrip(t *testing.T, input, want string) { - t.Helper() - got, err := Markdown(FromMarkdown(input)) - if err != nil || got != want { - t.Fatalf("round trip %q:\ngot %q\nwant %q\nerror %v", input, got, want, err) - } -} - -func TestWorkstreamFromMarkdownListItemsStartWithParagraphs(t *testing.T) { - cases := []struct { - input string - want string - }{ - {"- \n", "-\n"}, - {"- # Heading\n", "- # Heading\n"}, - {"- [live: jobs]\n", "- [live: jobs]\n"}, - {"- ```go\n call()\n ```\n", "- ```go\n call()\n ```\n"}, - {"- - Nested\n", "- - Nested\n"}, - {"- Parent\n - # Nested heading\n - \n", "- Parent\n - # Nested heading\n -\n"}, - } - for _, tc := range cases { - var doc map[string]any - if err := json.Unmarshal(FromMarkdown(tc.input), &doc); err != nil { - t.Fatal(err) - } - if count := workstreamCheckListParagraphs(t, doc); count == 0 { - t.Fatalf("no list items in %q", tc.input) - } - workstreamMarkdownRoundTrip(t, tc.input, tc.want) - } -} - -func workstreamCheckListParagraphs(t *testing.T, node map[string]any) int { - t.Helper() - count := 0 - kids := children(node) - if node["type"] == "listItem" { - count++ - if len(kids) == 0 || kids[0]["type"] != "paragraph" { - t.Fatalf("list item must begin with a paragraph: %+v", node) - } - } - for _, child := range kids { - count += workstreamCheckListParagraphs(t, child) - } - return count -} - -func TestWorkstreamFromMarkdownUnmatchedDelimitersAreBounded(t *testing.T) { - cases := []struct { - name string - input string - want string - }{ - {"backticks", "Notes " + strings.Repeat("`", 200000), "Notes " + strings.Repeat("\\`", 200000) + "\n"}, - {"brackets", strings.Repeat("[", 200000), strings.Repeat("\\[", 200000) + "\n"}, - {"unfinished link", strings.Repeat("[", 200000) + "](unfinished", strings.Repeat("\\[", 200000) + "\\](unfinished\n"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - finished := make(chan json.RawMessage, 1) - guard.Go("workstream/markdown-delimiters-test", func() { finished <- FromMarkdown(tc.input) }) - select { - case doc := <-finished: - if !json.Valid(doc) { - t.Fatal("conversion returned invalid editor JSON") - } - got, err := Markdown(doc) - if err != nil || got != tc.want { - t.Fatalf("conversion lost delimiter text: got %d bytes, want %d; %v", len(got), len(tc.want), err) - } - case <-time.After(5 * time.Second): - t.Fatal("malformed inline input exceeded the conversion bound") - } - }) - } -} - -func TestWorkstreamFromMarkdownCodeRequiresMatchingRunLengths(t *testing.T) { - workstreamMarkdownRoundTrip(t, "Notes ``a ` b`` and `c`.\n", "Notes ``a ` b`` and `c`.\n") - workstreamMarkdownRoundTrip(t, "Notes ``unclosed ` tail.\n", "Notes \\`\\`unclosed \\` tail.\n") - workstreamMarkdownRoundTrip(t, "Notes \\``code`.\n", "Notes \\``code`.\n") -} diff --git a/internal/workstream/pagedoc/pagedoc.go b/internal/workstream/pagedoc/pagedoc.go deleted file mode 100644 index 21c0c5591d..0000000000 --- a/internal/workstream/pagedoc/pagedoc.go +++ /dev/null @@ -1,395 +0,0 @@ -// Package pagedoc renders workstream page editor JSON documents to markdown. -package pagedoc - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "math" - "strconv" - "strings" - "unicode/utf8" -) - -// Markdown renders a workstream page's editor JSON document into deterministic -// markdown matching the desktop app's reference renderer. An absent document -// (nil, empty or whitespace only) returns "", nil, the same as JSON null. -// Invalid JSON returns an error prefixed with "pagedoc: ". -// -// Two untestable boundary cases differ from the JavaScript reference because -// of runtime semantics: Go's JSON decoder replaces lone UTF-16 surrogate -// escapes with U+FFFD, and documents nested deeply enough to overflow the -// JavaScript call stack (where the reference catches the range error and -// returns "") render normally in Go up to encoding/json's 10000-level nesting -// limit, beyond which json.Valid reports an invalid-JSON error. -func Markdown(doc json.RawMessage) (out string, err error) { - // The export boundary must not take the engine down on a document nobody - // foresaw; the reference renderer likewise returns rather than throws. - defer func() { - if r := recover(); r != nil { - out = "" - err = fmt.Errorf("pagedoc: recovered panic: %v", r) - } - }() - - if len(bytes.TrimSpace(doc)) == 0 { - return "", nil - } - if !json.Valid(doc) { - return "", errors.New("pagedoc: invalid JSON") - } - dec := json.NewDecoder(bytes.NewReader(doc)) - dec.UseNumber() - var root any - if err := dec.Decode(&root); err != nil { - return "", fmt.Errorf("pagedoc: invalid JSON: %w", err) - } - rendered := block(object(root)) - if rendered == "" { - return "", nil - } - return strings.TrimRight(rendered, "\n") + "\n", nil -} - -func object(v any) map[string]any { - if m, ok := v.(map[string]any); ok { - return m - } - return nil -} - -func children(node map[string]any) []map[string]any { - raw, ok := node["content"].([]any) - if !ok { - return nil - } - out := make([]map[string]any, len(raw)) - for i, elem := range raw { - out[i] = object(elem) - } - return out -} - -func marks(node map[string]any) []map[string]any { - raw, ok := node["marks"].([]any) - if !ok { - return nil - } - out := make([]map[string]any, len(raw)) - for i, elem := range raw { - out[i] = object(elem) - } - return out -} - -func str(v any) string { - if s, ok := v.(string); ok { - return s - } - return "" -} - -func number(v any) (float64, bool) { - n, ok := v.(json.Number) - if !ok { - return 0, false - } - f, err := strconv.ParseFloat(string(n), 64) - if err != nil && !errors.Is(err, strconv.ErrRange) { - return 0, false - } - return f, true -} - -func intNumber(v any) (float64, bool) { - f, ok := number(v) - if !ok || math.IsInf(f, 0) || math.IsNaN(f) || math.Trunc(f) != f { - return 0, false - } - return f, true -} - -func jsNumber(x float64) string { - if x == 0 { - return "0" - } - if math.Abs(x) < 1e21 { - return strconv.FormatFloat(x, 'f', -1, 64) - } - return strconv.FormatFloat(x, 'e', -1, 64) -} - -func escape(s string) string { - var b strings.Builder - for i := 0; i < len(s); i++ { - switch s[i] { - case '\\', '`', '*', '_', '[', ']', '<', '>': - b.WriteByte('\\') - } - b.WriteByte(s[i]) - } - return b.String() -} - -func longestBackticks(s string) int { - longest, run := 0, 0 - for i := 0; i < len(s); i++ { - if s[i] == '`' { - run++ - if run > longest { - longest = run - } - } else { - run = 0 - } - } - return longest -} - -func inlineLine(s string) string { - return strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(s) -} - -func trimInline(s string) string { - return strings.Trim(s, " \t") -} - -func descendantText(node map[string]any) string { - var b strings.Builder - b.WriteString(str(node["text"])) - for _, child := range children(node) { - b.WriteString(descendantText(child)) - } - return b.String() -} - -func inlineText(node map[string]any) string { - t := inlineLine(str(node["text"])) - if t == "" { - return "" - } - hasCode := false - for _, m := range marks(node) { - if str(m["type"]) == "code" { - hasCode = true - break - } - } - if !hasCode { - return escape(t) - } - delim := strings.Repeat("`", longestBackticks(t)+1) - pad := "" - if strings.HasPrefix(t, "`") || strings.HasSuffix(t, "`") { - pad = " " - } - return delim + pad + t + pad + delim -} - -func linkHref(node map[string]any) string { - if str(node["type"]) != "text" { - return "" - } - for _, m := range marks(node) { - if str(m["type"]) == "link" { - return str(object(m["attrs"])["href"]) - } - } - return "" -} - -func encodeHref(href string) string { - return strings.NewReplacer( - " ", "%20", - "(", "%28", - ")", "%29", - "<", "%3C", - ">", "%3E", - ).Replace(href) -} - -func inline(nodes []map[string]any) string { - var b strings.Builder - for i := 0; i < len(nodes); { - n := nodes[i] - href := linkHref(n) - text := inlineText(n) - i++ - if href != "" { - for i < len(nodes) && linkHref(nodes[i]) == href { - text += inlineText(nodes[i]) - i++ - } - if text == "" { - continue - } - b.WriteByte('[') - b.WriteString(text) - b.WriteString("](") - b.WriteString(encodeHref(href)) - b.WriteByte(')') - } else { - b.WriteString(text) - } - } - return b.String() -} - -func paragraph(s string) string { - s = trimInline(s) - var b strings.Builder - lineStart := true - for i := 0; i < len(s); { - if lineStart { - switch s[i] { - case '#', '-', '+', '>': - b.WriteByte('\\') - b.WriteByte(s[i]) - i++ - lineStart = false - continue - } - if s[i] >= '0' && s[i] <= '9' { - j := i + 1 - for j < len(s) && s[j] >= '0' && s[j] <= '9' { - j++ - } - b.WriteString(s[i:j]) - if j < len(s) && (s[j] == '.' || s[j] == ')') { - b.WriteByte('\\') - b.WriteByte(s[j]) - j++ - } - i = j - lineStart = false - continue - } - } - r, w := utf8.DecodeRuneInString(s[i:]) - b.WriteString(s[i : i+w]) - i += w - lineStart = r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' - } - return b.String() -} - -func liveBlock(attrs map[string]any) string { - kind := str(attrs["kind"]) - if kind == "" { - kind = "unknown" - } - switch kind { - case "jobs", "waves": - if m := str(attrs["milestone_id"]); m != "" { - return "[live: " + kind + " milestone=" + m + "]" - } - return "[live: " + kind + "]" - case "job": - if job, ok := intNumber(attrs["job"]); ok && job >= 0 { - return "[live: job " + str(attrs["factory"]) + "#" + jsNumber(job) + "]" - } - return "[live: job]" - case "decision": - if r := str(attrs["record_id"]); r != "" { - return "[live: decision " + r + "]" - } - return "[live: decision]" - default: - return "[live: " + kind + "]" - } -} - -func listItem(node map[string]any, prefix string) string { - var body strings.Builder - for _, child := range children(node) { - r := block(child) - if r == "" { - continue - } - if body.Len() > 0 { - t := str(child["type"]) - if t == "bulletList" || t == "orderedList" { - body.WriteByte('\n') - } else { - body.WriteString("\n\n") - } - } - body.WriteString(r) - } - if body.Len() == 0 { - return strings.TrimRight(prefix, " ") - } - lines := strings.Split(body.String(), "\n") - indent := strings.Repeat(" ", len(prefix)) - for i, line := range lines { - if i == 0 { - lines[i] = prefix + line - } else if line != "" { - lines[i] = indent + line - } - } - return strings.Join(lines, "\n") -} - -func block(node map[string]any) string { - attrs := object(node["attrs"]) - switch str(node["type"]) { - case "doc": - var parts []string - for _, child := range children(node) { - if r := block(child); r != "" { - parts = append(parts, r) - } - } - return strings.Join(parts, "\n\n") - case "paragraph": - return paragraph(inline(children(node))) - case "heading": - t := trimInline(inline(children(node))) - if t == "" { - return "" - } - level := 1 - if f, ok := number(attrs["level"]); ok && !math.IsInf(f, 0) && !math.IsNaN(f) { - tr := math.Trunc(f) - if tr < 1 { - level = 1 - } else if tr > 3 { - level = 3 - } else { - level = int(tr) - } - } - return strings.Repeat("#", level) + " " + t - case "bulletList", "orderedList": - start := 1.0 - if s, ok := intNumber(attrs["start"]); ok { - start = s - } - kids := children(node) - items := make([]string, len(kids)) - isBullet := str(node["type"]) == "bulletList" - for i, item := range kids { - prefix := "- " - if !isBullet { - prefix = jsNumber(start+float64(i)) + ". " - } - items[i] = listItem(item, prefix) - } - return strings.Join(items, "\n") - case "listItem": - return listItem(node, "") - case "codeBlock": - t := descendantText(node) - fence := strings.Repeat("`", max(3, longestBackticks(t)+1)) - if t != "" { - return fence + str(attrs["language"]) + "\n" + t + "\n" + fence - } - return fence + str(attrs["language"]) + "\n" + fence - case "liveBlock": - return liveBlock(attrs) - default: - return paragraph(escape(inlineLine(descendantText(node)))) - } -} diff --git a/internal/workstream/pagedoc/pagedoc_test.go b/internal/workstream/pagedoc/pagedoc_test.go deleted file mode 100644 index e2ce9fa7ee..0000000000 --- a/internal/workstream/pagedoc/pagedoc_test.go +++ /dev/null @@ -1,414 +0,0 @@ -package pagedoc - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -type testCase struct { - name, input, want string -} - -func doc(blocks ...string) string { - return `{"type":"doc","content":[` + strings.Join(blocks, ",") + `]}` -} - -func runCases(t *testing.T, cases []testCase) { - t.Helper() - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := Markdown(json.RawMessage(tc.input)) - if err != nil { - t.Fatalf("%s: unexpected error: %v", tc.name, err) - } - if got != tc.want { - t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) - } - }) - } -} - -// Ported from the desktop app's markdown tests: "paragraphs, heading levels 1–3 and clamping, empty blocks and exactly one terminal newline". -// Covers validation contract (a). -func TestMarkdownParagraphsHeadingsEmptyBlocksAndTerminalNewline(t *testing.T) { - runCases(t, []testCase{ - {"paragraphs and empty paragraph", doc(`{"type":"paragraph","content":[{"type":"text","text":"First"}]}`, `{"type":"paragraph","content":[]}`, `{"type":"paragraph","content":[{"type":"text","text":"Second "}]}`), "First\n\nSecond\n"}, - {"heading level 1", doc(`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, - {"heading level 2", doc(`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]}`), "## Title\n"}, - {"heading level 3", doc(`{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Title"}]}`), "### Title\n"}, - {"heading level 0 clamped to 1", doc(`{"type":"heading","attrs":{"level":0},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, - {"heading level -2 clamped to 1", doc(`{"type":"heading","attrs":{"level":-2},"content":[{"type":"text","text":"Title"}]}`), "# Title\n"}, - {"heading level 8 clamped to 3", doc(`{"type":"heading","attrs":{"level":8},"content":[{"type":"text","text":"Title"}]}`), "### Title\n"}, - {"heading default level", doc(`{"type":"heading","content":[{"type":"text","text":"Default"}]}`), "# Default\n"}, - {"empty heading and paragraph", doc(`{"type":"heading","content":[]}`, `{"type":"paragraph","content":[]}`), ""}, - {"null document", `null`, ""}, - {"doc without content", `{"type":"doc"}`, ""}, - {"doc with empty content", doc(), ""}, - }) -} - -// Ported from the desktop app's markdown tests: "paragraph and heading newlines become spaces, including inline code, and outer spaces and tabs are trimmed". -// Covers validation contract (a). -func TestMarkdownInlineNewlinesAndOuterWhitespace(t *testing.T) { - runCases(t, []testCase{ - {"paragraph crlf in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\r\nright"}]}`), "Left right\n"}, - {"paragraph crlf in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\r\nright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, - {"paragraph crlf alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\r\n"}]}`), ""}, - {"paragraph crlf and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\r\n "}]}`), ""}, - {"paragraph cr in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\rright"}]}`), "Left right\n"}, - {"paragraph cr in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\rright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, - {"paragraph cr alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\r"}]}`), ""}, - {"paragraph cr and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\r "}]}`), ""}, - {"paragraph lf in text", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\nright"}]}`), "Left right\n"}, - {"paragraph lf in code", doc(`{"type":"paragraph","content":[{"type":"text","text":"Left\nright","marks":[{"type":"code"}]}]}`), "`Left right`\n"}, - {"paragraph lf alone", doc(`{"type":"paragraph","content":[{"type":"text","text":"\n"}]}`), ""}, - {"paragraph lf and whitespace", doc(`{"type":"paragraph","content":[{"type":"text","text":" \t\n "}]}`), ""}, - {"paragraph outer spaces and tabs trimmed", doc(`{"type":"paragraph","content":[{"type":"text","text":" Indented \t"}]}`), "Indented\n"}, - {"paragraph spaces and tabs only", doc(`{"type":"paragraph","content":[{"type":"text","text":"\t "}]}`), ""}, - {"heading crlf in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\r\nright"}]}`), "# Left right\n"}, - {"heading crlf in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\r\nright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, - {"heading crlf alone", doc(`{"type":"heading","content":[{"type":"text","text":"\r\n"}]}`), ""}, - {"heading crlf and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\r\n "}]}`), ""}, - {"heading cr in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\rright"}]}`), "# Left right\n"}, - {"heading cr in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\rright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, - {"heading cr alone", doc(`{"type":"heading","content":[{"type":"text","text":"\r"}]}`), ""}, - {"heading cr and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\r "}]}`), ""}, - {"heading lf in text", doc(`{"type":"heading","content":[{"type":"text","text":"Left\nright"}]}`), "# Left right\n"}, - {"heading lf in code", doc(`{"type":"heading","content":[{"type":"text","text":"Left\nright","marks":[{"type":"code"}]}]}`), "# `Left right`\n"}, - {"heading lf alone", doc(`{"type":"heading","content":[{"type":"text","text":"\n"}]}`), ""}, - {"heading lf and whitespace", doc(`{"type":"heading","content":[{"type":"text","text":" \t\n "}]}`), ""}, - {"heading outer spaces and tabs trimmed", doc(`{"type":"heading","content":[{"type":"text","text":" Indented \t"}]}`), "# Indented\n"}, - {"heading spaces and tabs only", doc(`{"type":"heading","content":[{"type":"text","text":"\t "}]}`), ""}, - {"whitespace-only blocks omitted between paragraphs", doc(`{"type":"paragraph","content":[{"type":"text","text":"A"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"\n"}]}`, `{"type":"heading","content":[{"type":"text","text":"\n"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"B"}]}`), "A\n\nB\n"}, - {"empty paragraph inside list item omitted", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"\n"}]},{"type":"paragraph","content":[{"type":"text","text":" Kept "}]}]}]}`), "- Kept\n"}, - {"code block newlines and spaces verbatim", doc(`{"type":"codeBlock","content":[{"type":"text","text":" Left\r\nright\rnext\nlast "}]}`), "```\n Left\r\nright\rnext\nlast \n```\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "bullet and ordered lists, custom/default starts, nested and multi-block items, bare empty items". -// Covers validation contract (a). -func TestMarkdownBulletAndOrderedLists(t *testing.T) { - runCases(t, []testCase{ - {"bullet list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"One"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Two"}]}]}]}`), "- One\n- Two\n"}, - {"ordered list with custom start and multi-paragraph items", doc(`{"type":"orderedList","attrs":{"start":9,"ignored":true},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nine"}]},{"type":"paragraph","content":[{"type":"text","text":"Again"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Ten"}]},{"type":"paragraph","content":[{"type":"text","text":"Last"}]}]}]}`), "9. Nine\n\n Again\n10. Ten\n\n Last\n"}, - {"ordered list with default start and empty item", doc(`{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Default"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[]}]}]}`), "1. Default\n2.\n"}, - {"nested lists with empty paragraphs and bare empty item", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[]},{"type":"paragraph","content":[{"type":"text","text":"Parent"}]},{"type":"orderedList","attrs":{"start":3},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Child"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Leaf"}]}]}]}]}]},{"type":"paragraph","content":[]},{"type":"paragraph","content":[{"type":"text","text":"After"}]}]},{"type":"listItem","content":[]}]}`), "- Parent\n 3. Child\n - Leaf\n\n After\n-\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "code blocks preserve text, language, empty content and safe backtick fences". -// Covers validation contract (a). -func TestMarkdownCodeBlocks(t *testing.T) { - runCases(t, []testCase{ - {"code block with language", doc(`{"type":"codeBlock","attrs":{"language":"ts"},"content":[{"type":"text","text":"const x = 1"}]}`), "```ts\nconst x = 1\n```\n"}, - {"code block with triple backticks in text and null language", doc(`{"type":"codeBlock","attrs":{"language":null},"content":[{"type":"text","text":"before ` + "```" + ` after\n"}]}`), "````\nbefore ``` after\n\n````\n"}, - {"empty code block", doc(`{"type":"codeBlock"}`), "```\n```\n"}, - {"code block preserves outer spaces", doc(`{"type":"codeBlock","content":[{"type":"text","text":" x "}]}`), "```\n x \n```\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "inline code delimiters and padding handle embedded and edge backticks without escaping". -// Covers validation contract (a). -func TestMarkdownInlineCode(t *testing.T) { - runCases(t, []testCase{ - {"inline code without escaping", doc(`{"type":"paragraph","content":[{"type":"text","text":"x * y","marks":[{"type":"code"}]}]}`), "`x * y`\n"}, - {"inline code with embedded backtick", doc(`{"type":"paragraph","content":[{"type":"text","text":"a` + "`" + `b","marks":[{"type":"code"}]}]}`), "``a`b``\n"}, - {"inline code with edge backticks", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "`x`" + `","marks":[{"type":"code"}]}]}`), "`` `x` ``\n"}, - {"inline code with leading double backticks", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "``x" + `","marks":[{"type":"code"}]}]}`), "``` ``x ```\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "adjacent same-href text forms one link, code stays inside, destinations encode delimiters, absent href is plain text". -// Covers validation contract (a). -func TestMarkdownLinksAndDestinations(t *testing.T) { - runCases(t, []testCase{ - {"adjacent same-href nodes with inline code and encoded destination", doc(`{"type":"paragraph","content":[{"type":"text","text":"Read ","marks":[{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]},{"type":"text","text":"x","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]},{"type":"text","text":" now","marks":[{"type":"link","attrs":{"href":"https://example.com/a b(c)"}}]}]}`), "[Read `x` now](https://example.com/a%20b%28c%29%3Cd%3E)\n"}, - {"adjacent different-href links stay separate", doc(`{"type":"paragraph","content":[{"type":"text","text":"one","marks":[{"type":"link","attrs":{"href":"https://a.test"}}]},{"type":"text","text":"two","marks":[{"type":"link","attrs":{"href":"https://b.test"}}]}]}`), "[one](https://a.test)[two](https://b.test)\n"}, - {"empty and missing href render plain escaped text", doc(`{"type":"paragraph","content":[{"type":"text","text":"empty*","marks":[{"type":"link","attrs":{"href":""}}]},{"type":"text","text":"missing","marks":[{"type":"link","attrs":{}}]}]}`), "empty\\*missing\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "empty text and empty link runs render nothing while non-empty same-href runs remain linked". -// Covers validation contract (a). -func TestMarkdownEmptyTextAndLinkRuns(t *testing.T) { - runCases(t, []testCase{ - {"paragraph empty text no marks", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[]}]}`), ""}, - {"paragraph empty text code mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"code"}]}]}`), ""}, - {"paragraph empty text link mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"paragraph empty text code and link marks", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"paragraph multiple empty link nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"heading empty text no marks", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[]}]}`), ""}, - {"heading empty text code mark", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"code"}]}]}`), ""}, - {"heading empty text link mark", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"heading empty text code and link marks", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"heading multiple empty link nodes", doc(`{"type":"heading","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"code"},{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), ""}, - {"empty link between plain text nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"Before"},{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":" after"}]}`), "Before after\n"}, - {"non-empty link surrounded by empty same-href nodes", doc(`{"type":"paragraph","content":[{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"Guide","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}`), "[Guide](https://example.com)\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "every live block kind with and without attributes, job zero and an empty factory, unknown kinds". -// Covers validation contract (a). -func TestMarkdownLiveBlocks(t *testing.T) { - runCases(t, []testCase{ - {"all four kinds without attributes", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"decision","milestone_id":null,"factory":null,"job":null,"record_id":null}}`), "[live: jobs]\n\n[live: waves]\n\n[live: job]\n\n[live: decision]\n"}, - {"all four kinds with attributes", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":"m1","factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":"m2","factory":null,"job":null,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":"api","job":1107,"record_id":null}}`, `{"type":"liveBlock","attrs":{"kind":"decision","milestone_id":null,"factory":null,"job":null,"record_id":"r1"}}`), "[live: jobs milestone=m1]\n\n[live: waves milestone=m2]\n\n[live: job api#1107]\n\n[live: decision r1]\n"}, - {"job zero without factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":null,"job":0,"record_id":null}}`), "[live: job #0]\n"}, - {"factory without job number", doc(`{"type":"liveBlock","attrs":{"kind":"job","milestone_id":null,"factory":"api","job":null,"record_id":null}}`), "[live: job]\n"}, - {"unknown kind and missing attrs", doc(`{"type":"liveBlock","attrs":{"kind":"canvas"}}`, `{"type":"liveBlock"}`), "[live: canvas]\n\n[live: unknown]\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "live references require non-empty strings and job numbers require non-negative integers". -// Covers validation contract (a). -func TestMarkdownLiveBlockAttributeValidation(t *testing.T) { - // JSON cannot carry NaN, so the NaN row from the TypeScript loop is omitted; - // 1e400 and -1e400 cover positive and negative infinity after JSON parsing. - runCases(t, []testCase{ - {"jobs milestone empty string", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":""}}`), "[live: jobs]\n"}, - {"waves milestone empty string", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":""}}`), "[live: waves]\n"}, - {"decision record empty string", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":""}}`), "[live: decision]\n"}, - {"job factory empty string", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"","job":7}}`), "[live: job #7]\n"}, - {"jobs milestone number", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":42}}`), "[live: jobs]\n"}, - {"waves milestone number", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":42}}`), "[live: waves]\n"}, - {"decision record number", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":42}}`), "[live: decision]\n"}, - {"job factory number", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":42,"job":7}}`), "[live: job #7]\n"}, - {"jobs milestone false", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":false}}`), "[live: jobs]\n"}, - {"waves milestone false", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":false}}`), "[live: waves]\n"}, - {"decision record false", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":false}}`), "[live: decision]\n"}, - {"job factory false", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":false,"job":7}}`), "[live: job #7]\n"}, - {"jobs milestone object", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":{}}}`), "[live: jobs]\n"}, - {"waves milestone object", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":{}}}`), "[live: waves]\n"}, - {"decision record object", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":{}}}`), "[live: decision]\n"}, - {"job factory object", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":{},"job":7}}`), "[live: job #7]\n"}, - {"jobs milestone null", doc(`{"type":"liveBlock","attrs":{"kind":"jobs","milestone_id":null}}`), "[live: jobs]\n"}, - {"waves milestone null", doc(`{"type":"liveBlock","attrs":{"kind":"waves","milestone_id":null}}`), "[live: waves]\n"}, - {"decision record null", doc(`{"type":"liveBlock","attrs":{"kind":"decision","record_id":null}}`), "[live: decision]\n"}, - {"job factory null", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":null,"job":7}}`), "[live: job #7]\n"}, - {"job string", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":"7"}}`), "[live: job]\n"}, - {"job fractional", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1.5}}`), "[live: job]\n"}, - {"job negative", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-1}}`), "[live: job]\n"}, - {"job positive infinity", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1e400}}`), "[live: job]\n"}, - {"job negative infinity", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-1e400}}`), "[live: job]\n"}, - {"job boolean", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":false}}`), "[live: job]\n"}, - {"job null", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":null}}`), "[live: job]\n"}, - {"job without factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","job":7}}`), "[live: job #7]\n"}, - {"job zero with factory", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":0}}`), "[live: job api#0]\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "every escaped character and paragraph line-start case, also inside lists". -// Covers validation contract (a). -func TestMarkdownEscapingAndParagraphLineStarts(t *testing.T) { - runCases(t, []testCase{ - {"escape backslash", doc(`{"type":"paragraph","content":[{"type":"text","text":"\\"}]}`), "\\\\\n"}, - {"escape backtick", doc(`{"type":"paragraph","content":[{"type":"text","text":"` + "`" + `"}]}`), "\\`\n"}, - {"escape asterisk", doc(`{"type":"paragraph","content":[{"type":"text","text":"*"}]}`), "\\*\n"}, - {"escape underscore", doc(`{"type":"paragraph","content":[{"type":"text","text":"_"}]}`), "\\_\n"}, - {"escape open bracket", doc(`{"type":"paragraph","content":[{"type":"text","text":"["}]}`), "\\[\n"}, - {"escape close bracket", doc(`{"type":"paragraph","content":[{"type":"text","text":"]"}]}`), "\\]\n"}, - {"escape less than", doc(`{"type":"paragraph","content":[{"type":"text","text":"<"}]}`), "\\<\n"}, - {"escape greater than", doc(`{"type":"paragraph","content":[{"type":"text","text":">"}]}`), "\\>\n"}, - {"line start hash", doc(`{"type":"paragraph","content":[{"type":"text","text":"# title"}]}`), "\\# title\n"}, - {"line start hash after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" # title\t "}]}`), "\\# title\n"}, - {"line start hash in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"# title"}]}]}]}`), "- \\# title\n"}, - {"line start minus", doc(`{"type":"paragraph","content":[{"type":"text","text":"- bullet"}]}`), "\\- bullet\n"}, - {"line start minus after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" - bullet\t "}]}`), "\\- bullet\n"}, - {"line start minus in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"- bullet"}]}]}]}`), "- \\- bullet\n"}, - {"line start plus", doc(`{"type":"paragraph","content":[{"type":"text","text":"+ bullet"}]}`), "\\+ bullet\n"}, - {"line start plus after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" + bullet\t "}]}`), "\\+ bullet\n"}, - {"line start plus in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"+ bullet"}]}]}]}`), "- \\+ bullet\n"}, - {"line start quote", doc(`{"type":"paragraph","content":[{"type":"text","text":"> quote"}]}`), "\\> quote\n"}, - {"line start quote after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" > quote\t "}]}`), "\\> quote\n"}, - {"line start quote in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"> quote"}]}]}]}`), "- \\> quote\n"}, - {"line start dot ordered", doc(`{"type":"paragraph","content":[{"type":"text","text":"12. list"}]}`), "12\\. list\n"}, - {"line start dot ordered after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" 12. list\t "}]}`), "12\\. list\n"}, - {"line start dot ordered in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"12. list"}]}]}]}`), "- 12\\. list\n"}, - {"line start paren ordered", doc(`{"type":"paragraph","content":[{"type":"text","text":"42) list"}]}`), "42\\) list\n"}, - {"line start paren ordered after trim", doc(`{"type":"paragraph","content":[{"type":"text","text":" 42) list\t "}]}`), "42\\) list\n"}, - {"line start paren ordered in list", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"42) list"}]}]}]}`), "- 42\\) list\n"}, - {"mid-paragraph markers after normalized newlines", doc(`{"type":"paragraph","content":[{"type":"text","text":"one \n# two\n3. three "}]}`), "one # two 3. three\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "unknown blocks flatten descendant text, unknown inline nodes render their own text, unknown marks and malformed JSON never throw". -// Covers validation contract (a) and (d). -func TestMarkdownUnknownNodesMarksAndMalformedShapes(t *testing.T) { - runCases(t, []testCase{ - {"unknown block flattens descendant text", doc(`{"type":"future","content":[{"type":"paragraph","content":[{"type":"text","text":"a*"}]},{"type":"other","content":[{"type":"text","text":"b_"}]}]}`), "a\\*b\\_\n"}, - {"unknown inline node renders own text and unknown mark is ignored", doc(`{"type":"paragraph","content":[{"type":"futureInline","text":"","content":[{"type":"text","text":"ignored"}]},{"type":"text","text":" ok","marks":[{"type":"futureMark"}]}]}`), "\\ ok\n"}, - {"empty unknown block omitted", doc(`{"type":"future"}`), ""}, - {"top-level null", `null`, ""}, - {"top-level false", `false`, ""}, - {"top-level number", `7`, ""}, - {"top-level string", `"wrong"`, ""}, - {"top-level array", `[]`, ""}, - {"top-level empty object", `{}`, ""}, - {"malformed content elements and non-string text", `{"type":"doc","content":[null,3,{},{"type":"paragraph","content":[null,{"type":"text","text":7,"marks":[null]}]}]}`, ""}, - {"codeBlock with null attrs and non-array content", `{"type":"codeBlock","attrs":null,"content":"bad"}`, "```\n```\n"}, - }) -} - -// Ported from the desktop app's markdown tests: "whole-document fixture is the wire contract's exact JSON and markdown example". -// Covers validation contract (a) and (b). -func TestMarkdownRendersTheContractExample(t *testing.T) { - rawJSON, err := os.ReadFile(filepath.Join("testdata", "example.json")) - if err != nil { - t.Fatalf("read example.json: %v", err) - } - rawMD, err := os.ReadFile(filepath.Join("testdata", "example.md")) - if err != nil { - t.Fatalf("read example.md: %v", err) - } - example := map[string]any{"type": "doc", "content": []any{ - map[string]any{"type": "heading", "attrs": map[string]any{"level": float64(1)}, "content": []any{map[string]any{"type": "text", "text": "Spec"}}}, - map[string]any{"type": "paragraph", "content": []any{ - map[string]any{"type": "text", "text": "Keep "}, - map[string]any{"type": "text", "text": "event_id", "marks": []any{map[string]any{"type": "code"}}}, - map[string]any{"type": "text", "text": " and "}, - map[string]any{"type": "text", "text": "guide", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://example.com/spec"}}}}, - map[string]any{"type": "text", "text": "."}, - }}, - map[string]any{"type": "bulletList", "content": []any{ - map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Retry safely"}}}}}, - map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Record outcomes"}}}}}, - }}, - map[string]any{"type": "orderedList", "attrs": map[string]any{"start": float64(3)}, "content": []any{ - map[string]any{"type": "listItem", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Ship"}}}}}, - }}, - map[string]any{"type": "codeBlock", "attrs": map[string]any{"language": "ts"}, "content": []any{map[string]any{"type": "text", "text": "send(event)"}}}, - map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "jobs", "milestone_id": "m1", "factory": nil, "job": nil, "record_id": nil}}, - map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "waves", "milestone_id": nil, "factory": nil, "job": nil, "record_id": nil}}, - map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "job", "milestone_id": nil, "factory": "api", "job": float64(1107), "record_id": nil}}, - map[string]any{"type": "liveBlock", "attrs": map[string]any{"kind": "decision", "milestone_id": nil, "factory": nil, "job": nil, "record_id": "r1"}}, - }} - var decoded any - if err := json.Unmarshal(rawJSON, &decoded); err != nil { - t.Fatalf("unmarshal example.json: %v", err) - } - if !reflect.DeepEqual(decoded, example) { - t.Errorf("example.json differs from the reference example") - } - const expected = "# Spec\n\nKeep `event_id` and [guide](https://example.com/spec).\n\n- Retry safely\n- Record outcomes\n\n3. Ship\n\n```ts\nsend(event)\n```\n\n[live: jobs milestone=m1]\n\n[live: waves]\n\n[live: job api#1107]\n\n[live: decision r1]\n" - if string(rawMD) != expected { - t.Errorf("example.md: got %q, want %q", string(rawMD), expected) - } - got, err := Markdown(json.RawMessage(rawJSON)) - if err != nil { - t.Fatalf("Markdown(example.json): unexpected error: %v", err) - } - if got != string(rawMD) { - t.Errorf("Markdown(example.json): got %q, want %q", got, string(rawMD)) - } -} - -// Covers validation contract (c). -func TestMarkdownRefusesInvalidJSON(t *testing.T) { - cases := []struct { - name, input string - }{ - {"unclosed brace", `{`}, - {"trailing comma", `{"type":"doc",}`}, - {"bare word", `nope`}, - {"trailing token", `{"type":"doc"} x`}, - {"unclosed array", `[1,2`}, - {"unclosed content array", `{"type":"doc","content":[`}, - {"nesting past encoding/json limit", strings.Repeat("[", 10001) + strings.Repeat("]", 10001)}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := Markdown(json.RawMessage(tc.input)) - if err == nil { - t.Fatalf("%s: expected error, got nil (output %q)", tc.name, got) - } - if !strings.HasPrefix(err.Error(), "pagedoc: ") { - t.Errorf("%s: error %q does not start with %q", tc.name, err.Error(), "pagedoc: ") - } - if got != "" { - t.Errorf("%s: got %q, want %q", tc.name, got, "") - } - }) - } -} - -// Covers validation contract (c). -func TestMarkdownTreatsAnAbsentDocumentAsEmpty(t *testing.T) { - cases := []struct { - name string - input json.RawMessage - }{ - {"nil", nil}, - {"empty", json.RawMessage("")}, - {"whitespace only", json.RawMessage(" \n")}, - {"json null", json.RawMessage("null")}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := Markdown(tc.input) - if err != nil { - t.Fatalf("%s: unexpected error: %v", tc.name, err) - } - if got != "" { - t.Errorf("%s: got %q, want %q", tc.name, got, "") - } - }) - } -} - -// Covers validation contract (c). -func TestMarkdownRendersADeepDocumentWithoutPanicking(t *testing.T) { - deep := strings.Repeat(`{"type":"future","content":[`, 4000) + `{"type":"text","text":"x"}` + strings.Repeat(`]}`, 4000) - got, err := Markdown(json.RawMessage(deep)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "x\n" { - t.Errorf("deep document: got %q, want %q", got, "x\n") - } -} - -// Covers validation contract (d) and (e). -func TestMarkdownMatchesTheReferenceOnEdgeCases(t *testing.T) { - runCases(t, []testCase{ - {"1 line separator u2028 triggers line-start escape", doc(`{"type":"paragraph","content":[{"type":"text","text":"a\u2028# b"}]}`), "a\u2028\\# b\n"}, - {"2 newline inside link destination triggers line-start escape", doc(`{"type":"paragraph","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"a\n- b"}}]}]}`), "[x](a\n\\-%20b)\n"}, - {"3 bulletList with direct paragraph child", doc(`{"type":"bulletList","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}`), "- a\n"}, - {"4 bulletList with null child", doc(`{"type":"bulletList","content":[null]}`), "-\n"}, - {"5 orderedList with null children", doc(`{"type":"orderedList","content":[null,null]}`), "1.\n2.\n"}, - {"6 unknown inline node with code mark and embedded backtick", doc(`{"type":"paragraph","content":[{"type":"mention","text":"a` + "`" + `b","marks":[{"type":"code"}]}]}`), "``a`b``\n"}, - {"7 unknown inline node with link mark breaks link run", doc(`{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]},{"type":"x","text":"b","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]},{"type":"text","text":"c","marks":[{"type":"link","attrs":{"href":"https://e.x"}}]}]}`), "[a](https://e.x)b[c](https://e.x)\n"}, - {"8 first link mark with empty href hides later link mark", doc(`{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"link","attrs":{"href":""}},{"type":"link","attrs":{"href":"https://x"}}]}]}`), "a\n"}, - {"9 text node at block level", doc(`{"type":"text","text":"*a*"}`), "\\*a\\*\n"}, - {"10 nested doc inside doc", doc(doc(`{"type":"paragraph","content":[{"type":"text","text":"a"}]}`, `{"type":"paragraph","content":[{"type":"text","text":"b"}]}`)), "a\n\nb\n"}, - {"11 bare paragraph root without doc", `{"type":"paragraph","content":[{"type":"text","text":"x"}]}`, "x\n"}, - {"12 lf followed by cr becomes two spaces", doc(`{"type":"paragraph","content":[{"type":"text","text":"a\n\rb"}]}`), "a b\n"}, - {"13 non-breaking spaces preserved at paragraph edges", doc(`{"type":"paragraph","content":[{"type":"text","text":"\u00a0x\u00a0"}]}`), "\u00a0x\u00a0\n"}, - {"14 heading text starting with hash is not line-start escaped", doc(`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"# x"}]}`), "# # x\n"}, - {"15 codeBlock collects nested descendant text", doc(`{"type":"codeBlock","content":[{"type":"text","text":"a"},{"type":"x","text":"b","content":[{"type":"text","text":"c"}]}]}`), "```\nabc\n```\n"}, - {"16 codeBlock ignores non-string language", doc(`{"type":"codeBlock","attrs":{"language":5},"content":[{"type":"text","text":"x"}]}`), "```\nx\n```\n"}, - {"17a liveBlock empty kind", doc(`{"type":"liveBlock","attrs":{"kind":""}}`), "[live: unknown]\n"}, - {"17b liveBlock null attrs", doc(`{"type":"liveBlock","attrs":null}`), "[live: unknown]\n"}, - {"17c liveBlock array attrs", doc(`{"type":"liveBlock","attrs":[]}`), "[live: unknown]\n"}, - {"18 bulletList codeBlock with crlf indents cr line", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"codeBlock","content":[{"type":"text","text":"a\r\nb"}]}]}]}`), "- ```\n a\r\n b\n ```\n"}, - {"19 bulletList codeBlock with blank line keeps blank line empty", doc(`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"codeBlock","content":[{"type":"text","text":"a\n\nb"}]}]}]}`), "- ```\n a\n\n b\n ```\n"}, - {"20 orderedList start 1e21 uses exponential format", doc(`{"type":"orderedList","attrs":{"start":1e21},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1e+21. x\n"}, - {"21 orderedList start 1e20 uses fixed format", doc(`{"type":"orderedList","attrs":{"start":1e20},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "100000000000000000000. x\n"}, - {"22 orderedList start -0 renders as 0", doc(`{"type":"orderedList","attrs":{"start":-0},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}`), "0. a\n1. b\n"}, - {"23 orderedList start -2 counts up through negative integers", doc(`{"type":"orderedList","attrs":{"start":-2},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}`), "-2. a\n-1. b\n"}, - {"24a orderedList fractional start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":2.5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, - {"24b orderedList string start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":"3"},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, - {"24c orderedList infinite start falls back to 1", doc(`{"type":"orderedList","attrs":{"start":1e400},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "1. x\n"}, - {"25 orderedList start 3.0 is accepted as integer", doc(`{"type":"orderedList","attrs":{"start":3.0},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}`), "3. x\n"}, - {"26 liveBlock job -0 renders as 0", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":-0}}`), "[live: job api#0]\n"}, - {"27 liveBlock job 1e21 uses exponential format", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":1e21}}`), "[live: job api#1e+21]\n"}, - {"28 liveBlock job 3.0 renders as 3", doc(`{"type":"liveBlock","attrs":{"kind":"job","factory":"api","job":3.0}}`), "[live: job api#3]\n"}, - {"29 heading level 2.9 truncates to 2", doc(`{"type":"heading","attrs":{"level":2.9},"content":[{"type":"text","text":"T"}]}`), "## T\n"}, - {"30a heading string level falls back to 1", doc(`{"type":"heading","attrs":{"level":"2"},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, - {"30b heading infinite level falls back to 1", doc(`{"type":"heading","attrs":{"level":1e400},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, - {"30c heading level -0.5 clamps to 1", doc(`{"type":"heading","attrs":{"level":-0.5},"content":[{"type":"text","text":"T"}]}`), "# T\n"}, - }) -} diff --git a/internal/workstream/pagedoc/testdata/example.json b/internal/workstream/pagedoc/testdata/example.json deleted file mode 100644 index a8cfa438e9..0000000000 --- a/internal/workstream/pagedoc/testdata/example.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "type": "doc", - "content": [ - { "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Spec" }] }, - { "type": "paragraph", "content": [ - { "type": "text", "text": "Keep " }, - { "type": "text", "text": "event_id", "marks": [{ "type": "code" }] }, - { "type": "text", "text": " and " }, - { "type": "text", "text": "guide", "marks": [{ "type": "link", "attrs": { "href": "https://example.com/spec" } }] }, - { "type": "text", "text": "." } - ] }, - { "type": "bulletList", "content": [ - { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Retry safely" }] }] }, - { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Record outcomes" }] }] } - ] }, - { "type": "orderedList", "attrs": { "start": 3 }, "content": [ - { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Ship" }] }] } - ] }, - { "type": "codeBlock", "attrs": { "language": "ts" }, "content": [{ "type": "text", "text": "send(event)" }] }, - { "type": "liveBlock", "attrs": { "kind": "jobs", "milestone_id": "m1", "factory": null, "job": null, "record_id": null } }, - { "type": "liveBlock", "attrs": { "kind": "waves", "milestone_id": null, "factory": null, "job": null, "record_id": null } }, - { "type": "liveBlock", "attrs": { "kind": "job", "milestone_id": null, "factory": "api", "job": 1107, "record_id": null } }, - { "type": "liveBlock", "attrs": { "kind": "decision", "milestone_id": null, "factory": null, "job": null, "record_id": "r1" } } - ] -} diff --git a/internal/workstream/pagedoc/testdata/example.md b/internal/workstream/pagedoc/testdata/example.md deleted file mode 100644 index 36608d30f2..0000000000 --- a/internal/workstream/pagedoc/testdata/example.md +++ /dev/null @@ -1,20 +0,0 @@ -# Spec - -Keep `event_id` and [guide](https://example.com/spec). - -- Retry safely -- Record outcomes - -3. Ship - -```ts -send(event) -``` - -[live: jobs milestone=m1] - -[live: waves] - -[live: job api#1107] - -[live: decision r1] diff --git a/internal/workstream/pagedoc/testdata/suggestion-keep.json b/internal/workstream/pagedoc/testdata/suggestion-keep.json deleted file mode 100644 index 08a391447f..0000000000 --- a/internal/workstream/pagedoc/testdata/suggestion-keep.json +++ /dev/null @@ -1,306 +0,0 @@ -{ - "_about": "WP-6d shared fixture: identical bytes in the engine and the desk. doc_before + every edit kept in document order = doc_after; keeping only e3 = doc_after_e3. Offsets count Unicode code points. markdown_* are the exact renderer outputs.", - "doc_before": { - "type": "doc", - "content": [ - { - "type": "heading", - "attrs": { - "level": 2 - }, - "content": [ - { - "type": "text", - "text": "Signing" - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Every request carries " - }, - { - "type": "text", - "text": "X-Signature", - "marks": [ - { - "type": "code" - } - ] - }, - { - "type": "text", - "text": ", an HMAC 🔏 of the café body. Retries reuse the first signature." - } - ] - }, - { - "type": "bulletList", - "content": [ - { - "type": "listItem", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Partners verify within 5 minutes of the " - }, - { - "type": "text", - "text": "timestamp", - "marks": [ - { - "type": "link", - "attrs": { - "href": "https://example.com/ts" - } - } - ] - }, - { - "type": "text", - "text": "." - } - ] - } - ] - } - ] - }, - { - "type": "codeBlock", - "attrs": { - "language": "go" - }, - "content": [ - { - "type": "text", - "text": "sign(body)\nsend()" - } - ] - } - ] - }, - "edits": [ - { - "id": "e1", - "find": "Signing", - "replace": "Signing and retries", - "why": "Covers retries too", - "block": [ - 0 - ], - "offset": 7, - "delete": "", - "insert": " and retries", - "block_text": "Signing" - }, - { - "id": "e2", - "find": "Retries reuse the first signature.", - "replace": "Retries are signed again at send time.", - "why": "Retries are signed at send time since api#1107", - "block": [ - 1 - ], - "offset": 71, - "delete": "reuse the first signature.", - "insert": "are signed again at send time.", - "block_text": "Every request carries X-Signature, an HMAC 🔏 of the café body. Retries reuse the first signature." - }, - { - "id": "e3", - "find": "within 5 minutes", - "replace": "within 10 minutes", - "why": "The backoff cap is now 10 minutes", - "block": [ - 2, - 0, - 0 - ], - "offset": 23, - "delete": "5", - "insert": "10", - "block_text": "Partners verify within 5 minutes of the timestamp." - } - ], - "doc_after": { - "type": "doc", - "content": [ - { - "type": "heading", - "attrs": { - "level": 2 - }, - "content": [ - { - "type": "text", - "text": "Signing and retries" - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Every request carries " - }, - { - "type": "text", - "text": "X-Signature", - "marks": [ - { - "type": "code" - } - ] - }, - { - "type": "text", - "text": ", an HMAC 🔏 of the café body. Retries are signed again at send time." - } - ] - }, - { - "type": "bulletList", - "content": [ - { - "type": "listItem", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Partners verify within 10 minutes of the " - }, - { - "type": "text", - "text": "timestamp", - "marks": [ - { - "type": "link", - "attrs": { - "href": "https://example.com/ts" - } - } - ] - }, - { - "type": "text", - "text": "." - } - ] - } - ] - } - ] - }, - { - "type": "codeBlock", - "attrs": { - "language": "go" - }, - "content": [ - { - "type": "text", - "text": "sign(body)\nsend()" - } - ] - } - ] - }, - "doc_after_e3": { - "type": "doc", - "content": [ - { - "type": "heading", - "attrs": { - "level": 2 - }, - "content": [ - { - "type": "text", - "text": "Signing" - } - ] - }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Every request carries " - }, - { - "type": "text", - "text": "X-Signature", - "marks": [ - { - "type": "code" - } - ] - }, - { - "type": "text", - "text": ", an HMAC 🔏 of the café body. Retries reuse the first signature." - } - ] - }, - { - "type": "bulletList", - "content": [ - { - "type": "listItem", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Partners verify within 10 minutes of the " - }, - { - "type": "text", - "text": "timestamp", - "marks": [ - { - "type": "link", - "attrs": { - "href": "https://example.com/ts" - } - } - ] - }, - { - "type": "text", - "text": "." - } - ] - } - ] - } - ] - }, - { - "type": "codeBlock", - "attrs": { - "language": "go" - }, - "content": [ - { - "type": "text", - "text": "sign(body)\nsend()" - } - ] - } - ] - }, - "markdown_before": "## Signing\n\nEvery request carries `X-Signature`, an HMAC 🔏 of the café body. Retries reuse the first signature.\n\n- Partners verify within 5 minutes of the [timestamp](https://example.com/ts).\n\n```go\nsign(body)\nsend()\n```\n", - "markdown_after": "## Signing and retries\n\nEvery request carries `X-Signature`, an HMAC 🔏 of the café body. Retries are signed again at send time.\n\n- Partners verify within 10 minutes of the [timestamp](https://example.com/ts).\n\n```go\nsign(body)\nsend()\n```\n" -} diff --git a/internal/workstream/pagedoc/textblocks.go b/internal/workstream/pagedoc/textblocks.go deleted file mode 100644 index 20034a62cb..0000000000 --- a/internal/workstream/pagedoc/textblocks.go +++ /dev/null @@ -1,275 +0,0 @@ -package pagedoc - -import ( - "bytes" - "encoding/json" - "errors" - "maps" - "reflect" - "slices" - "strings" - "unicode" - "unicode/utf8" -) - -// TextBlock retains the editor node so a keep changes only its text content. -type TextBlock struct { - Path []int - Kind string - Text string - Node map[string]any -} - -// Decode preserves exact JSON numbers, including unknown editor attributes. -func Decode(raw json.RawMessage) (map[string]any, error) { - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - var root map[string]any - err := decoder.Decode(&root) - if err == nil && root == nil { - err = errors.New("pagedoc: expected a document object") - } - return root, err -} - -func TextBlocks(doc map[string]any) []TextBlock { - blocks := make([]TextBlock, 0) - var visit func(map[string]any, []int) - visit = func(node map[string]any, path []int) { - kind, _ := node["type"].(string) - if kind == "paragraph" || kind == "heading" || kind == "codeBlock" { - blocks = append(blocks, TextBlock{Path: slices.Clone(path), Kind: kind, Text: nodeText(node), Node: node}) - } - content, _ := node["content"].([]any) - for i, child := range content { - visit(object(child), append(slices.Clone(path), i)) - } - } - visit(doc, []int{}) - return blocks -} - -func nodeText(node map[string]any) string { - if node["type"] == "text" { - text, _ := node["text"].(string) - return text - } - var text strings.Builder - for _, child := range children(node) { - text.WriteString(nodeText(child)) - } - return text.String() -} - -// Locate tries the stored path before the unique whole-block text fallback. -func Locate(doc map[string]any, path []int, text string) (TextBlock, bool) { - blocks := TextBlocks(doc) - for _, block := range blocks { - if slices.Equal(block.Path, path) && block.Text == text { - return block, true - } - } - var match TextBlock - count := 0 - for _, block := range blocks { - if block.Text == text { - match = block - count++ - } - } - return match, count == 1 -} - -// TrimEdit trims only at whitespace word boundaries in both strings. -func TrimEdit(find, replace string) (offset int, del, ins string) { - a, b := []rune(find), []rune(replace) - p := 0 - for p < len(a) && p < len(b) && a[p] == b[p] { - p++ - } - for !wordBoundary(a, p) || !wordBoundary(b, p) { - p-- - } - s := 0 - for s < len(a)-p && s < len(b)-p && a[len(a)-1-s] == b[len(b)-1-s] { - s++ - } - for !wordBoundary(a, len(a)-s) || !wordBoundary(b, len(b)-s) { - s-- - } - return p, string(a[p : len(a)-s]), string(b[p : len(b)-s]) -} - -func wordBoundary(s []rune, p int) bool { - return p == 0 || p == len(s) || unicode.IsSpace(s[p-1]) || unicode.IsSpace(s[p]) -} - -// Apply rewrites a located block. Offsets and deletion lengths are code points. -func Apply(block TextBlock, offset int, del, ins string) error { - text, removed := []rune(block.Text), []rune(del) - if offset < 0 || offset+len(removed) > len(text) || string(text[offset:offset+len(removed)]) != del { - return errors.New("pagedoc: edit text does not match") - } - if block.Kind != "codeBlock" && strings.ContainsAny(ins, "\r\n") { - return errors.New("pagedoc: line break outside code block") - } - var inherited []any - if len(removed) > 0 { - at := offset - inherited, _ = characterMarks(block.Node, &at) - } else if offset > 0 && offset < len(text) { - leftAt, rightAt := offset-1, offset - left, _ := characterMarks(block.Node, &leftAt) - right, _ := characterMarks(block.Node, &rightAt) - inherited = commonMarks(left, right) - } - edit := textRewrite{start: offset, end: offset + len(removed), insert: ins, marks: inherited} - edit.content(block.Node) - if !edit.inserted && ins != "" { - content, _ := block.Node["content"].([]any) - block.Node["content"] = mergeText(append(content, edit.insertion())) - } - return nil -} - -// characterMarks stops at the requested character without allocating per rune. -func characterMarks(node map[string]any, at *int) ([]any, bool) { - if node["type"] == "text" { - text, _ := node["text"].(string) - length := utf8.RuneCountInString(text) - if *at < length { - marks, _ := node["marks"].([]any) - return marks, true - } - *at -= length - return nil, false - } - for _, child := range children(node) { - if marks, found := characterMarks(child, at); found { - return marks, true - } - } - return nil, false -} - -func commonMarks(a, b []any) []any { - var out []any - for _, mark := range a { - if slices.ContainsFunc(b, func(other any) bool { return reflect.DeepEqual(mark, other) }) { - out = append(out, mark) - } - } - return out -} - -type textRewrite struct { - start, end, position int - insert string - marks []any - inserted bool -} - -func (e *textRewrite) insertion() map[string]any { - node := map[string]any{"type": "text", "text": e.insert} - if len(e.marks) > 0 { - node["marks"] = e.marks - } - return node -} - -func (e *textRewrite) content(node map[string]any) { - content, exists := node["content"].([]any) - if !exists { - return - } - out := make([]any, 0, len(content)) - for _, raw := range content { - child := object(raw) - if child["type"] == "text" { - out = append(out, e.leaf(child)...) - } else { - e.content(child) - out = append(out, raw) - } - } - node["content"] = mergeText(out) -} - -func (e *textRewrite) leaf(node map[string]any) []any { - text, _ := node["text"].(string) - runes := []rune(text) - start, end := e.position, e.position+len(runes) - e.position = end - if end < e.start || start > e.end { - return []any{node} - } - out := []any{} - appendText := func(text string) { - if text != "" { - part := maps.Clone(node) - part["text"] = text - out = append(out, part) - } - } - left := min(len(runes), max(0, e.start-start)) - appendText(string(runes[:left])) - if !e.inserted && e.start >= start && e.start <= end { - e.inserted = true - if e.insert != "" { - out = append(out, e.insertion()) - } - } - right := min(len(runes), max(0, e.end-start)) - appendText(string(runes[right:])) - return out -} - -func mergeText(content []any) []any { - out := make([]any, 0, len(content)) - for _, raw := range content { - node := object(raw) - if node["type"] != "text" { - out = append(out, raw) - continue - } - text, _ := node["text"].(string) - if text == "" { - continue - } - if len(out) > 0 { - previous := object(out[len(out)-1]) - if previous["type"] == "text" && sameTextFields(previous, node) { - previous["text"] = previous["text"].(string) + text - continue - } - } - out = append(out, raw) - } - return out -} - -func sameTextFields(a, b map[string]any) bool { - aMarks, _ := a["marks"].([]any) - bMarks, _ := b["marks"].([]any) - if !slices.EqualFunc(aMarks, bMarks, reflect.DeepEqual) { - return false - } - for key, value := range a { - if key == "text" || key == "marks" { - continue - } - other, exists := b[key] - if !exists || !reflect.DeepEqual(value, other) { - return false - } - } - for key := range b { - if key == "text" || key == "marks" { - continue - } - if _, exists := a[key]; !exists { - return false - } - } - return true -} diff --git a/internal/workstream/pagedoc/textblocks_test.go b/internal/workstream/pagedoc/textblocks_test.go deleted file mode 100644 index c7350cb435..0000000000 --- a/internal/workstream/pagedoc/textblocks_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package pagedoc - -import ( - "encoding/json" - "os" - "reflect" - "slices" - "testing" -) - -// WP-6d (g): the shared fixture fixes trimming, code-point offsets, JSON and Markdown. -func TestSuggestionKeepSharedFixture(t *testing.T) { - raw, err := os.ReadFile("testdata/suggestion-keep.json") - if err != nil { - t.Fatal(err) - } - var fixture struct { - Before json.RawMessage `json:"doc_before"` - After json.RawMessage `json:"doc_after"` - OnlyE3 json.RawMessage `json:"doc_after_e3"` - MarkdownBefore string `json:"markdown_before"` - MarkdownAfter string `json:"markdown_after"` - Edits []struct { - ID, Find, Replace, Delete, Insert string - Block []int - Offset int - BlockText string `json:"block_text"` - } `json:"edits"` - } - if err := json.Unmarshal(raw, &fixture); err != nil { - t.Fatal(err) - } - root, err := Decode(fixture.Before) - if err != nil { - t.Fatal(err) - } - for _, edit := range fixture.Edits { - block, ok := Locate(root, edit.Block, edit.BlockText) - if !ok || !slices.Equal(block.Path, edit.Block) { - t.Fatalf("%s block: %+v", edit.ID, block) - } - prefix, del, ins := TrimEdit(edit.Find, edit.Replace) - // Locate the quoted text in code points, independently of byte indexes. - text, find := []rune(block.Text), []rune(edit.Find) - index := -1 - for i := 0; i+len(find) <= len(text); i++ { - if slices.Equal(text[i:i+len(find)], find) { - index = i - break - } - } - if index < 0 || index+prefix != edit.Offset || del != edit.Delete || ins != edit.Insert { - t.Fatalf("%s trim: offset=%d delete=%q insert=%q", edit.ID, index+prefix, del, ins) - } - if err := Apply(block, edit.Offset, del, ins); err != nil { - t.Fatal(err) - } - } - want, err := Decode(fixture.After) - if err != nil || !reflect.DeepEqual(root, want) { - t.Fatalf("all edits: %+v; %v", root, err) - } - root, err = Decode(fixture.Before) - if err != nil { - t.Fatal(err) - } - e3 := fixture.Edits[2] - block, ok := Locate(root, e3.Block, e3.BlockText) - if !ok { - t.Fatal("e3 missing") - } - if err := Apply(block, e3.Offset, e3.Delete, e3.Insert); err != nil { - t.Fatal(err) - } - want, err = Decode(fixture.OnlyE3) - if err != nil || !reflect.DeepEqual(root, want) { - t.Fatal("e3 changed other text", err) - } - for _, test := range []struct { - doc json.RawMessage - markdown string - }{{fixture.Before, fixture.MarkdownBefore}, {fixture.After, fixture.MarkdownAfter}} { - got, err := Markdown(test.doc) - if err != nil || got != test.markdown { - t.Fatalf("Markdown: %q; %v", got, err) - } - } -} - -// WP-6d (g), Tool: trim stops at word boundaries, including pure insertion. -func TestSuggestionWordBoundaryTrim(t *testing.T) { - for _, test := range []struct { - find, replace string - at int - del, ins string - }{ - {"within 5 minutes", "within 10 minutes", 7, "5", "10"}, - {"Retries reuse the first signature.", "Retries are signed again at send time.", 8, "reuse the first signature.", "are signed again at send time."}, - {"Signing", "Signing and retries", 7, "", " and retries"}, - {"foobar", "foobaz", 0, "foobar", "foobaz"}, - {"α β γ", "α δ γ", 2, "β", "δ"}, - } { - at, del, ins := TrimEdit(test.find, test.replace) - if at != test.at || del != test.del || ins != test.ins { - t.Errorf("%q: %d %q %q", test.find, at, del, ins) - } - } -} - -// WP-6d (g): replacement inherits first-deleted marks, insertion intersects -// both sides, ends inherit none, empty nodes vanish and exact numbers survive. -func TestSuggestionKeepMarksAndUnknownAttributes(t *testing.T) { - for _, test := range []struct { - name string - at int - del, ins, content string - }{ - {"replacement", 1, "bc", "X", `[{"type":"text","text":"aX","marks":[{"type":"code"}]},{"type":"text","text":"d","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, - {"intersection", 2, "", "X", `[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"X"},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, - {"end", 4, "", "X", `[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},{"type":"text","text":"X"}]`}, - {"start", 0, "", "X", `[{"type":"text","text":"X"},{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, - {"deletion", 0, "abcd", "", `[]`}, - {"same marks", 1, "", "X", `[{"type":"text","text":"aXb","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]`}, - } { - t.Run(test.name, func(t *testing.T) { - doc, err := Decode(json.RawMessage(`{"type":"doc","unknown":9007199254740993,"content":[{"type":"paragraph","attrs":{"keep":9007199254740993},"content":[{"type":"text","text":"ab","marks":[{"type":"code"}]},{"type":"text","text":"cd","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]},{"type":"liveBlock","attrs":{"kind":"jobs"}}]}`)) - if err != nil { - t.Fatal(err) - } - if err := Apply(TextBlocks(doc)[0], test.at, test.del, test.ins); err != nil { - t.Fatal(err) - } - want, err := Decode(json.RawMessage(`{"type":"doc","unknown":9007199254740993,"content":[{"type":"paragraph","attrs":{"keep":9007199254740993},"content":` + test.content + `},{"type":"liveBlock","attrs":{"kind":"jobs"}}]}`)) - if err != nil || !reflect.DeepEqual(doc, want) { - t.Fatalf("rewrote attributes or marks: %+v; %v", doc, err) - } - }) - } -} - -// WP-6d (g): keeping an edit preserves every field on untouched text nodes. -func TestSuggestionKeepPreservesTextNodeFields(t *testing.T) { - for _, test := range []struct { - name, before, after string - }{ - { - "right-only attrs", - `{"type":"text","text":" suffix"},{"type":"text","text":" tail","attrs":{"revision":9007199254740993}}`, - `{"type":"text","text":" suffix"},{"type":"text","text":" tail","attrs":{"revision":9007199254740993}}`, - }, - { - "left-only field", - `{"type":"text","text":" suffix","revision":9007199254740993},{"type":"text","text":" tail"}`, - `{"type":"text","text":" suffix","revision":9007199254740993},{"type":"text","text":" tail"}`, - }, - { - "different nested attrs", - `{"type":"text","text":" suffix","attrs":{"meta":{"revision":9007199254740992}}},{"type":"text","text":" tail","attrs":{"meta":{"revision":9007199254740993}}}`, - `{"type":"text","text":" suffix","attrs":{"meta":{"revision":9007199254740992}}},{"type":"text","text":" tail","attrs":{"meta":{"revision":9007199254740993}}}`, - }, - { - "equal nested fields", - `{"type":"text","text":" suffix","attrs":{"revision":9007199254740993},"extra":[{"value":1}]},{"type":"text","text":" tail","extra":[{"value":1}],"attrs":{"revision":9007199254740993}}`, - `{"type":"text","text":" suffix tail","attrs":{"revision":9007199254740993},"extra":[{"value":1}]}`, - }, - } { - t.Run(test.name, func(t *testing.T) { - doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Old","marks":[{"type":"code"}]},` + test.before + `]}]}`)) - if err != nil { - t.Fatal(err) - } - if err := Apply(TextBlocks(doc)[0], 0, "Old", "New"); err != nil { - t.Fatal(err) - } - want, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"New","marks":[{"type":"code"}]},` + test.after + `]}]}`)) - if err != nil || !reflect.DeepEqual(doc, want) { - t.Fatalf("rewrote untouched text fields: %+v; %v", doc, err) - } - if test.name == "right-only attrs" { - tail := children(TextBlocks(doc)[0].Node)[2] - if revision, ok := object(tail["attrs"])["revision"].(json.Number); !ok || revision.String() != "9007199254740993" { - t.Fatalf("revision lost precision: %#v", tail["attrs"]) - } - } - }) - } -} - -// WP-6d (g): absent and empty marks both mean no marks for keeps and insertions. -func TestSuggestionKeepTreatsEmptyAndAbsentMarksEqually(t *testing.T) { - for _, test := range []struct { - name, before, after string - at int - del, ins string - }{ - {"replacement", `[{"type":"text","text":"one","marks":[]},{"type":"text","text":" two"}]`, `[{"type":"text","text":"one three","marks":[]}]`, 4, "two", "three"}, - {"insertion", `[{"type":"text","text":"one","marks":[]},{"type":"text","text":" two"}]`, `[{"type":"text","text":"one more two","marks":[]}]`, 3, "", " more"}, - {"reverse insertion", `[{"type":"text","text":"one"},{"type":"text","text":" two","marks":[]}]`, `[{"type":"text","text":"one more two"}]`, 3, "", " more"}, - } { - t.Run(test.name, func(t *testing.T) { - doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":` + test.before + `}]}`)) - if err != nil { - t.Fatal(err) - } - if err := Apply(TextBlocks(doc)[0], test.at, test.del, test.ins); err != nil { - t.Fatal(err) - } - want, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":` + test.after + `}]}`)) - if err != nil || !reflect.DeepEqual(doc, want) { - t.Fatalf("no-mark text did not merge: %+v; %v", doc, err) - } - }) - } -} - -// WP-6d (d): path wins over duplicate text; fallback needs exactly one block, -// and nested list paragraphs retain their full content-array path. -func TestSuggestionLocatePathAndUniqueFallback(t *testing.T) { - doc, err := Decode(json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Same"}]},{"type":"paragraph","content":[{"type":"text","text":"Same"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Unique"}]}]}]}]}`)) - if err != nil { - t.Fatal(err) - } - if block, ok := Locate(doc, []int{1}, "Same"); !ok || !slices.Equal(block.Path, []int{1}) { - t.Fatalf("stored path: %+v %v", block, ok) - } - if _, ok := Locate(doc, []int{9}, "Same"); ok { - t.Fatal("ambiguous fallback applied") - } - if block, ok := Locate(doc, []int{9}, "Unique"); !ok || !slices.Equal(block.Path, []int{2, 0, 0}) { - t.Fatalf("nested fallback: %+v %v", block, ok) - } - if _, ok := Locate(doc, []int{0}, "Changed"); ok { - t.Fatal("stale text applied") - } -} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go deleted file mode 100644 index 920787dba1..0000000000 --- a/internal/workstream/pages.go +++ /dev/null @@ -1,711 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -const pageSelect = `SELECT id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc,overview,deleted_at FROM workstream_pages WHERE id=?` - -// ReadByJobsDefault chooses the creation default; later title changes do not -// alter an existing page's explicit Read by jobs setting. -func ReadByJobsDefault(title string) bool { - title = strings.TrimSpace(title) - return strings.EqualFold(title, "Spec") || strings.EqualFold(title, "Decisions") -} - -func pageIn(ctx context.Context, tx *sql.Tx, id string) (Page, bool, error) { - var p Page - var at int64 - var source sql.NullString - var deletedAt sql.NullInt64 - var overview bool - var doc string - err := tx.QueryRowContext(ctx, pageSelect, id).Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &doc, &overview, &deletedAt) - if err != nil { - return p, false, found(err) - } - p.UpdatedAt = instant(at) - if deletedAt.Valid { - p.DeletedAt = instant(deletedAt.Int64) - } - p.Doc = json.RawMessage(doc) - p.UpdatedFrom, err = readSource(source) - return p, overview, err -} -func livePageIn(ctx context.Context, tx *sql.Tx, id string) (Page, bool, error) { - p, overview, err := pageIn(ctx, tx, id) - if err == nil && !p.DeletedAt.IsZero() { - err = ErrNotFound - } - return p, overview, err -} - -const pageNodeColumns = `id,workstream_id,coalesce(parent_id,''),title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,deleted_at` - -func readPageNodes(rows *sql.Rows) ([]PageNode, error) { - defer rows.Close() - out := make([]PageNode, 0) - for rows.Next() { - var p PageNode - var at int64 - var source sql.NullString - var deletedAt sql.NullInt64 - if err := rows.Scan(&p.ID, &p.WorkstreamID, &p.ParentID, &p.Title, &p.Owner, &p.ReadByJobs, &p.Position, &p.Version, &at, &p.UpdatedBy, &source, &deletedAt); err != nil { - return nil, err - } - p.UpdatedAt = instant(at) - if deletedAt.Valid { - p.DeletedAt = instant(deletedAt.Int64) - } - var err error - p.UpdatedFrom, err = readSource(source) - if err != nil { - return nil, err - } - out = append(out, p) - } - return out, rows.Err() -} - -func pageNodesIn(ctx context.Context, tx *sql.Tx, id string) ([]PageNode, error) { - rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_at IS NULL ORDER BY position,id`, id) - if err != nil { - return nil, err - } - nodes, err := readPageNodes(rows) - if err != nil { - return nil, err - } - siblings := map[string][]PageNode{} - for _, p := range nodes { - siblings[p.ParentID] = append(siblings[p.ParentID], p) - } - out := make([]PageNode, 0) - seen := map[string]bool{} - // An explicit stack keeps exports safe for a very deep tree. - var stack []PageNode - push := func(nodes []PageNode) { - for i := len(nodes) - 1; i >= 0; i-- { - stack = append(stack, nodes[i]) - } - } - push(siblings[""]) - for len(stack) > 0 { - last := len(stack) - 1 - p := stack[last] - stack = stack[:last] - if seen[p.ID] { - continue - } - seen[p.ID] = true - out = append(out, p) - push(siblings[p.ID]) - } - return out, nil -} -func (s *Store) Pages(ctx context.Context, id string) ([]PageNode, error) { - out := make([]PageNode, 0) - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - var err error - out, err = pageNodesIn(ctx, tx, id) - return err - }) - return out, err -} - -// DeletedPages lists delete roots; descendants remain readable but are not -// separate entries unless they were deleted by their own delete operation. -func (s *Store) DeletedPages(ctx context.Context, id string) ([]PageNode, error) { - out := make([]PageNode, 0) - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_at IS NOT NULL AND deleted_root=id ORDER BY deleted_at DESC,id`, id) - if err != nil { - return err - } - out, err = readPageNodes(rows) - return err - }) - return out, err -} -func (s *Store) Page(ctx context.Context, id string, markdown bool) (Page, error) { - return s.page(ctx, id, 0, markdown) -} - -// PageAtVersion reads saved content while preserving the page's current tree metadata. -func (s *Store) PageAtVersion(ctx context.Context, id string, version int, markdown bool) (Page, error) { - if version < 1 { - return Page{}, invalid("version must be positive") - } - return s.page(ctx, id, version, markdown) -} - -func (s *Store) page(ctx context.Context, id string, version int, markdown bool) (Page, error) { - var p Page - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - var err error - p, _, err = pageIn(ctx, tx, id) - if err != nil || version == 0 || version == p.Version { - return err - } - var at int64 - var source sql.NullString - var doc string - err = tx.QueryRowContext(ctx, `SELECT title,doc,version,saved_at,saved_by,source FROM workstream_page_versions WHERE page_id=? AND version=?`, id, version). - Scan(&p.Title, &doc, &p.Version, &at, &p.UpdatedBy, &source) - if err != nil { - return found(err) - } - p.Doc, p.UpdatedAt = json.RawMessage(doc), instant(at) - p.UpdatedFrom, err = readSource(source) - return err - }) - if err == nil && markdown { - var md string - md, err = pagedoc.Markdown(p.Doc) - p.Markdown = &md - } - return p, err -} -func checkDoc(doc json.RawMessage) error { - if len(doc) > DocMax { - return ErrTooLarge - } - raw := strings.TrimSpace(string(doc)) - if len(raw) == 0 || raw[0] != '{' || !json.Valid(doc) || !utf8.Valid(doc) { - return invalid("doc must be a JSON object") - } - return nil -} -func saveVersion(ctx context.Context, tx *sql.Tx, id string) error { - _, err := tx.ExecContext(ctx, `INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,source,title,doc,bytes) - SELECT id,version,updated_at,updated_by,updated_from,title,doc,length(CAST(doc AS BLOB)) FROM workstream_pages WHERE id=? AND deleted_at IS NULL`, id) - return err -} -func (s *Store) SavePage(ctx context.Context, id string, base int, doc json.RawMessage, who Attribution) (Page, Change, error) { - if err := checkDoc(doc); err != nil { - return Page{}, Change{}, err - } - return s.savePage(ctx, id, base, doc, 0, who) -} -func (s *Store) RestorePage(ctx context.Context, id string, version, base int, who Attribution) (Page, Change, error) { - if version < 1 { - return Page{}, Change{}, invalid("version must be positive") - } - return s.savePage(ctx, id, base, nil, version, who) -} -func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawMessage, restore int, who Attribution) (Page, Change, error) { - if base < 0 { - return Page{}, Change{}, invalid("base_version must be non-negative") - } - by, err := actor(who.By) - if err != nil { - return Page{}, Change{}, err - } - source, err := sourceJSON(who.From) - if err != nil { - return Page{}, Change{}, err - } - var out Page - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, _, err := pageIn(ctx, tx, id) - if err != nil { - return err - } - if !p.DeletedAt.IsZero() { - if restore > 0 { - return invalid("deleted page restore requires only id") - } - return ErrNotFound - } - if err := workstreamPageWriteAllowed(ctx, tx, p, by); err != nil { - return err - } - if p.Version != base { - return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} - } - title := p.Title - if restore > 0 { - var raw string - err := tx.QueryRowContext(ctx, "SELECT title,doc FROM workstream_page_versions WHERE page_id=? AND version=?", id, restore).Scan(&title, &raw) - if err != nil { - return found(err) - } - doc = json.RawMessage(raw) - } - if err := checkDoc(doc); err != nil { - return err - } - if err := workstreamSavePageIn(ctx, tx, p, title, doc, by, source, s.now().UnixMilli()); err != nil { - return err - } - out, _, err = pageIn(ctx, tx, id) - c = changed(p.WorkstreamID, PartPages) - return err - }) - if err != nil { - return Page{}, Change{}, err - } - return out, c, nil -} - -// workstreamSavePageIn shares history and attribution with atomic wake writes. -func workstreamSavePageIn(ctx context.Context, tx *sql.Tx, p Page, title string, doc json.RawMessage, by string, source any, at int64) error { - if err := workstreamPageWriteAllowed(ctx, tx, p, by); err != nil { - return err - } - if err := checkDoc(doc); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,doc=?,version=version+1,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, string(doc), at, by, source, p.ID); err != nil { - return err - } - if err := saveVersion(ctx, tx, p.ID); err != nil { - return err - } - return touch(ctx, tx, p.WorkstreamID, at) -} - -// placePage validates every target before changing sibling positions. It packs -// positions after a move so a later insert never relies on gaps or old offsets. -func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, last bool, at int64, by string, source any) (bool, error) { - if parent != "" { - seen := map[string]bool{} - for id := parent; id != ""; { - if id == p.ID || seen[id] { - return false, ErrCycle - } - seen[id] = true - var workstreamID, next string - err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=? AND deleted_at IS NULL", id).Scan(&workstreamID, &next) - if err != nil { - return false, found(err) - } - if workstreamID != p.WorkstreamID { - return false, invalid("parent is in another workstream") - } - id = next - } - } - if after != "" { - var workstreamID, siblingParent string - err := tx.QueryRowContext(ctx, "SELECT workstream_id,coalesce(parent_id,'') FROM workstream_pages WHERE id=? AND deleted_at IS NULL", after).Scan(&workstreamID, &siblingParent) - if err != nil { - return false, found(err) - } - if workstreamID != p.WorkstreamID || siblingParent != parent || after == p.ID { - return false, invalid("after_id must be another sibling in this workstream") - } - } - nodes, err := pageNodesIn(ctx, tx, p.WorkstreamID) - if err != nil { - return false, err - } - siblings := make([]PageNode, 0) - old := make([]PageNode, 0) - for _, n := range nodes { - if n.ID == p.ID { - continue - } - if n.ParentID == parent { - siblings = append(siblings, n) - } - if p.ParentID != parent && n.ParentID == p.ParentID { - old = append(old, n) - } - } - index := 0 - if last { - index = len(siblings) - } - if after != "" { - for i, n := range siblings { - if n.ID == after { - index = i + 1 - break - } - } - } - // Root position zero belongs to the immutable Overview identity, even after a rename. - if parent == "" && index == 0 && len(siblings) > 0 { - index = 1 - } - if p.ParentID == parent && p.Position == index { - return false, nil - } - siblings = append(siblings, PageNode{}) - copy(siblings[index+1:], siblings[index:]) - siblings[index] = p.PageNode - for i, n := range siblings { - if n.ID == p.ID { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET parent_id=?,position=?,updated_at=?,updated_by=?,updated_from=? WHERE id=?", nullString(parent), i, at, by, source, p.ID); err != nil { - return false, err - } - } else if n.Position != i { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { - return false, err - } - } - } - for i, n := range old { - if n.Position != i { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { - return false, err - } - } - } - return true, nil -} -func workstreamPreparePageCreate(p PageCreate) (PageCreate, any, error) { - if err := validTitle(p.Title); err != nil { - return p, nil, err - } - owner, err := actor(p.Owner) - if err != nil { - return p, nil, err - } - by, err := actor(p.By) - if err != nil { - return p, nil, err - } - if by == "agent" && owner != "agent" { - return p, nil, invalid("the agent creates only its own pages") - } - source, err := sourceJSON(p.From) - if err != nil { - return p, nil, err - } - if p.Doc == nil { - p.Doc = json.RawMessage(emptyDoc) - } - if err := checkDoc(p.Doc); err != nil { - return p, nil, err - } - p.Owner, p.By = owner, by - return p, source, nil -} - -func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, error) { - p, source, err := workstreamPreparePageCreate(p) - if err != nil { - return Page{}, Change{}, err - } - var out Page - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - out, err = s.workstreamCreatePageIn(ctx, tx, p, source) - c = changed(p.WorkstreamID, PartPages) - return err - }) - if err != nil { - return Page{}, Change{}, err - } - return out, c, nil -} - -func (s *Store) workstreamCreatePageIn(ctx context.Context, tx *sql.Tx, p PageCreate, source any) (Page, error) { - if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, p.By); err != nil { - return Page{}, err - } - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_pages WHERE workstream_id=?", p.WorkstreamID).Scan(&count); err != nil { - return Page{}, err - } - if count >= PagesMax { - return Page{}, fmt.Errorf("%w: a workstream holds at most %d pages, including deleted pages", ErrLimit, PagesMax) - } - id, err := s.newID() - if err != nil { - return Page{}, err - } - at := s.now().UnixMilli() - // A temporary root position lets the common placement path pack every sibling. - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc) VALUES (?,?,?,?,?,2147483647,1,?,?,?,?)`, id, p.WorkstreamID, p.Title, p.Owner, p.ReadByJobs, at, p.By, source, string(p.Doc)); err != nil { - return Page{}, err - } - page, _, err := livePageIn(ctx, tx, id) - if err != nil { - return Page{}, err - } - if _, err := placePage(ctx, tx, page, p.ParentID, p.AfterID, p.AfterID == "", at, p.By, source); err != nil { - return Page{}, err - } - if err := saveVersion(ctx, tx, id); err != nil { - return Page{}, err - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return Page{}, err - } - out, _, err := pageIn(ctx, tx, id) - return out, err -} -func (s *Store) UpdatePage(ctx context.Context, id string, p PagePatch) (PageNode, Change, error) { - if p.Title != nil { - if err := validTitle(*p.Title); err != nil { - return PageNode{}, Change{}, err - } - } - if p.Owner != nil { - if *p.Owner != "you" && *p.Owner != "agent" { - return PageNode{}, Change{}, invalid("owner must be you or agent") - } - } - by, err := actor(p.By) - if err != nil { - return PageNode{}, Change{}, err - } - source, err := sourceJSON(p.From) - if err != nil { - return PageNode{}, Change{}, err - } - var out PageNode - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - page, _, err := livePageIn(ctx, tx, id) - if err != nil { - return err - } - title, owner, read := page.Title, page.Owner, page.ReadByJobs - if p.Title != nil { - title = *p.Title - } - if p.Owner != nil { - owner = *p.Owner - } - if p.ReadByJobs != nil { - read = *p.ReadByJobs - } - if title == page.Title && owner == page.Owner && read == page.ReadByJobs { - out = page.PageNode - return nil - } - at := s.now().UnixMilli() - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET title=?,owner=?,read_by_jobs=?,updated_at=?,updated_by=?,updated_from=? WHERE id=?", title, owner, read, at, by, source, id); err != nil { - return err - } - if err := touch(ctx, tx, page.WorkstreamID, at); err != nil { - return err - } - page, _, err = pageIn(ctx, tx, id) - out = page.PageNode - c = changed(page.WorkstreamID, PartPages) - return err - }) - if err != nil { - return PageNode{}, Change{}, err - } - return out, c, nil -} -func (s *Store) MovePage(ctx context.Context, id, parent, after string) (PageNode, Change, error) { - var out PageNode - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, overview, err := livePageIn(ctx, tx, id) - if err != nil { - return err - } - if overview { - return invalid("Overview cannot be moved") - } - at := s.now().UnixMilli() - moved, err := placePage(ctx, tx, p, parent, after, false, at, "you", nil) - if err != nil { - return err - } - if moved { - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - c = changed(p.WorkstreamID, PartPages) - } - p, _, err = pageIn(ctx, tx, id) - out = p.PageNode - return err - }) - if err != nil { - return PageNode{}, Change{}, err - } - return out, c, nil -} -func (s *Store) DeletePage(ctx context.Context, id string) ([]string, Change, error) { - deleted := []string{} - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, overview, err := livePageIn(ctx, tx, id) - if err != nil { - return err - } - if overview { - return invalid("Overview cannot be deleted") - } - nodes, err := pageNodesIn(ctx, tx, p.WorkstreamID) - if err != nil { - return err - } - gone := map[string]bool{id: true} - for _, n := range nodes { - if gone[n.ID] || gone[n.ParentID] { - gone[n.ID] = true - deleted = append(deleted, n.ID) - } - } - at := s.now().UnixMilli() - for _, deletedID := range deleted { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET deleted_at=?,deleted_root=? WHERE id=?", at, id, deletedID); err != nil { - return err - } - } - i := 0 - for _, n := range nodes { - if !gone[n.ID] && n.ParentID == p.ParentID { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET position=? WHERE id=?", i, n.ID); err != nil { - return err - } - i++ - } - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - c = changed(p.WorkstreamID, PartPages) - return nil - }) - if err != nil { - return nil, Change{}, err - } - return deleted, c, nil -} - -// UndeletePage restores only descendants removed by the same delete. Like a -// move, it records the person as the actor and does not create a saved version. -func (s *Store) UndeletePage(ctx context.Context, id string) (Page, Change, error) { - var out Page - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - p, _, err := pageIn(ctx, tx, id) - if err != nil { - return err - } - if p.DeletedAt.IsZero() { - return invalid("live page restore requires version and base_version") - } - var root string - if err := tx.QueryRowContext(ctx, "SELECT deleted_root FROM workstream_pages WHERE id=?", id).Scan(&root); err != nil { - return found(err) - } - rows, err := tx.QueryContext(ctx, `SELECT `+pageNodeColumns+` FROM workstream_pages WHERE workstream_id=? AND deleted_root=? AND deleted_at=? ORDER BY position,id`, p.WorkstreamID, root, p.DeletedAt.UnixMilli()) - if err != nil { - return err - } - nodes, err := readPageNodes(rows) - if err != nil { - return err - } - children := map[string][]string{} - for _, n := range nodes { - children[n.ParentID] = append(children[n.ParentID], n.ID) - } - parent := p.ParentID - if parent != "" { - if _, _, err := livePageIn(ctx, tx, parent); errors.Is(err, ErrNotFound) { - parent = "" - } else if err != nil { - return err - } - } - at := s.now().UnixMilli() - stack := []string{id} - seen := map[string]bool{} - for len(stack) > 0 { - last := len(stack) - 1 - restoredID := stack[last] - stack = stack[:last] - if seen[restoredID] { - continue - } - seen[restoredID] = true - if _, err := tx.ExecContext(ctx, `UPDATE workstream_pages SET deleted_at=NULL,deleted_root=NULL,updated_at=?,updated_by='you',updated_from=NULL WHERE id=?`, at, restoredID); err != nil { - return err - } - stack = append(stack, children[restoredID]...) - } - // Force placement even when the old ordinal happens to equal the append - // index: the restored page still needs its parent and attribution written. - p.Position = -1 - if _, err := placePage(ctx, tx, p, parent, "", true, at, "you", nil); err != nil { - return err - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - out, _, err = pageIn(ctx, tx, id) - c = changed(p.WorkstreamID, PartPages) - return err - }) - if err != nil { - return Page{}, Change{}, err - } - return out, c, nil -} - -func (s *Store) PageHistory(ctx context.Context, id string, limit int) ([]PageVersion, error) { - if limit == 0 { - limit = 50 - } - if limit < 1 || limit > 200 { - return nil, invalid("history limit must be 1–200") - } - out := []PageVersion{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, _, err := livePageIn(ctx, tx, id); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, "SELECT version,saved_at,saved_by,source,title,bytes FROM workstream_page_versions WHERE page_id=? ORDER BY version DESC LIMIT ?", id, limit) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var p PageVersion - var at int64 - var source sql.NullString - if err := rows.Scan(&p.Version, &at, &p.SavedBy, &source, &p.Title, &p.Bytes); err != nil { - return err - } - p.SavedAt = instant(at) - p.From, err = readSource(source) - if err != nil { - return err - } - out = append(out, p) - } - return rows.Err() - }) - return out, err -} diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go deleted file mode 100644 index 22b15f8a4a..0000000000 --- a/internal/workstream/pages_test.go +++ /dev/null @@ -1,517 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -// Part A (f): optimistic saves refuse stale versions without changing history; -// restoration copies an old title and document into a new immutable version. -func TestPartAPageSaveHistoryAndRestore(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Pages") - nodes, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - first, err := s.Page(ctx, nodes[0].ID, false) - if err != nil { - t.Fatal(err) - } - owner := "agent" - if _, _, err := s.UpdatePage(ctx, first.ID, PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Saved"}]}]}`) - *now = now.Add(time.Hour) - saved, _, err := s.SavePage(ctx, first.ID, first.Version, doc, Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}) - if err != nil || saved.Version != 2 { - t.Fatalf("save: %+v %v", saved, err) - } - _, c, err := s.SavePage(ctx, first.ID, 1, json.RawMessage(emptyDoc), Attribution{}) - var stale *VersionConflict - if !errors.As(err, &stale) || stale.ID != first.ID || stale.Version != 2 || stale.UpdatedBy != "agent" || !stale.UpdatedAt.Equal(*now) || len(c.Parts) != 0 { - t.Fatalf("stale: %+v %v", stale, err) - } - current, _ := s.Page(ctx, first.ID, false) - history, _ := s.PageHistory(ctx, first.ID, 50) - if !reflect.DeepEqual(current, saved) || len(history) != 2 || history[0].Version != 2 || history[1].Version != 1 { - t.Fatalf("stale changed page or history: %+v %+v", current, history) - } - title := "Renamed" - _, _, err = s.UpdatePage(ctx, first.ID, PagePatch{Title: &title}) - if err != nil { - t.Fatal(err) - } - restored, _, err := s.RestorePage(ctx, first.ID, 1, 2, Attribution{}) - if err != nil || restored.Version != 3 || restored.Title != "Overview" || string(restored.Doc) != emptyDoc { - t.Fatalf("restore: %+v %v", restored, err) - } - history, _ = s.PageHistory(ctx, first.ID, 50) - if len(history) != 3 || history[0].Version != 3 || history[1].SavedBy != "agent" { - t.Fatalf("history: %+v", history) - } - _, _, err = s.SavePage(ctx, first.ID, 3, json.RawMessage(`{"x":"`+strings.Repeat("x", DocMax)+`"}`), Attribution{}) - if !errors.Is(err, ErrTooLarge) { - t.Fatalf("size: %v", err) - } -} - -// Part A (f): placement defaults differ for create and move; Overview is fixed, -// parent cycles and cross-workstream moves refuse, and delete removes descendants. -func TestPartAPageTreePlacementCyclesAndDelete(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Tree") - nodes, _ := s.Pages(ctx, w.ID) - overview := nodes[0] - a, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "A"}) - if err != nil { - t.Fatal(err) - } - b, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "B"}) - if err != nil { - t.Fatal(err) - } - child, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, ParentID: a.ID, Title: "Child"}) - if err != nil { - t.Fatal(err) - } - if a.Position != 1 || b.Position != 2 || child.Position != 0 { - t.Fatalf("positions: %+v %+v %+v", a, b, child) - } - if _, _, err := s.MovePage(ctx, a.ID, child.ID, ""); !errors.Is(err, ErrCycle) { - t.Fatalf("descendant: %v", err) - } - if _, _, err := s.MovePage(ctx, a.ID, a.ID, ""); !errors.Is(err, ErrCycle) { - t.Fatalf("self: %v", err) - } - if _, _, err := s.MovePage(ctx, overview.ID, a.ID, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("Overview move: %v", err) - } - moved, _, err := s.MovePage(ctx, b.ID, "", "") - if err != nil || moved.Position != 1 { - t.Fatalf("root move: %+v %v", moved, err) - } - nodes, _ = s.Pages(ctx, w.ID) - ids := []string{} - for _, n := range nodes { - ids = append(ids, n.ID) - } - if !reflect.DeepEqual(ids, []string{overview.ID, b.ID, a.ID, child.ID}) { - t.Fatalf("depth-first: %v", ids) - } - other := createStream(t, s, "OtherTree") - otherPages, _ := s.Pages(ctx, other.ID) - if _, _, err := s.MovePage(ctx, a.ID, otherPages[0].ID, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("cross-workstream: %v", err) - } - if _, _, err := s.DeletePage(ctx, overview.ID); !errors.Is(err, ErrInvalid) { - t.Fatalf("Overview delete: %v", err) - } - deleted, _, err := s.DeletePage(ctx, a.ID) - if err != nil || !reflect.DeepEqual(deleted, []string{a.ID, child.ID}) { - t.Fatalf("delete: %v %v", deleted, err) - } - if page, err := s.Page(ctx, child.ID, false); err != nil || page.DeletedAt.IsZero() { - t.Fatalf("deleted child must remain readable: %+v %v", page, err) - } -} - -func TestPartAPlanMembershipVersionsAndDone(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Plan", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "8", Factory: "f"}) - p, err := s.Plan(ctx, w.ID) - if err != nil || p.Version != 0 || p.Milestones == nil || p.Waves == nil { - t.Fatalf("initial: %+v %v", p, err) - } - waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 8}, {Kind: "draft", DraftID: "draft", Title: "Next"}}}} - p, _, err = s.SetPlan(ctx, w.ID, 0, []Milestone{{Title: "Ship"}}, waves, Attribution{By: "agent"}) - if err != nil || p.Version != 1 || p.Milestones[0].ID == "" { - t.Fatalf("set: %+v %v", p, err) - } - _, _, err = s.SetPlan(ctx, w.ID, 0, nil, nil, Attribution{}) - var stale *VersionConflict - if !errors.As(err, &stale) || stale.Version != 1 || stale.UpdatedBy != "agent" { - t.Fatalf("stale: %v", err) - } - for _, bad := range [][]Wave{ - {{N: 0}}, {{N: 2}, {N: 1}}, {{N: 1}, {N: 1}}, {{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 99}}}}, - } { - if _, _, err := s.SetPlan(ctx, w.ID, 1, nil, bad, Attribution{}); !errors.Is(err, ErrInvalid) { - t.Fatalf("invalid waves %+v: %v", bad, err) - } - } - _, _, err = s.MarkDone(ctx, w.ID, "") - if err != nil { - t.Fatal(err) - } - if _, _, err := s.SetPlan(ctx, w.ID, 1, nil, nil, Attribution{}); !errors.Is(err, ErrConflict) { - t.Fatalf("done: %v", err) - } -} - -// page.get's markdown is the shared page renderer's output (pagedoc), so the -// engine has one renderer and it matches the desktop app's. -func TestPartAMarkdownExportIsThePageRenderer(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Markdown") - pages, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - doc := json.RawMessage(`{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},{"type":"paragraph","content":[{"type":"text","text":"Body"}]}]}`) - if _, _, err := s.SavePage(ctx, pages[0].ID, 1, doc, Attribution{}); err != nil { - t.Fatal(err) - } - p, err := s.Page(ctx, pages[0].ID, true) - if err != nil { - t.Fatal(err) - } - want, err := pagedoc.Markdown(doc) - if err != nil { - t.Fatal(err) - } - if p.Markdown == nil || *p.Markdown != want || !strings.Contains(want, "Title") { - t.Fatalf("markdown %v, want %q", p.Markdown, want) - } -} - -func TestPartAOverviewIdentitySurvivesRenaming(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Identity") - pages, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - title := "Introduction" - overview, _, err := s.UpdatePage(ctx, pages[0].ID, PagePatch{Title: &title}) - if err != nil { - t.Fatal(err) - } - other, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Overview"}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MovePage(ctx, overview.ID, other.ID, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("renamed Overview move: %v", err) - } - if _, _, err := s.DeletePage(ctx, overview.ID); !errors.Is(err, ErrInvalid) { - t.Fatalf("renamed Overview delete: %v", err) - } - if _, _, err := s.MovePage(ctx, other.ID, "", ""); err != nil { - t.Fatalf("ordinary page named Overview: %v", err) - } - pages, err = s.Pages(ctx, w.ID) - if err != nil || pages[0].ID != overview.ID || pages[0].Position != 0 { - t.Fatalf("Overview order: %+v %v", pages, err) - } -} - -// WP-F1 (a): saved content and attribution combine with current page metadata; -// selecting the current version is identical to reading without a version. -func TestWorkstreamPageGetSavedVersion(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Version reads") - firstDoc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]}`) - first, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "First title", Owner: "agent", Doc: firstDoc, - Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: "first-source"}}}) - if err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - if _, _, err := s.SavePage(ctx, first.ID, 1, json.RawMessage(emptyDoc), Attribution{}); err != nil { - t.Fatal(err) - } - parent, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Parent"}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MovePage(ctx, first.ID, parent.ID, ""); err != nil { - t.Fatal(err) - } - title, owner, read := "Current title", "agent", true - if _, _, err := s.UpdatePage(ctx, first.ID, PagePatch{Title: &title, Owner: &owner, ReadByJobs: &read}); err != nil { - t.Fatal(err) - } - current, err := s.Page(ctx, first.ID, false) - if err != nil { - t.Fatal(err) - } - selected, err := s.PageAtVersion(ctx, first.ID, current.Version, false) - if err != nil || !reflect.DeepEqual(selected, current) { - t.Fatalf("current version: %+v, want %+v: %v", selected, current, err) - } - check := func(deleted bool) { - t.Helper() - old, err := s.PageAtVersion(ctx, first.ID, 1, true) - if err != nil { - t.Fatal(err) - } - want := current - want.Title, want.Doc, want.Version = first.Title, first.Doc, first.Version - want.UpdatedAt, want.UpdatedBy, want.UpdatedFrom = first.UpdatedAt, first.UpdatedBy, first.UpdatedFrom - md, err := pagedoc.Markdown(firstDoc) - if err != nil { - t.Fatal(err) - } - want.Markdown = &md - if deleted { - want.DeletedAt = *now - } - if !reflect.DeepEqual(old, want) || old.Markdown == nil || !strings.Contains(*old.Markdown, "First") { - t.Fatalf("saved version: %+v, want %+v", old, want) - } - } - check(false) - if _, err := s.PageAtVersion(ctx, first.ID, 99, false); !errors.Is(err, ErrNotFound) { - t.Fatalf("unknown version: %v", err) - } - for _, version := range []int{0, -1} { - if _, err := s.PageAtVersion(ctx, first.ID, version, false); !errors.Is(err, ErrInvalid) { - t.Fatalf("invalid version %d: %v", version, err) - } - } - if _, _, err := s.DeletePage(ctx, first.ID); err != nil { - t.Fatal(err) - } - check(true) -} - -func createWorkstreamPage(t *testing.T, s *Store, workstreamID, parent, title string) Page { - t.Helper() - p, _, err := s.CreatePage(context.Background(), PageCreate{WorkstreamID: workstreamID, ParentID: parent, Title: title}) - if err != nil { - t.Fatal(err) - } - return p -} - -// WP-F1 (b): delete groups preserve separate deletions, history, nesting and order; -// undelete appends under a live parent and changes attribution without a save. -func TestWorkstreamDeletedPageTreeRestore(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Restorable tree") - deleted, err := s.DeletedPages(ctx, w.ID) - if err != nil || deleted == nil || len(deleted) != 0 { - t.Fatalf("initial deleted list: %+v %v", deleted, err) - } - parent := createWorkstreamPage(t, s, w.ID, "", "Parent") - root := createWorkstreamPage(t, s, w.ID, parent.ID, "Root") - earlier := createWorkstreamPage(t, s, w.ID, root.ID, "Earlier deletion") - first := createWorkstreamPage(t, s, w.ID, root.ID, "First") - grandchild := createWorkstreamPage(t, s, w.ID, first.ID, "Grandchild") - second := createWorkstreamPage(t, s, w.ID, root.ID, "Second") - sibling := createWorkstreamPage(t, s, w.ID, parent.ID, "Sibling") - doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Keep me"}]}]}`) - // The agent writes only its own pages, so the page is the agent's for this save. - agentOwner := "agent" - if _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{Owner: &agentOwner}); err != nil { - t.Fatal(err) - } - if _, _, err := s.SavePage(ctx, root.ID, 1, doc, Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}); err != nil { - t.Fatal(err) - } - lastTitle := "Last title" - if _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{Title: &lastTitle, Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}}); err != nil { - t.Fatal(err) - } - if _, _, err := s.DeletePage(ctx, earlier.ID); err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - ids, change, err := s.DeletePage(ctx, root.ID) - if err != nil || !reflect.DeepEqual(ids, []string{root.ID, first.ID, grandchild.ID, second.ID}) || !reflect.DeepEqual(change.Parts, []ChangedPart{PartWorkstream, PartPages}) { - t.Fatalf("delete receipt: %v %+v %v", ids, change, err) - } - deleted, err = s.DeletedPages(ctx, w.ID) - if err != nil || len(deleted) != 2 || deleted[0].ID != root.ID || deleted[1].ID != earlier.ID || !deleted[0].DeletedAt.Equal(*now) || !deleted[0].DeletedAt.After(deleted[1].DeletedAt) { - t.Fatalf("delete roots: %+v %v", deleted, err) - } - nodes, err := s.Pages(ctx, w.ID) - if err != nil || len(nodes) != 3 || nodes[1].ID != parent.ID || nodes[2].ID != sibling.ID || nodes[2].Position != 0 { - t.Fatalf("live tree: %+v %v", nodes, err) - } - for _, id := range ids { - p, err := s.Page(ctx, id, true) - if err != nil || !p.DeletedAt.Equal(*now) { - t.Fatalf("deleted page %s: %+v %v", id, p, err) - } - } - for name, write := range map[string]func() error{ - "save": func() error { - _, _, err := s.SavePage(ctx, root.ID, 2, json.RawMessage(emptyDoc), Attribution{}) - return err - }, - "update": func() error { _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{}); return err }, - "move": func() error { _, _, err := s.MovePage(ctx, root.ID, "", ""); return err }, - "delete": func() error { _, _, err := s.DeletePage(ctx, root.ID); return err }, - "history": func() error { _, err := s.PageHistory(ctx, root.ID, 50); return err }, - "create parent": func() error { - _, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, ParentID: root.ID, Title: "No"}) - return err - }, - "create after": func() error { - _, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, AfterID: root.ID, Title: "No"}) - return err - }, - "move parent": func() error { _, _, err := s.MovePage(ctx, sibling.ID, root.ID, ""); return err }, - "move after": func() error { _, _, err := s.MovePage(ctx, sibling.ID, "", root.ID); return err }, - } { - t.Run(name, func(t *testing.T) { - if err := write(); !errors.Is(err, ErrNotFound) { - t.Fatalf("deleted target: %v", err) - } - }) - } - *now = now.Add(time.Hour) - restored, change, err := s.UndeletePage(ctx, root.ID) - if err != nil || restored.ParentID != parent.ID || restored.Position != 1 || restored.Title != lastTitle || restored.Version != 2 || string(restored.Doc) != string(doc) || !restored.DeletedAt.IsZero() || !restored.UpdatedAt.Equal(*now) || restored.UpdatedBy != "you" || restored.UpdatedFrom != nil || change.ID != w.ID || !reflect.DeepEqual(change.Parts, []ChangedPart{PartWorkstream, PartPages}) { - t.Fatalf("restore: %+v %+v %v", restored, change, err) - } - nodes, err = s.Pages(ctx, w.ID) - var liveIDs []string - for _, p := range nodes { - liveIDs = append(liveIDs, p.ID) - } - if err != nil || len(liveIDs) != 7 || !reflect.DeepEqual(liveIDs[1:], []string{parent.ID, sibling.ID, root.ID, first.ID, grandchild.ID, second.ID}) { - t.Fatalf("restored order: %+v %v", nodes, err) - } - for _, want := range []Page{first, grandchild, second} { - p, err := s.Page(ctx, want.ID, false) - if err != nil || p.ParentID != want.ParentID || p.Version != want.Version || !p.DeletedAt.IsZero() { - t.Fatalf("restored nesting: %+v %v", p, err) - } - } - deleted, err = s.DeletedPages(ctx, w.ID) - if err != nil || len(deleted) != 1 || deleted[0].ID != earlier.ID { - t.Fatalf("earlier deletion revived: %+v %v", deleted, err) - } - history, err := s.PageHistory(ctx, root.ID, 50) - if err != nil || len(history) != 2 || history[0].Title != root.Title || history[0].SavedBy != "agent" || history[0].From == nil || history[0].From.ID != "source" { - t.Fatalf("restore changed saved history: %+v %v", history, err) - } -} - -// WP-F1 (b): restoring a descendant of a deleted parent restores only its own -// group subtree, appends at the root, and remains permitted on a done workstream. -func TestWorkstreamDeletedDescendantRestoresAtRoot(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Detached restore") - root := createWorkstreamPage(t, s, w.ID, "", "Root") - child := createWorkstreamPage(t, s, w.ID, root.ID, "Child") - grandchild := createWorkstreamPage(t, s, w.ID, child.ID, "Grandchild") - other := createWorkstreamPage(t, s, w.ID, root.ID, "Other") - last := createWorkstreamPage(t, s, w.ID, "", "Last") - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - if _, _, err := s.DeletePage(ctx, root.ID); err != nil { - t.Fatal(err) - } - restored, _, err := s.UndeletePage(ctx, child.ID) - if err != nil || restored.ParentID != "" || restored.Position != 2 || restored.Version != 1 { - t.Fatalf("descendant restore: %+v %v", restored, err) - } - nodes, err := s.Pages(ctx, w.ID) - if err != nil || len(nodes) != 4 || nodes[1].ID != last.ID || nodes[2].ID != child.ID || nodes[3].ID != grandchild.ID { - t.Fatalf("detached tree: %+v %v", nodes, err) - } - for _, id := range []string{root.ID, other.ID} { - p, err := s.Page(ctx, id, false) - if err != nil || p.DeletedAt.IsZero() { - t.Fatalf("unrelated deleted page: %+v %v", p, err) - } - } - rootRestored, _, err := s.UndeletePage(ctx, root.ID) - if err != nil || rootRestored.ParentID != "" || rootRestored.Position != 3 { - t.Fatalf("root append: %+v %v", rootRestored, err) - } - p, err := s.Page(ctx, child.ID, false) - if err != nil || p.ParentID != "" || p.Position != 2 { - t.Fatalf("later root restore moved detached child: %+v %v", p, err) - } - stream, err := s.Get(ctx, w.ID) - if err != nil || stream.DoneAt.IsZero() { - t.Fatalf("restore changed done state: %+v %v", stream, err) - } -} - -// WP-F1 (b): explicitly purging a workstream and its collection removes deleted -// pages and their saved versions through the cascade, while page.delete retains both. -func TestWorkstreamDeletedPagesCollectionCascade(t *testing.T) { - s, storage, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Page retention") - root := createWorkstreamPage(t, s, w.ID, "", "Root") - child := createWorkstreamPage(t, s, w.ID, root.ID, "Child") - if _, _, err := s.DeletePage(ctx, root.ID); err != nil { - t.Fatal(err) - } - for _, id := range []string{root.ID, child.ID} { - if p, err := s.PageAtVersion(ctx, id, 1, false); err != nil || p.DeletedAt.IsZero() { - t.Fatalf("retained version: %+v %v", p, err) - } - } - if err := storage.Write(ctx, func(tx *sql.Tx) error { - if _, err := tx.ExecContext(ctx, "DELETE FROM workstreams WHERE id=?", w.ID); err != nil { - return err - } - _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", w.ID) - return err - }); err != nil { - t.Fatal(err) - } - if err := storage.Read(ctx, func(tx *sql.Tx, _ int) error { - for _, table := range []string{"workstream_pages", "workstream_page_versions"} { - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM "+table).Scan(&count); err != nil { - return err - } - if count != 0 { - t.Errorf("%s retained %d rows after collection purge", table, count) - } - } - return nil - }); err != nil { - t.Fatal(err) - } -} - -// WP-F1 (c): the exported creation default trims Unicode space and uses case -// folding, while the store's existing explicit boolean remains authoritative. -func TestWorkstreamReadByJobsDefault(t *testing.T) { - for _, tc := range []struct { - title string - want bool - }{ - {"Spec", true}, {" decisions ", true}, {"DECISIONS", true}, {"\u2003sPeC\u2003", true}, - {"Spec notes", false}, {"Overview", false}, {"Notes", false}, {"", false}, - } { - if got := ReadByJobsDefault(tc.title); got != tc.want { - t.Errorf("%q: %v, want %v", tc.title, got, tc.want) - } - } - s, _, _ := testStore(t) - w := createStream(t, s, "Explicit store default") - p := createWorkstreamPage(t, s, w.ID, "", "Spec") - if p.ReadByJobs { - t.Fatal("store changed an explicit false to the title default") - } -} diff --git a/internal/workstream/plan.go b/internal/workstream/plan.go deleted file mode 100644 index 8dd7142ddd..0000000000 --- a/internal/workstream/plan.go +++ /dev/null @@ -1,197 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "math" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func planIn(ctx context.Context, tx *sql.Tx, id string) (Plan, error) { - p := Plan{WorkstreamID: id, Milestones: []Milestone{}, Waves: []Wave{}, UpdatedBy: "you"} - var at int64 - var milestones, waves string - err := tx.QueryRowContext(ctx, "SELECT version,updated_at,updated_by,milestones,waves FROM workstream_plans WHERE workstream_id=?", id).Scan(&p.Version, &at, &p.UpdatedBy, &milestones, &waves) - if errors.Is(err, sql.ErrNoRows) { - return p, nil - } - if err != nil { - return p, err - } - p.UpdatedAt = instant(at) - if err := json.Unmarshal([]byte(milestones), &p.Milestones); err != nil { - return p, err - } - err = json.Unmarshal([]byte(waves), &p.Waves) - return p, err -} -func (s *Store) Plan(ctx context.Context, id string) (Plan, error) { - var p Plan - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - var err error - p, err = planIn(ctx, tx, id) - return err - }) - return p, err -} -func (s *Store) SetPlan(ctx context.Context, id string, base int, milestones []Milestone, waves []Wave, who Attribution) (Plan, Change, error) { - by, err := actor(who.By) - if err != nil { - return Plan{}, Change{}, err - } - if base < 0 { - return Plan{}, Change{}, invalid("base_version must be non-negative") - } - // Copy slices because assigned ids and normalized lists belong to the saved plan. - milestones = append([]Milestone{}, milestones...) - waves = append([]Wave{}, waves...) - for i := range waves { - waves[i].Items = append([]PlanItem{}, waves[i].Items...) - } - var out Plan - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if !w.DoneAt.IsZero() { - return ErrConflict - } - if len(milestones) > PlanMilestonesMax { - return fmt.Errorf("%w: a plan holds at most %d milestones", ErrLimit, PlanMilestonesMax) - } - if len(waves) > PlanWavesMax { - return fmt.Errorf("%w: a plan holds at most %d waves", ErrLimit, PlanWavesMax) - } - items := 0 - for _, wave := range waves { - items += len(wave.Items) - if items > PlanItemsMax { - return fmt.Errorf("%w: a plan holds at most %d items", ErrLimit, PlanItemsMax) - } - } - p, err := planIn(ctx, tx, id) - if err != nil { - return err - } - if base != p.Version { - at := p.UpdatedAt - if at.IsZero() { - at = w.UpdatedAt - } - return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: at, UpdatedBy: p.UpdatedBy} - } - ids := map[string]bool{} - for i := range milestones { - m := &milestones[i] - if err := validTitle(m.Title); err != nil { - return err - } - if m.ID == "" { - m.ID, err = s.newID() - if err != nil { - return err - } - } - if ids[m.ID] { - return invalid("milestone ids must be unique") - } - ids[m.ID] = true - } - jobs := map[Job]bool{} - for _, j := range w.Jobs() { - jobs[j] = true - } - last := 0 - for _, wave := range waves { - if wave.N <= last { - return invalid("waves must be positive, unique and ascending") - } - last = wave.N - if !lineWithin(wave.Title, 200) { - return invalid("wave title must be at most 200 bytes without control characters") - } - if wave.MilestoneID != "" && !ids[wave.MilestoneID] { - return invalid("wave milestone does not exist") - } - for _, item := range wave.Items { - if err := workstreamDraftFields(item); err != nil { - return err - } - switch item.Kind { - case "job": - if item.Job <= 0 || !jobs[Job{Factory: item.Factory, Number: item.Job}] { - return invalid("plan job must be a member") - } - case "draft": - if item.DraftID == "" || item.Title == "" { - return invalid("draft needs draft_id and title") - } - if err := validTitle(item.Title); err != nil { - return err - } - // Control characters stay invalid whatever the length. - if !textWithin(item.Text, len(item.Text)) { - return invalid("draft text allows only newline and tab control characters") - } - if len(item.Text) > PlanDraftTextMax { - return fmt.Errorf("%w: draft text must be at most %d bytes", ErrLimit, PlanDraftTextMax) - } - default: - return invalid("unknown plan item kind") - } - } - } - m, err := json.Marshal(milestones) - if err != nil { - return err - } - v, err := json.Marshal(waves) - if err != nil { - return err - } - if len(m)+len(v) > PlanEncodedMax { - return fmt.Errorf("%w: a plan's milestones and waves must encode to at most %d bytes", ErrLimit, PlanEncodedMax) - } - at := s.now().UnixMilli() - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES (?,?,?,?,?,?) - ON CONFLICT(workstream_id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,updated_by=excluded.updated_by,milestones=excluded.milestones,waves=excluded.waves`, id, p.Version+1, at, by, string(m), string(v)); err != nil { - return err - } - if err := touch(ctx, tx, id, at); err != nil { - return err - } - out, err = planIn(ctx, tx, id) - c = changed(id, PartPlan) - return err - }) - if err != nil { - return Plan{}, Change{}, err - } - return out, c, nil -} - -func workstreamDraftFields(item PlanItem) error { - if item.Kind != "draft" && (item.Size != "" || item.BudgetUSD != 0 || item.Source != nil) { - return invalid("size, budget_usd and source are allowed only on drafts") - } - if item.Size != "" && item.Size != "S" && item.Size != "M" && item.Size != "L" { - return invalid("draft size must be S, M or L") - } - if math.IsNaN(item.BudgetUSD) || math.IsInf(item.BudgetUSD, 0) || item.BudgetUSD < 0 || item.BudgetUSD > workspace.WorkstreamGoAheadCapMax { - return invalid(fmt.Sprintf("draft budget_usd must be greater than 0 and at most %d when set", workspace.WorkstreamGoAheadCapMax)) - } - _, err := sourceJSON(item.Source) - return err -} diff --git a/internal/workstream/records.go b/internal/workstream/records.go deleted file mode 100644 index 05de613c91..0000000000 --- a/internal/workstream/records.go +++ /dev/null @@ -1,310 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" -) - -const recordSelect = `SELECT id,workstream_id,kind,text,state,actor,source,created_at,decided_at,coalesce(supersedes,''),coalesce(superseded_by,''),goahead,coalesce(goahead_id,'') FROM workstream_records` - -func recordIn(ctx context.Context, tx *sql.Tx, id string) (Record, error) { - var r Record - var source sql.NullString - var proposal sql.NullString - var at int64 - var decided sql.NullInt64 - err := tx.QueryRowContext(ctx, recordSelect+" WHERE id=?", id).Scan(&r.ID, &r.WorkstreamID, &r.Kind, &r.Text, &r.State, &r.By, &source, &at, &decided, &r.Supersedes, &r.SupersededBy, &proposal, &r.GoAheadID) - if err != nil { - return r, found(err) - } - r.CreatedAt, r.DecidedAt = instant(at), optionalInstant(decided) - if proposal.Valid { - if err := json.Unmarshal([]byte(proposal.String), &r.GoAhead); err != nil { - return r, err - } - } - r.Source, err = readSource(source) - return r, err -} -func recordText(kind, text string) error { - if kind != "decision" && kind != "question" && kind != "goahead" { - return invalid("record kind must be decision, question or goahead") - } - if strings.TrimSpace(text) == "" || !textWithin(text, 2000) { - return invalid("record text must be 1–2000 bytes") - } - return nil -} - -func workstreamRecordLimitIn(ctx context.Context, tx *sql.Tx, id string) error { - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_records WHERE workstream_id=?", id).Scan(&count); err != nil { - return err - } - if count >= RecordsMax { - return fmt.Errorf("%w: a workstream holds at most %d records", ErrLimit, RecordsMax) - } - return nil -} - -func (s *Store) Records(ctx context.Context, id, kind, state string, includeGoAheads ...bool) ([]Record, error) { - if kind != "" && kind != "decision" && kind != "question" && kind != "goahead" { - return nil, invalid("unknown record kind") - } - if state != "" && state != "proposed" && state != "kept" && state != "dropped" && state != "superseded" { - return nil, invalid("unknown record state") - } - out := []Record{} - include := len(includeGoAheads) > 0 && includeGoAheads[0] - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, `SELECT id FROM workstream_records WHERE workstream_id=? AND (?='' OR kind=?) AND (?!='' OR ? OR kind!='goahead') AND (?='' OR state=?) ORDER BY created_at DESC,rowid DESC`, id, kind, kind, kind, include, state, state) - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err - } - for _, id := range ids { - r, err := recordIn(ctx, tx, id) - if err != nil { - return err - } - out = append(out, r) - } - return nil - }) - return out, err -} -func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Change, error) { - if err := recordText(p.Kind, p.Text); err != nil { - return Record{}, Change{}, err - } - if (p.Kind == "goahead") != (p.GoAhead != nil) || p.Kind == "goahead" && p.Kept { - return Record{}, Change{}, invalid("goahead is required only for kind goahead, which cannot be proposed kept") - } - if p.GoAhead != nil { - // Store the end at the grant's millisecond precision, so keeping it grants the same end. - proposal := *p.GoAhead - proposal.EndsAt = instant(proposal.EndsAt.UnixMilli()) - p.GoAhead = &proposal - } - // Decisions and questions preserve the wire's unattributed done behavior. - // Go-ahead proposals check done with the grant rules inside the transaction. - asked := p.By - if p.By == "" { - if p.Kept { - p.By = "you" - } else { - p.By = "agent" - } - } - by, err := actor(p.By) - if err != nil { - return Record{}, Change{}, err - } - source, err := sourceJSON(p.Source) - if err != nil { - return Record{}, Change{}, err - } - var out Record - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if p.Kind != "goahead" { - if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, asked); err != nil { - return err - } - } - now := s.now() - var proposal any - if p.GoAhead != nil { - if err := workstreamGoAheadAllowedIn(ctx, tx, p.GoAhead.grant(p.WorkstreamID), now); err != nil { - return err - } - encoded, err := json.Marshal(p.GoAhead) - if err != nil { - return err - } - proposal = string(encoded) - } - if err := workstreamRecordLimitIn(ctx, tx, p.WorkstreamID); err != nil { - return err - } - id, err := s.newID() - if err != nil { - return err - } - at := now.UnixMilli() - state := "proposed" - var decided any - if p.Kept { - state = "kept" - decided = at - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at,goahead) VALUES (?,?,?,?,?,?,?,?,?,?)`, id, p.WorkstreamID, p.Kind, p.Text, state, by, source, at, decided, proposal); err != nil { - return err - } - if err := touch(ctx, tx, p.WorkstreamID, at); err != nil { - return err - } - out, err = recordIn(ctx, tx, id) - c = changed(p.WorkstreamID, PartRecords) - return err - }) - if err != nil { - return Record{}, Change{}, err - } - return out, c, nil -} -func (s *Store) KeepRecord(ctx context.Context, id string) (Record, Change, error) { - return s.setRecordState(ctx, id, "kept") -} -func (s *Store) DropRecord(ctx context.Context, id string) (Record, Change, error) { - return s.setRecordState(ctx, id, "dropped") -} -func (s *Store) setRecordState(ctx context.Context, id, state string) (Record, Change, error) { - var out Record - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - r, err := recordIn(ctx, tx, id) - if err != nil { - return err - } - if r.State == "superseded" { - return ErrConflict - } - if r.State == state { - out = r - return nil - } - now := s.now() - goaheadChanged, err := s.workstreamRecordGoAheadIn(ctx, tx, &r, state, now) - if err != nil { - return err - } - at := now.UnixMilli() - if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state=?,decided_at=?,goahead_id=? WHERE id=?", state, at, nullString(r.GoAheadID), id); err != nil { - return err - } - if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { - return err - } - out, err = recordIn(ctx, tx, id) - c = changed(r.WorkstreamID, PartRecords) - if goaheadChanged { - c.Parts = append(c.Parts, PartGoAheads) - } - return err - }) - if err != nil { - return Record{}, Change{}, err - } - return out, c, nil -} -func (s *Store) SupersedeRecord(ctx context.Context, id string, p RecordReplacement) (Record, Record, Change, error) { - by := p.By - if by == "" { - by = "agent" - } - by, err := actor(by) - if err != nil { - return Record{}, Record{}, Change{}, err - } - source, err := sourceJSON(p.Source) - if err != nil { - return Record{}, Record{}, Change{}, err - } - var out, old Record - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - r, err := recordIn(ctx, tx, id) - if err != nil { - return err - } - if r.Kind == "goahead" || p.Kind == "goahead" { - return invalid("goahead records cannot supersede or be superseded") - } - if r.State == "superseded" { - return ErrConflict - } - kind := p.Kind - if kind == "" { - kind = r.Kind - } - if err := recordText(kind, p.Text); err != nil { - return err - } - if err := workstreamRecordLimitIn(ctx, tx, r.WorkstreamID); err != nil { - return err - } - replacement, err := s.newID() - if err != nil { - return err - } - at := s.now().UnixMilli() - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at,supersedes) VALUES (?,?,?,?,'kept',?,?,?,?,?)`, replacement, r.WorkstreamID, kind, p.Text, by, source, at, at, id); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_records SET state='superseded',decided_at=?,superseded_by=? WHERE id=?", at, replacement, id); err != nil { - return err - } - if err := touch(ctx, tx, r.WorkstreamID, at); err != nil { - return err - } - out, err = recordIn(ctx, tx, replacement) - if err != nil { - return err - } - old, err = recordIn(ctx, tx, id) - c = changed(r.WorkstreamID, PartRecords) - return err - }) - if err != nil { - return Record{}, Record{}, Change{}, err - } - return out, old, c, nil -} - -func (s *Store) workstreamRecordGoAheadIn(ctx context.Context, tx *sql.Tx, r *Record, state string, now time.Time) (bool, error) { - if r.Kind != "goahead" { - return false, nil - } - if state == "kept" { - if r.GoAhead == nil { - return false, invalid("goahead record requires a proposal") - } - g, err := s.grantGoAheadIn(ctx, tx, r.GoAhead.grant(r.WorkstreamID), now) - if err != nil { - return false, err - } - r.GoAheadID = g.ID - return true, nil - } - if r.State == "kept" && r.GoAheadID != "" { - _, ended, err := revokeGoAheadIn(ctx, tx, r.GoAheadID, now) - return ended, err - } - return false, nil -} diff --git a/internal/workstream/records_goaheads_test.go b/internal/workstream/records_goaheads_test.go deleted file mode 100644 index 3641a63594..0000000000 --- a/internal/workstream/records_goaheads_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package workstream - -import ( - "context" - "errors" - "math" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -// Part A (g): keep/drop support undo; supersede creates a kept replacement with -// both links, and every attempt to change the old record conflicts. -func TestPartARecordLifecycle(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Records") - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Count retries?"}) - if err != nil || r.State != "proposed" || r.By != "agent" { - t.Fatalf("propose: %+v %v", r, err) - } - r, _, err = s.KeepRecord(ctx, r.ID) - if err != nil || r.State != "kept" || r.DecidedAt.IsZero() { - t.Fatalf("keep: %+v %v", r, err) - } - r, _, err = s.DropRecord(ctx, r.ID) - if err != nil || r.State != "dropped" { - t.Fatalf("drop: %+v %v", r, err) - } - r, _, err = s.KeepRecord(ctx, r.ID) - if err != nil || r.State != "kept" { - t.Fatalf("undo: %+v %v", r, err) - } - replacement, old, _, err := s.SupersedeRecord(ctx, r.ID, RecordReplacement{Kind: "decision", Text: "Retries do not count"}) - if err != nil || replacement.State != "kept" || replacement.Supersedes != r.ID || old.State != "superseded" || old.SupersededBy != replacement.ID { - t.Fatalf("supersede: %+v %+v %v", replacement, old, err) - } - if _, _, err := s.KeepRecord(ctx, r.ID); !errors.Is(err, ErrConflict) { - t.Fatalf("keep superseded: %v", err) - } - if _, _, err := s.DropRecord(ctx, r.ID); !errors.Is(err, ErrConflict) { - t.Fatalf("drop superseded: %v", err) - } - if _, _, _, err := s.SupersedeRecord(ctx, r.ID, RecordReplacement{Text: "Again"}); !errors.Is(err, ErrConflict) { - t.Fatalf("supersede superseded: %v", err) - } - kept, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "My decision", Kept: true}) - if err != nil || kept.By != "you" || kept.State != "kept" || kept.DecidedAt.IsZero() { - t.Fatalf("kept: %+v %v", kept, err) - } - all, err := s.Records(ctx, w.ID, "", "") - if err != nil || len(all) != 3 { - t.Fatalf("records: %+v %v", all, err) - } - proposed, err := s.Records(ctx, w.ID, "question", "proposed") - if err != nil || len(proposed) != 0 { - t.Fatalf("filter: %+v %v", proposed, err) - } -} - -// Part A (h): each malformed grant is refused and stores nothing. -func TestPartAGoAheadBoundsAndTargets(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Bounds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) - good := GoAheadGrant{WorkstreamID: w.ID, Scope: "job", Factory: "f", Job: 4, CapUSD: 1, EndsAt: now.Add(time.Hour)} - for _, tc := range []struct { - name string - change func(*GoAheadGrant) - }{ - {"scope", func(p *GoAheadGrant) { p.Scope = "" }}, - {"cap absent", func(p *GoAheadGrant) { p.CapUSD = 0 }}, - {"negative cap", func(p *GoAheadGrant) { p.CapUSD = -1 }}, - {"large cap", func(p *GoAheadGrant) { p.CapUSD = 10000.01 }}, - {"nan cap", func(p *GoAheadGrant) { p.CapUSD = math.NaN() }}, - {"infinite cap", func(p *GoAheadGrant) { p.CapUSD = math.Inf(1) }}, - {"end absent", func(p *GoAheadGrant) { p.EndsAt = time.Time{} }}, - {"end past", func(p *GoAheadGrant) { p.EndsAt = now.Add(-time.Second) }}, - {"end now", func(p *GoAheadGrant) { p.EndsAt = *now }}, - {"end too far", func(p *GoAheadGrant) { p.EndsAt = now.Add(90*24*time.Hour + time.Millisecond) }}, - {"factory", func(p *GoAheadGrant) { p.Factory = "" }}, - {"job", func(p *GoAheadGrant) { p.Job = 0 }}, - {"not member", func(p *GoAheadGrant) { p.Job = 5 }}, - {"wave missing", func(p *GoAheadGrant) { p.Scope = "wave"; p.Factory = ""; p.Job = 0; p.Wave = 1 }}, - {"milestone missing", func(p *GoAheadGrant) { p.Scope = "milestone"; p.Factory = ""; p.Job = 0; p.MilestoneID = "missing" }}, - } { - t.Run(tc.name, func(t *testing.T) { - isolated, _, _ := testStore(t) - stream := createStream(t, isolated, "Bounds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) - p := good - p.WorkstreamID = stream.ID - tc.change(&p) - _, c, err := isolated.GrantGoAhead(ctx, p) - if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("grant: %+v %v", c, err) - } - all, err := isolated.GoAheads(ctx, stream.ID, true) - if err != nil || len(all) != 0 { - t.Fatalf("stored invalid grant: %+v %v", all, err) - } - }) - } - good.CapUSD = 10000 - good.EndsAt = now.Add(90 * 24 * time.Hour) - if _, _, err := s.GrantGoAhead(ctx, good); err != nil { - t.Fatalf("inclusive limits: %v", err) - } -} - -// Part A (e): MarkDone ends every scope together, is idempotent, and preserves -// prior revocations. Expiry is derived by reads without mutating storage. -func TestPartAMarkDoneEndsGoAheadsAndExpiry(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "GoAheads", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "4", Factory: "f"}) - plan, _, err := s.SetPlan(ctx, w.ID, 0, []Milestone{{ID: "ship", Title: "Ship"}}, []Wave{{N: 1}}, Attribution{}) - if err != nil || plan.Version != 1 { - t.Fatal(err) - } - for _, p := range []GoAheadGrant{ - {Scope: "job", Factory: "f", Job: 4}, {Scope: "wave", Wave: 1}, {Scope: "milestone", MilestoneID: "ship"}, - } { - p.WorkstreamID = w.ID - p.CapUSD = 20 - p.EndsAt = now.Add(time.Hour) - if _, _, err := s.GrantGoAhead(ctx, p); err != nil { - t.Fatal(err) - } - } - all, _ := s.GoAheads(ctx, w.ID, true) - if _, _, err := s.RevokeGoAhead(ctx, all[0].ID); err != nil { - t.Fatal(err) - } - _, c, err := s.MarkDone(ctx, w.ID, "Shipped") - if err != nil || len(c.Parts) != 2 { - t.Fatalf("done: %+v %v", c, err) - } - active, _ := s.GoAheads(ctx, w.ID, false) - if len(active) != 0 { - t.Fatalf("active: %+v", active) - } - all, _ = s.GoAheads(ctx, w.ID, true) - ended := 0 - for _, g := range all { - if g.State != "ended" || g.EndedAt.IsZero() { - t.Fatalf("not ended: %+v", g) - } - if g.EndReason == "workstream_done" { - ended++ - } - } - if ended != 2 { - t.Fatalf("ended %d active scopes", ended) - } - _, c, err = s.MarkDone(ctx, w.ID, "Changed") - if err != nil || len(c.Parts) != 0 { - t.Fatalf("repeated done: %+v %v", c, err) - } - if _, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: w.ID, Scope: "wave", Wave: 1, CapUSD: 2, EndsAt: now.Add(time.Hour)}); !errors.Is(err, ErrConflict) { - t.Fatalf("grant on done: %v", err) - } - fresh := createStream(t, s, "Expiry", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "9", Factory: "f"}) - g, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: fresh.ID, Scope: "job", Factory: "f", Job: 9, CapUSD: 2, EndsAt: now.Add(time.Minute)}) - if err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - all, err = s.GoAheads(ctx, fresh.ID, true) - if err != nil || len(all) != 1 || all[0].EndReason != "expired" || !all[0].EndedAt.Equal(g.EndsAt) { - t.Fatalf("expiry: %+v %v", all, err) - } - active, _ = s.GoAheads(ctx, fresh.ID, false) - if len(active) != 0 { - t.Fatal("expired shown active") - } - g, c, err = s.RevokeGoAhead(ctx, g.ID) - if err != nil || g.EndReason != "expired" || len(c.Parts) != 0 { - t.Fatalf("expired revoke: %+v %+v %v", g, c, err) - } - if _, _, err := s.MarkDone(ctx, fresh.ID, ""); err != nil { - t.Fatal(err) - } - all, err = s.GoAheads(ctx, fresh.ID, true) - if err != nil || len(all) != 1 || all[0].EndReason != "expired" { - t.Fatalf("done changed expiry: %+v %v", all, err) - } -} - -// Part A (j): unknown readings stay absent and measured zero stays present. -func TestPartANumbersUnknownAndMeasuredZero(t *testing.T) { - jobs := []Job{{Factory: "f", Number: 1}, {Factory: "f", Number: 2}} - zero := 0.0 - no := false - yes := true - n := CountNumbers(jobs, nil, &zero, 3) - if n.Total != 2 || n.Merged != nil || n.NeedsYou != nil || n.SpendUSD != nil { - t.Fatalf("unknown: %+v", n) - } - facts := map[Job]JobFacts{jobs[0]: {State: "backlog", Merged: &no, CostUSD: &zero}, jobs[1]: {State: "backlog", Merged: &no, CostUSD: &zero}} - n = CountNumbers(jobs, facts, &zero, 0) - if n.Merged == nil || *n.Merged != 0 || n.NeedsYou == nil || *n.NeedsYou != 0 || n.SpendUSD == nil || *n.SpendUSD != 0 { - t.Fatalf("zero: %+v", n) - } - cost := 2.5 - agent := 1.0 - facts[jobs[0]] = JobFacts{State: "needs_you", Merged: &yes, CostUSD: &cost} - n = CountNumbers(jobs, facts, &agent, 3) - if *n.Merged != 1 || *n.NeedsYou != 4 || *n.SpendUSD != 3.5 { - t.Fatalf("numbers: %+v", n) - } - n = CountNumbers(jobs, facts, nil, 0) - if n.SpendUSD != nil || n.Merged == nil || n.NeedsYou == nil { - t.Fatalf("unknown agent: %+v", n) - } -} - -func TestPartANumbersWithPartiallyUnknownFacts(t *testing.T) { - jobs := []Job{{Factory: "f", Number: 1}, {Factory: "f", Number: 2}} - zero := 0.0 - no := false - known := JobFacts{State: "done", Merged: &no, CostUSD: &zero} - for _, facts := range []map[Job]JobFacts{ - {jobs[0]: known}, - {jobs[0]: known, jobs[1]: {State: "unknown", Merged: &no, CostUSD: &zero}}, - } { - n := CountNumbers(jobs, facts, &zero, 0) - if n.Total != 2 || n.Merged != nil || n.NeedsYou != nil || n.SpendUSD != nil { - t.Fatalf("partial unknown: %+v", n) - } - } - n := CountNumbers(jobs, map[Job]JobFacts{jobs[0]: known, jobs[1]: {State: "done", Merged: &no}}, &zero, 0) - if n.Merged == nil || n.NeedsYou == nil || n.SpendUSD != nil { - t.Fatalf("unknown cost: %+v", n) - } - n = CountNumbers(nil, nil, &zero, 2) - if n.Total != 0 || n.Merged == nil || *n.Merged != 0 || n.NeedsYou == nil || *n.NeedsYou != 2 || n.SpendUSD == nil || *n.SpendUSD != 0 { - t.Fatalf("no jobs: %+v", n) - } -} diff --git a/internal/workstream/sources.go b/internal/workstream/sources.go deleted file mode 100644 index 3d75422ca0..0000000000 --- a/internal/workstream/sources.go +++ /dev/null @@ -1,391 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "os" - "path/filepath" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const ( - SourceFile = "file" - SourceRepo = "repo" - SourceIssue = "issue" - SourceLink = "link" - SourceKept = "kept" - SourcesMax = 2000 - CreateSourcesMax = 200 - SourceTitleMax = 200 - SourcePathMax = 4096 - SourceURLMax = 2048 - SourceIssueMax = 2147483647 - SourceRefMax = 128 - SourceWhyMax = 500 -) - -type KeptRef struct { - Kind string `json:"kind"` - ID string `json:"id"` - ChatID *string `json:"chat_id,omitempty"` - Factory *string `json:"factory,omitempty"` -} - -// Pointers distinguish an absent field from a supplied field of another kind. -type SourceAdd struct { - Kind string `json:"kind"` - Path *string `json:"path,omitempty"` - Repo *string `json:"repo,omitempty"` - Number *int `json:"number,omitempty"` - URL *string `json:"url,omitempty"` - Ref *KeptRef `json:"ref,omitempty"` - Title *string `json:"title,omitempty"` - ReadByJobs bool `json:"read_by_jobs,omitempty"` -} - -type SourceItem struct { - ID string - WorkstreamID string - Kind string - Title string - Path string - Repo string - Number int - URL string - Ref *KeptRef - ReadByJobs bool - Proposed bool - Why string - AddedBy string - AddedAt time.Time - Gone bool - target string -} - -type SourcePatch struct { - Title *string - ReadByJobs *bool - Keep *bool -} - -type preparedSource struct { - SourceAdd - target string - title string -} - -const sourceItemSelect = `SELECT id,workstream_id,kind,title,coalesce(path,''),coalesce(repo,''),coalesce(number,0),coalesce(url,''),ref,read_by_jobs,proposed,coalesce(why,''),added_by,added_at,target FROM workstream_sources` - -type sourceScanner interface{ Scan(...any) error } - -func scanSourceItem(row sourceScanner) (SourceItem, error) { - var out SourceItem - var ref sql.NullString - var at int64 - err := row.Scan(&out.ID, &out.WorkstreamID, &out.Kind, &out.Title, &out.Path, &out.Repo, &out.Number, &out.URL, &ref, &out.ReadByJobs, &out.Proposed, &out.Why, &out.AddedBy, &at, &out.target) - if err != nil { - return SourceItem{}, found(err) - } - out.AddedAt = instant(at) - if ref.Valid { - err = json.Unmarshal([]byte(ref.String), &out.Ref) - } - return out, err -} - -func sourceItemIn(ctx context.Context, tx *sql.Tx, id string) (SourceItem, error) { - return scanSourceItem(tx.QueryRowContext(ctx, sourceItemSelect+" WHERE id=?", id)) -} - -func (s *Store) GetSource(ctx context.Context, id string) (SourceItem, error) { - var out SourceItem - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < workspace.WorkstreamSourcesSchemaVersion { - return ErrNotFound - } - var err error - out, err = sourceItemIn(ctx, tx, id) - return err - }) - return out, err -} - -func (s *Store) Sources(ctx context.Context, id string) ([]SourceItem, error) { - out := []SourceItem{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - if version < workspace.WorkstreamSourcesSchemaVersion { - return nil - } - rows, err := tx.QueryContext(ctx, sourceItemSelect+" WHERE workstream_id=? ORDER BY seq", id) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - item, err := scanSourceItem(rows) - if err != nil { - return err - } - out = append(out, item) - } - return rows.Err() - }) - if err != nil { - return nil, err - } - // Filesystem observations must not hold the database's read transaction. - for i := range out { - path := sourceItemPath(out[i]) - if path == "" { - continue - } - _, err := os.Stat(path) - out[i].Gone = errors.Is(err, os.ErrNotExist) - } - return out, nil -} - -func sourceItemPath(item SourceItem) string { - if item.Kind == SourceFile { - return item.Path - } - if item.Kind == SourceRepo && filepath.IsAbs(item.Repo) { - return item.Repo - } - return "" -} - -func sourceWriteAllowed(ctx context.Context, tx *sql.Tx, id string, proposed bool) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if w.DoneAt.IsZero() { - return nil - } - if proposed { - return ErrDone - } - return ErrConflict -} - -func (s *Store) AddSource(ctx context.Context, id string, p SourceAdd) (SourceItem, bool, Change, error) { - return s.addSource(ctx, id, p, false, "") -} - -func (s *Store) ProposeSource(ctx context.Context, id string, p SourceAdd, why string) (SourceItem, bool, Change, error) { - if err := ValidateSourceWhy(why); err != nil { - return SourceItem{}, false, Change{}, err - } - if p.ReadByJobs { - return SourceItem{}, false, Change{}, invalid("a proposal cannot be read by jobs") - } - return s.addSource(ctx, id, p, true, why) -} - -func (s *Store) addSource(ctx context.Context, id string, p SourceAdd, proposed bool, why string) (SourceItem, bool, Change, error) { - prepared, err := prepareSource(p) - if err != nil { - return SourceItem{}, false, Change{}, err - } - // Check references before a writer can initialize or migrate the file. - err = s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - if err := sourceWriteAllowed(ctx, tx, id, proposed); err != nil { - return err - } - return sourceKeptIn(ctx, tx, id, &prepared) - }) - if err != nil { - return SourceItem{}, false, Change{}, err - } - var out SourceItem - var already bool - var change Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if err := sourceWriteAllowed(ctx, tx, id, proposed); err != nil { - return err - } - if err := sourceKeptIn(ctx, tx, id, &prepared); err != nil { - return err - } - var wrote bool - var err error - out, already, wrote, err = s.addSourceIn(ctx, tx, id, prepared, proposed, why) - if err != nil || !wrote { - return err - } - if err := touch(ctx, tx, id, s.now().UnixMilli()); err != nil { - return err - } - change = changed(id, PartSources) - return nil - }) - if err != nil { - return SourceItem{}, false, Change{}, err - } - return out, already, change, nil -} - -func (s *Store) addSourceIn(ctx context.Context, tx *sql.Tx, id string, p preparedSource, proposed bool, why string) (SourceItem, bool, bool, error) { - existing, err := scanSourceItem(tx.QueryRowContext(ctx, sourceItemSelect+" WHERE workstream_id=? AND kind=? AND target=?", id, p.Kind, p.target)) - if err == nil { - if proposed || !existing.Proposed { - return existing, true, false, nil - } - title := existing.Title - if p.Title != nil { - title = *p.Title - } - _, err = tx.ExecContext(ctx, "UPDATE workstream_sources SET proposed=0,title=?,read_by_jobs=? WHERE id=?", title, p.ReadByJobs, existing.ID) - if err != nil { - return SourceItem{}, false, false, err - } - out, err := sourceItemIn(ctx, tx, existing.ID) - return out, true, true, err - } - if !errors.Is(err, ErrNotFound) { - return SourceItem{}, false, false, err - } - if err := sourceLimitIn(ctx, tx, id); err != nil { - return SourceItem{}, false, false, err - } - return s.insertSourceIn(ctx, tx, id, p, proposed, why) -} - -func sourceLimitIn(ctx context.Context, tx *sql.Tx, id string) error { - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_sources WHERE workstream_id=?", id).Scan(&count); err != nil { - return err - } - if count >= SourcesMax { - return ErrLimit - } - return nil -} - -func (s *Store) insertSourceIn(ctx context.Context, tx *sql.Tx, id string, p preparedSource, proposed bool, why string) (SourceItem, bool, bool, error) { - sourceID, err := s.newID() - if err != nil { - return SourceItem{}, false, false, err - } - var ref any - if p.Ref != nil { - b, err := json.Marshal(p.Ref) - if err != nil { - return SourceItem{}, false, false, err - } - ref = string(b) - } - by := "you" - if proposed { - by = "agent" - } - _, err = tx.ExecContext(ctx, `INSERT INTO workstream_sources(id,workstream_id,kind,target,title,path,repo,number,url,ref,why,read_by_jobs,proposed,added_by,added_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, sourceID, id, p.Kind, p.target, p.title, p.Path, p.Repo, p.Number, p.URL, ref, nullString(why), p.ReadByJobs, proposed, by, s.now().UnixMilli()) - if err != nil { - return SourceItem{}, false, false, err - } - out, err := sourceItemIn(ctx, tx, sourceID) - return out, false, true, err -} - -func ValidateSourcePatch(p SourcePatch) error { - if p.Title == nil && p.ReadByJobs == nil && p.Keep == nil { - return invalid("source update needs title, read_by_jobs or keep") - } - if p.Keep != nil && !*p.Keep { - return invalid("keep can only be true") - } - return sourceTitle(p.Title) -} - -func (s *Store) UpdateSource(ctx context.Context, id string, p SourcePatch) (SourceItem, Change, error) { - if err := ValidateSourcePatch(p); err != nil { - return SourceItem{}, Change{}, err - } - var out SourceItem - var change Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - item, err := sourceItemIn(ctx, tx, id) - if err != nil { - return err - } - if err := sourceWriteAllowed(ctx, tx, item.WorkstreamID, false); err != nil { - return err - } - out = item - if err := sourcePatchItem(&out, p); err != nil { - return err - } - if out.Title == item.Title && out.ReadByJobs == item.ReadByJobs && out.Proposed == item.Proposed { - return nil - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_sources SET title=?,read_by_jobs=?,proposed=? WHERE id=?", out.Title, out.ReadByJobs, out.Proposed, id); err != nil { - return err - } - if err := touch(ctx, tx, item.WorkstreamID, s.now().UnixMilli()); err != nil { - return err - } - change = changed(item.WorkstreamID, PartSources) - return nil - }) - if err != nil { - return SourceItem{}, Change{}, err - } - return out, change, nil -} - -func sourcePatchItem(item *SourceItem, p SourcePatch) error { - if p.Keep != nil { - item.Proposed = false - } - if p.ReadByJobs != nil { - if *p.ReadByJobs && item.Proposed { - return invalid("keep a proposal before enabling read_by_jobs") - } - item.ReadByJobs = *p.ReadByJobs - } - if p.Title != nil { - item.Title = *p.Title - } - return nil -} - -func (s *Store) RemoveSource(ctx context.Context, id string) (SourceItem, Change, error) { - var out SourceItem - var change Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - var err error - out, err = sourceItemIn(ctx, tx, id) - if err != nil { - return err - } - if err := sourceWriteAllowed(ctx, tx, out.WorkstreamID, false); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, "DELETE FROM workstream_sources WHERE id=?", id); err != nil { - return err - } - if err := touch(ctx, tx, out.WorkstreamID, s.now().UnixMilli()); err != nil { - return err - } - change = changed(out.WorkstreamID, PartSources) - return nil - }) - if err != nil { - return SourceItem{}, Change{}, err - } - return out, change, nil -} diff --git a/internal/workstream/sources_migration_test.go b/internal/workstream/sources_migration_test.go deleted file mode 100644 index a0c3581f3b..0000000000 --- a/internal/workstream/sources_migration_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func workstreamSourceStoredRows(t *testing.T, collections *workspace.Store) map[string][][]any { - t.Helper() - out := map[string][][]any{} - err := collections.Read(context.Background(), func(tx *sql.Tx, version int) error { - tables := []string{"collections", "memberships", "workstreams", "workstream_jobs", "workstream_pages", "workstream_page_versions", "workstream_records", "workstream_goaheads", "workstream_plans"} - if version >= workspace.WorkstreamWakeSchemaVersion { - tables = append(tables, "workstream_wakes", "workstream_wake_items") - } - for _, table := range tables { - rows, err := tx.Query("SELECT * FROM " + table + " ORDER BY 1,2") - if err != nil { - return err - } - columns, err := rows.Columns() - if err != nil { - rows.Close() - return err - } - out[table] = nil - for rows.Next() { - values := make([]any, len(columns)) - pointers := make([]any, len(columns)) - for i := range values { - pointers[i] = &values[i] - } - if err := rows.Scan(pointers...); err != nil { - rows.Close() - return err - } - out[table] = append(out[table], values) - } - err = rows.Err() - rows.Close() - if err != nil { - return err - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } - return out -} - -func workstreamSourceSchemaVersion(t *testing.T, collections *workspace.Store) int { - t.Helper() - var version int - if err := collections.Read(context.Background(), func(tx *sql.Tx, _ int) error { return tx.QueryRow("PRAGMA user_version").Scan(&version) }); err != nil { - t.Fatal(err) - } - return version -} - -// WP-12 (i): the real source.add path reads older schemas without writing, then -// migrates to schema five while retaining every earlier row except its required -// workstream updated_at touch. Every existing domain read keeps its behavior. -func TestWorkstreamSourceAddMigratesSchemaThreeAndPreservesRows(t *testing.T) { - workstreamSourceMigration(t, 3) -} - -func TestWorkstreamSourceAddMigratesSchemaFourAndPreservesRows(t *testing.T) { - workstreamSourceMigration(t, 4) -} - -func workstreamSourceMigration(t *testing.T, version int) { - t.Helper() - ctx := context.Background() - path := filepath.Join(t.TempDir(), "collections.db") - collections, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) - s := New(collections, Options{Now: func() time.Time { return now }}) - w := createStream(t, s, "Migrated sources", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}) - pages, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.SavePage(ctx, pages[0].ID, 1, json.RawMessage(`{"type":"doc","content":[]}`), Attribution{}); err != nil { - t.Fatal(err) - } - if _, _, err := s.SetPlan(ctx, w.ID, 0, []Milestone{{ID: "m", Title: "Milestone"}}, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { - t.Fatal(err) - } - if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Keep rows.", Kept: true}); err != nil { - t.Fatal(err) - } - proposal, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Allow wave.", GoAhead: &GoAheadProposal{Scope: "wave", Wave: 1, CapUSD: 1, EndsAt: now.Add(time.Hour)}}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.KeepRecord(ctx, proposal.ID); err != nil { - t.Fatal(err) - } - if err := collections.Write(ctx, func(tx *sql.Tx) error { - query := "DROP TABLE workstream_sources; DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3" - if version == 4 { - if _, err := tx.Exec(`INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,summary,cost_usd,started_at,settled_at) - VALUES ('wake',?,'morning:2026-11-03','{"kind":"morning","at":"2026-11-03T08:00:00Z"}','settled','Review this.',0.04,100,200)`, w.ID); err != nil { - return err - } - if err := workstreamSaveWakeItemIn(ctx, tx, "wake", 0, WakeItem{ID: "wake-item", Kind: "decision", State: "proposed", Text: "Keep the context.", Why: "For review."}); err != nil { - return err - } - query = "DROP TABLE workstream_sources; PRAGMA user_version=4" - } - query = "DROP TABLE workstream_suggestion_edits; DROP TABLE workstream_suggestions; " + query - _, err := tx.Exec(query) - return err - }); err != nil { - t.Fatal(err) - } - if err := collections.Close(); err != nil { - t.Fatal(err) - } - bytesBefore, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - collections, err = workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer collections.Close() - s = New(collections, Options{Now: func() time.Time { return now }}) - before := workstreamSourceStoredRows(t, collections) - items, err := s.Sources(ctx, w.ID) - if err != nil || items == nil || len(items) != 0 || workstreamSourceSchemaVersion(t, collections) != version { - t.Fatalf("schema %d sources: %v %v", version, items, err) - } - if _, err := s.Get(ctx, w.ID); err != nil { - t.Fatal(err) - } - if got, err := collections.Members(ctx, w.ID); err != nil || len(got) != 2 || got[0].Factory != "f" { - t.Fatalf("schema %d members: %+v %v", version, got, err) - } - if _, err := s.Pages(ctx, w.ID); err != nil { - t.Fatal(err) - } - if _, err := s.PageHistory(ctx, pages[0].ID, 50); err != nil { - t.Fatal(err) - } - if _, err := s.Records(ctx, w.ID, "", "", true); err != nil { - t.Fatal(err) - } - if _, err := s.GoAheads(ctx, w.ID, true); err != nil { - t.Fatal(err) - } - if _, err := s.Plan(ctx, w.ID); err != nil { - t.Fatal(err) - } - wakes, err := s.Wakes(ctx, w.ID, 20) - if err != nil || version == 3 && len(wakes) != 0 || version == 4 && (len(wakes) != 1 || len(wakes[0].Items) != 1 || wakes[0].Items[0].ID != "wake-item") { - t.Fatalf("schema %d wakes: %+v %v", version, wakes, err) - } - cost, err := s.WakeSpendByAgent(ctx, w.AgentID) - if err != nil || version == 3 && cost != 0 || version == 4 && cost != 0.04 { - t.Fatalf("schema %d wake cost: %v %v", version, cost, err) - } - if changes, err := s.ExpireWakes(ctx); err != nil || len(changes) != 0 { - t.Fatalf("settled wakes changed: %+v %v", changes, err) - } - bytesAfter, err := os.ReadFile(path) - if err != nil || string(bytesBefore) != string(bytesAfter) { - t.Fatalf("schema %d reads wrote: %v", version, err) - } - item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")}) - if err != nil || workstreamSourceSchemaVersion(t, collections) != 6 { - t.Fatalf("migration: %+v %v", item, err) - } - after := workstreamSourceStoredRows(t, collections) - if version == 3 { - for _, table := range []string{"workstream_wakes", "workstream_wake_items"} { - if len(after[table]) != 0 { - t.Fatalf("migration populated %s", table) - } - delete(after, table) - } - } - // updated_at is the tenth column in the unchanged workstreams DDL. Source writes - // advance that timestamp; migration itself changes no existing value. - if after["workstreams"][0][9].(int64) <= before["workstreams"][0][9].(int64) { - t.Fatal("source write did not touch updated_at") - } - after["workstreams"][0][9] = before["workstreams"][0][9] - if !reflect.DeepEqual(after, before) { - t.Fatalf("migration changed existing rows: before=%v after=%v", before, after) - } - items, err = s.Sources(ctx, w.ID) - if err != nil || len(items) != 1 || items[0].ID != item.ID { - t.Fatalf("migrated sources: %v %v", items, err) - } -} diff --git a/internal/workstream/sources_test.go b/internal/workstream/sources_test.go deleted file mode 100644 index 7d8b1da726..0000000000 --- a/internal/workstream/sources_test.go +++ /dev/null @@ -1,556 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func workstreamSourcePtr[T any](v T) *T { return &v } - -func workstreamSourceFile(t *testing.T) string { - t.Helper() - p := filepath.Join(t.TempDir(), "usage.md") - if err := os.WriteFile(p, []byte("Recorded usage.\n"), 0600); err != nil { - t.Fatal(err) - } - return p -} - -// WP-12 (a): all nine target forms derive titles and retain only their kind's -// fields, in added order; every duplicate returns the original row. -func TestWorkstreamSourcesEveryKind(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Kinds", workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}) - record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Use totals.\nDetails.", Kept: true}) - if err != nil { - t.Fatal(err) - } - pages, err := s.Pages(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - path := workstreamSourceFile(t) - folder := t.TempDir() - cases := []struct { - add SourceAdd - title string - }{ - {SourceAdd{Kind: SourceFile, Path: &path}, "usage.md"}, - {SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing")}, "ACME/Billing"}, - {SourceAdd{Kind: SourceRepo, Repo: &folder}, filepath.Base(folder)}, - {SourceAdd{Kind: SourceIssue, Repo: workstreamSourcePtr("acme/billing"), Number: workstreamSourcePtr(412)}, "acme/billing#412"}, - {SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("HTTPS://Docs.Example.com/x/?query=yes#part")}, "docs.example.com/x"}, - {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: record.ID}}, "Use totals."}, - {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "page", ID: pages[0].ID}}, "Overview"}, - {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "message", ID: "message", ChatID: workstreamSourcePtr("chat")}}, "Kept message"}, - {SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "factory_job", ID: "7", Factory: workstreamSourcePtr("f")}}, "Job 7"}, - } - want := []SourceItem{} - for _, tc := range cases { - item, already, _, err := s.AddSource(ctx, w.ID, tc.add) - if err != nil || already || item.Title != tc.title || item.Proposed || item.ReadByJobs || item.AddedBy != "you" || item.Why != "" { - t.Fatalf("%+v: %+v %v %v", tc.add, item, already, err) - } - duplicate := tc.add - if tc.add.Kind == SourceRepo && tc.add.Repo != &folder { - duplicate.Repo = workstreamSourcePtr("acme/billing") - } - if tc.add.Kind == SourceIssue { - duplicate.Repo = workstreamSourcePtr("ACME/Billing") - } - if tc.add.Kind == SourceLink { - duplicate.URL = workstreamSourcePtr("https://docs.example.com/x/?query=yes#part") - } - again, already, change, err := s.AddSource(ctx, w.ID, duplicate) - if err != nil || !already || !reflect.DeepEqual(item, again) || len(change.Parts) != 0 { - t.Fatalf("duplicate: %+v %v %+v %v", again, already, change, err) - } - want = append(want, item) - } - list, err := s.Sources(ctx, w.ID) - if err != nil || !reflect.DeepEqual(list, want) { - t.Fatalf("round trip: %+v %v", list, err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Remove(folder); err != nil { - t.Fatal(err) - } - list, err = s.Sources(ctx, w.ID) - if err != nil || !list[0].Gone || !list[2].Gone || list[1].Gone || list[3].Gone { - t.Fatalf("gone: %+v %v", list, err) - } -} - -// WP-12 (b), (c), (g): proposals cannot enable jobs, duplicates do not mutate -// them, and either person keep path preserves their attribution and reason. -func TestWorkstreamSourceProposalsAndKeeping(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Proposals") - add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/guide")} - proposal, already, _, err := s.ProposeSource(ctx, w.ID, add, "Read the guide.") - if err != nil || already || !proposal.Proposed || proposal.ReadByJobs || proposal.AddedBy != "agent" || proposal.Why != "Read the guide." { - t.Fatalf("proposal: %+v %v", proposal, err) - } - again, already, change, err := s.ProposeSource(ctx, w.ID, add, "Another reason.") - if err != nil || !already || !reflect.DeepEqual(again, proposal) || len(change.Parts) != 0 { - t.Fatalf("duplicate proposal: %+v %v", again, err) - } - if _, _, err := s.UpdateSource(ctx, proposal.ID, SourcePatch{ReadByJobs: workstreamSourcePtr(true)}); !errors.Is(err, ErrInvalid) { - t.Fatalf("proposal jobs: %v", err) - } - unchanged, err := s.GetSource(ctx, proposal.ID) - if err != nil || !reflect.DeepEqual(unchanged, proposal) { - t.Fatalf("refusal changed proposal: %+v %v", unchanged, err) - } - add.Title, add.ReadByJobs = workstreamSourcePtr("The guide"), true - kept, already, change, err := s.AddSource(ctx, w.ID, add) - if err != nil || !already || kept.ID != proposal.ID || kept.Title != "The guide" || kept.Proposed || !kept.ReadByJobs || kept.AddedBy != "agent" || kept.Why != proposal.Why || len(change.Parts) == 0 { - t.Fatalf("person keep: %+v %v %+v %v", kept, already, change, err) - } - if _, _, change, err := s.ProposeSource(ctx, w.ID, add, "Again."); !errors.Is(err, ErrInvalid) || len(change.Parts) != 0 { - t.Fatalf("agent cannot set jobs: %v", err) - } - add.ReadByJobs = false - if got, already, change, err := s.ProposeSource(ctx, w.ID, add, "Again."); err != nil || !already || got.Proposed || len(change.Parts) != 0 { - t.Fatalf("duplicate kept: %+v %v", got, err) - } - for _, patch := range []SourcePatch{{Keep: workstreamSourcePtr(true)}, {Title: workstreamSourcePtr(kept.Title)}, {ReadByJobs: workstreamSourcePtr(true)}} { - got, change, err := s.UpdateSource(ctx, kept.ID, patch) - if err != nil || !reflect.DeepEqual(got, kept) || len(change.Parts) != 0 { - t.Fatalf("no-op: %+v %+v %v", got, change, err) - } - } -} - -// WP-12 (b): every invalid kind field is refused before a write and leaves -// the source list and the workstream's timestamp unchanged. -func TestWorkstreamSourcesInvalidInputsStoreNothing(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Invalid sources") - path := workstreamSourceFile(t) - bad := []SourceAdd{ - {Kind: SourceFile, Path: workstreamSourcePtr("relative")}, - {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "/../usage.md")}, - {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "/./usage.md")}, - {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Dir(path) + "//usage.md")}, - {Kind: SourceFile, Path: workstreamSourcePtr(path + "/")}, - {Kind: SourceFile, Path: workstreamSourcePtr(path + "\x00")}, - {Kind: SourceFile, Path: workstreamSourcePtr(path + "\n")}, - {Kind: SourceFile, Path: workstreamSourcePtr(filepath.Join(t.TempDir(), "missing"))}, - {Kind: SourceFile, Path: workstreamSourcePtr("/" + strings.Repeat("x", 4096))}, - {Kind: SourceFile, Path: workstreamSourcePtr(string([]byte{'/', 255}))}, - {Kind: SourceFile, Path: &path, URL: workstreamSourcePtr("")}, - {Kind: SourceRepo, Repo: &path}, - {Kind: SourceIssue, Repo: &path, Number: workstreamSourcePtr(1)}, - {Kind: SourceKept, Ref: &KeptRef{Kind: "factory_job", ID: "7", Factory: workstreamSourcePtr("elsewhere")}}, - } - for _, repo := range []string{"../x", "owner/..", "-owner/x", "owner/name/extra", "relative", "owner/.", "owner/"} { - bad = append(bad, SourceAdd{Kind: SourceRepo, Repo: &repo}) - } - for _, number := range []int{0, -1, 2147483648} { - bad = append(bad, SourceAdd{Kind: SourceIssue, Repo: workstreamSourcePtr("owner/name"), Number: &number}) - } - for _, url := range []string{"file:///etc/passwd", "javascript:alert(1)", "ftp://h/x", "//h/x", "https:///x", "https://user:pw@h/x", "https://h/x y", "https://h/x\n", "https://h/" + strings.Repeat("x", 2039)} { - bad = append(bad, SourceAdd{Kind: SourceLink, URL: &url}) - } - for _, title := range []string{"", strings.Repeat("x", 201), "two\nlines", " untrimmed ", "two\u2028lines"} { - bad = append(bad, SourceAdd{Kind: SourceFile, Path: &path, Title: &title}) - } - for i, add := range bad { - _, _, change, err := s.AddSource(ctx, w.ID, add) - if !errors.Is(err, ErrInvalid) || len(change.Parts) != 0 { - t.Fatalf("invalid %d: %v %+v", i, err, change) - } - } - for _, ref := range []*KeptRef{{Kind: "record", ID: "missing"}, {Kind: "page", ID: "missing"}} { - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrNotFound) { - t.Fatalf("unknown kept: %v", err) - } - } - list, err := s.Sources(ctx, w.ID) - after, getErr := s.Get(ctx, w.ID) - if err != nil || getErr != nil || len(list) != 0 || !after.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("refused sources changed state: %+v %v %v", list, err, getErr) - } -} - -// WP-12 (b), (d): kept rows must belong here and removal never deletes a -// record or page. Deleted pages cannot be kept. -func TestWorkstreamSourcesKeptOwnershipAndRemoval(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Kept") - other := createStream(t, s, "Other") - record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Keep?"}) - if err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes"}) - if err != nil { - t.Fatal(err) - } - for _, ref := range []*KeptRef{{Kind: "record", ID: record.ID}, {Kind: "page", ID: page.ID}} { - if _, _, _, err := s.AddSource(ctx, other.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrNotFound) { - t.Fatalf("foreign ref: %v", err) - } - item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.RemoveSource(ctx, item.ID); err != nil { - t.Fatal(err) - } - } - if records, err := s.Records(ctx, w.ID, "", ""); err != nil || len(records) != 1 { - t.Fatalf("removed record: %v %v", records, err) - } - if _, err := s.Page(ctx, page.ID, false); err != nil { - t.Fatal(err) - } - if _, _, err := s.DeletePage(ctx, page.ID); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "page", ID: page.ID}}); !errors.Is(err, ErrNotFound) { - t.Fatalf("deleted page: %v", err) - } -} - -// WP-12 (e), (f): create validates all sources atomically, preserves why, -// folds duplicate targets, and ignores sources on a client retry. -func TestWorkstreamCreateSourcesAreAtomic(t *testing.T) { - s, collections, _ := testStore(t) - ctx := context.Background() - path := workstreamSourceFile(t) - p := Create{Name: "Created sources", AgentID: "agent", ClientID: "client", Why: " Exactly as sent.\n", Sources: []SourceAdd{{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing"), ReadByJobs: true}, {Kind: SourceFile, Path: &path}, {Kind: SourceRepo, Repo: workstreamSourcePtr("acme/billing")}}} - w, _, err := s.Create(ctx, p) - if err != nil || w.Why != p.Why { - t.Fatalf("create: %+v %v", w, err) - } - items, err := s.Sources(ctx, w.ID) - if err != nil || len(items) != 2 || !items[0].ReadByJobs || items[1].ReadByJobs { - t.Fatalf("create sources: %+v %v", items, err) - } - p.Sources = []SourceAdd{{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/new")}} - again, change, err := s.Create(ctx, p) - if err != nil || again.ID != w.ID || len(change.Parts) != 0 { - t.Fatalf("retry: %+v %v", again, err) - } - items, err = s.Sources(ctx, w.ID) - if err != nil || len(items) != 2 { - t.Fatalf("retry added sources: %v %v", items, err) - } - for _, bad := range []SourceAdd{{Kind: SourceFile, Path: workstreamSourcePtr("relative")}, {Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: "missing"}}} { - p.Name, p.ClientID, p.Sources = "Free name", "", []SourceAdd{{Kind: SourceFile, Path: &path}, bad} - if _, _, err := s.Create(ctx, p); !errors.Is(err, ErrInvalid) { - t.Fatalf("bad create: %v", err) - } - } - rows, err := collections.Collections(ctx) - if err != nil || len(rows) != 1 { - t.Fatalf("partial create: %v %v", rows, err) - } - p.Sources = nil - if _, _, err := s.Create(ctx, p); err != nil { - t.Fatalf("name was taken: %v", err) - } - if err := collections.Add(ctx, w.ID, workspace.Ref{Kind: workspace.ArtifactKind, ID: path}); err != nil { - t.Fatal(err) - } - items, err = s.Sources(ctx, w.ID) - if err != nil || len(items) != 2 { - t.Fatalf("artifact became a source: %v %v", items, err) - } -} - -// WP-12 Fix 1 Major 2 (e): an identical client retry returns its first row -// even after an initial source disappears; a new client still validates it. -func TestWorkstreamMajor2CreateRetryE(t *testing.T) { - s, collections, _ := testStore(t) - ctx := context.Background() - path := workstreamSourceFile(t) - p := Create{Name: "Retry with removed source", AgentID: "agent", ClientID: "retry-removed", Sources: []SourceAdd{{Kind: SourceFile, Path: &path}}} - w, _, err := s.Create(ctx, p) - if err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - before, err := s.Sources(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - again, change, err := s.Create(ctx, p) - if err != nil || !reflect.DeepEqual(again, w) || change.ID != "" || len(change.Parts) != 0 { - t.Fatalf("retry: %+v %+v %v", again, change, err) - } - after, err := s.Sources(ctx, w.ID) - if err != nil || len(after) != 1 || !reflect.DeepEqual(after, before) { - t.Fatalf("retry changed sources: %+v %v", after, err) - } - p.ClientID, p.Name = "new-client", "New client name" - if _, _, err := s.Create(ctx, p); !errors.Is(err, ErrInvalid) { - t.Fatalf("new client bypassed validation: %v", err) - } - rows, err := collections.Collections(ctx) - if err != nil || len(rows) != 1 { - t.Fatalf("new client wrote rows: %+v %v", rows, err) - } - p.Sources = nil - if _, _, err := s.Create(ctx, p); err != nil { - t.Fatalf("refusal reserved name or client: %v", err) - } -} - -// WP-12 Fix 1 Minor 1 (a), (b): the normalized URL obeys the byte bound and -// a fitting multibyte URL remains an idempotent target when re-added as returned. -func TestWorkstreamMinor1LinkBoundAB(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Normalized URL bounds") - tooLong := "https://h/" + strings.Repeat("é", 1019) - if len(tooLong) != 2048 { - t.Fatal("fixture must be exactly 2048 input bytes") - } - _, _, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &tooLong}) - if !errors.Is(err, ErrInvalid) || change.ID != "" || len(change.Parts) != 0 { - t.Fatalf("expanded URL accepted: %+v %v", change, err) - } - items, err := s.Sources(ctx, w.ID) - if err != nil || len(items) != 0 { - t.Fatalf("invalid URL stored: %+v %v", items, err) - } - unchanged, err := s.Get(ctx, w.ID) - if err != nil || !unchanged.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("invalid URL touched workstream: %+v %v", unchanged, err) - } - fits := "https://h/" + strings.Repeat("é", 339) + "abcd" - want := "https://h/" + strings.Repeat("%C3%A9", 339) + "abcd" - item, already, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &fits}) - if err != nil || already || item.URL != want || len(item.URL) != 2048 { - t.Fatalf("fitting URL: %+v %v %v", item, already, err) - } - again, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: &item.URL}) - if err != nil || !already || !reflect.DeepEqual(again, item) || change.ID != "" || len(change.Parts) != 0 { - t.Fatalf("returned URL retry: %+v %v %+v %v", again, already, change, err) - } -} - -// WP-12 (c): the Read by jobs toggle persists through reopening the file. -func TestWorkstreamSourcesToggleSurvivesReopen(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "collections.db") - collections, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - s := New(collections, Options{}) - w := createStream(t, s, "Persistence") - item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")}) - if err != nil { - t.Fatal(err) - } - for _, flag := range []bool{true, false, true} { - if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{ReadByJobs: &flag}); err != nil { - t.Fatal(err) - } - list, err := s.Sources(ctx, w.ID) - if err != nil || len(list) != 1 || list[0].ReadByJobs != flag { - t.Fatalf("toggle: %v %v", list, err) - } - } - if err := collections.Close(); err != nil { - t.Fatal(err) - } - collections, err = workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer collections.Close() - s = New(collections, Options{}) - list, err := s.Sources(ctx, w.ID) - if err != nil || len(list) != 1 || !list[0].ReadByJobs { - t.Fatalf("reopen: %+v %v", list, err) - } -} - -// WP-12 (h): done is read-only for every source write, and reopen restores them. -func TestWorkstreamSourcesDoneAndReopen(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Done sources") - add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com")} - item, _, _, err := s.AddSource(ctx, w.ID, add) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.AddSource(ctx, w.ID, add); !errors.Is(err, ErrConflict) { - t.Fatalf("add done: %v", err) - } - if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("New")}); !errors.Is(err, ErrConflict) { - t.Fatalf("update done: %v", err) - } - if _, _, err := s.RemoveSource(ctx, item.ID); !errors.Is(err, ErrConflict) { - t.Fatalf("remove done: %v", err) - } - if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); !errors.Is(err, ErrDone) { - t.Fatalf("propose done: %v", err) - } - list, err := s.Sources(ctx, w.ID) - if err != nil || len(list) != 1 || !reflect.DeepEqual(item, list[0]) { - t.Fatalf("done list: %v %v", list, err) - } - if _, _, err := s.Reopen(ctx, w.ID); err != nil { - t.Fatal(err) - } - if _, _, err := s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("New")}); err != nil { - t.Fatal(err) - } - if _, _, err := s.RemoveSource(ctx, item.ID); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); err != nil { - t.Fatal(err) - } -} - -// WP-12 (b): the 2,001st source refuses atomically, while duplicates still work. -func TestWorkstreamSourcesLimit(t *testing.T) { - s, collections, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Source limit") - if err := collections.Write(ctx, func(tx *sql.Tx) error { - statement, err := tx.Prepare("INSERT INTO workstream_sources(id,workstream_id,kind,target,title,url,proposed,added_by,added_at) VALUES (?,?,'link',?,'Example',?,1,'agent',100)") - if err != nil { - return err - } - defer statement.Close() - for i := 0; i < SourcesMax; i++ { - url := fmt.Sprintf("https://example.com/%d", i) - if _, err := statement.Exec(fmt.Sprintf("limit-%d", i), w.ID, url, url); err != nil { - return err - } - } - return nil - }); err != nil { - t.Fatal(err) - } - add := SourceAdd{Kind: SourceLink, URL: workstreamSourcePtr("https://example.com/extra")} - if _, _, _, err := s.AddSource(ctx, w.ID, add); !errors.Is(err, ErrLimit) { - t.Fatalf("source limit: %v", err) - } - if _, _, _, err := s.ProposeSource(ctx, w.ID, add, "Read it."); !errors.Is(err, ErrLimit) { - t.Fatalf("proposal limit: %v", err) - } - add.URL = workstreamSourcePtr("https://example.com/0") - item, already, _, err := s.AddSource(ctx, w.ID, add) - if err != nil || !already || item.Proposed { - t.Fatalf("keep at limit: %+v %v", item, err) - } -} - -// WP-12 (a), (b): derived titles cut at a UTF-8 boundary, supplied titles and -// file base names preserve internal spaces, and kept ids enforce byte bounds. -func TestWorkstreamSourceTitlesAndKeptRefBounds(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Source text bounds") - record, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: strings.Repeat("界", 67) + "\nDetails."}) - if err != nil { - t.Fatal(err) - } - item, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "record", ID: record.ID}}) - if err != nil || item.Title != strings.Repeat("界", 66) || !utf8.ValidString(item.Title) { - t.Fatalf("derived cut: %+v %v", item, err) - } - path := filepath.Join(t.TempDir(), "two spaces.md") - if err := os.WriteFile(path, []byte("Words"), 0600); err != nil { - t.Fatal(err) - } - item, _, _, err = s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceFile, Path: &path}) - if err != nil || item.Title != "two spaces.md" { - t.Fatalf("base title: %+v %v", item, err) - } - item, _, err = s.UpdateSource(ctx, item.ID, SourcePatch{Title: workstreamSourcePtr("Two spaces")}) - if err != nil || item.Title != "Two spaces" { - t.Fatalf("given title: %+v %v", item, err) - } - for _, length := range []int{0, 129} { - ref := &KeptRef{Kind: "message", ID: strings.Repeat("x", length), ChatID: workstreamSourcePtr("chat")} - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrInvalid) { - t.Fatalf("kept id %d: %v", length, err) - } - ref.ID = "message" - ref.ChatID = workstreamSourcePtr(strings.Repeat("x", length)) - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: ref}); !errors.Is(err, ErrInvalid) { - t.Fatalf("kept chat %d: %v", length, err) - } - } - if _, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceKept, Ref: &KeptRef{Kind: "message", ID: strings.Repeat("x", 128), ChatID: workstreamSourcePtr(strings.Repeat("c", 128))}}); err != nil { - t.Fatal(err) - } -} - -// WP-12 (a), (c), (d), (f): sources retain identity, flags and order, without -// becoming members or changing the things they point at. -func TestWorkstreamSourcesRoundTripAndRemove(t *testing.T) { - s, collections, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Sources") - before, err := collections.Members(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - path := workstreamSourceFile(t) - file, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceFile, Path: &path, ReadByJobs: true}) - if err != nil || already || file.Title != "usage.md" || file.AddedBy != "you" || file.Proposed || !file.ReadByJobs || len(change.Parts) == 0 { - t.Fatalf("file: %+v %v %+v %v", file, already, change, err) - } - repo, _, _, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("ACME/Billing")}) - if err != nil { - t.Fatal(err) - } - duplicate, already, change, err := s.AddSource(ctx, w.ID, SourceAdd{Kind: SourceRepo, Repo: workstreamSourcePtr("acme/billing")}) - if err != nil || !already || duplicate.ID != repo.ID || len(change.Parts) != 0 { - t.Fatalf("duplicate: %+v %v %+v %v", duplicate, already, change, err) - } - for _, flag := range []bool{false, true} { - if _, _, err := s.UpdateSource(ctx, file.ID, SourcePatch{ReadByJobs: &flag}); err != nil { - t.Fatal(err) - } - list, err := s.Sources(ctx, w.ID) - if err != nil || len(list) != 2 || list[0].ReadByJobs != flag || list[1].ID != repo.ID { - t.Fatalf("list: %+v %v", list, err) - } - } - after, err := collections.Members(ctx, w.ID) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("sources changed members: %+v %v", after, err) - } - removed, _, err := s.RemoveSource(ctx, file.ID) - if err != nil || !reflect.DeepEqual(removed, file) { - t.Fatalf("remove: %+v %v", removed, err) - } - data, err := os.ReadFile(path) - if err != nil || string(data) != "Recorded usage.\n" { - t.Fatalf("source removal changed file: %q %v", data, err) - } -} diff --git a/internal/workstream/sources_validation.go b/internal/workstream/sources_validation.go deleted file mode 100644 index 484e0cad90..0000000000 --- a/internal/workstream/sources_validation.go +++ /dev/null @@ -1,297 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "net/url" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "unicode" - "unicode/utf8" -) - -var sourceRepoPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$`) - -// ValidateSourceAdd checks kind fields and local existence without writing or reading contents. -func ValidateSourceAdd(p SourceAdd) error { - _, err := prepareSource(p) - return err -} - -func ValidateCreateSources(sources []SourceAdd) error { - _, err := prepareCreateSources(sources) - return err -} - -func prepareCreateSources(sources []SourceAdd) ([]preparedSource, error) { - if len(sources) > CreateSourcesMax { - return nil, invalid("create takes at most 200 sources") - } - out := make([]preparedSource, 0, len(sources)) - for _, p := range sources { - if p.Kind == SourceKept { - return nil, invalid("create cannot keep a source") - } - prepared, err := prepareSource(p) - if err != nil { - return nil, err - } - out = append(out, prepared) - } - return out, nil -} - -func sourceTitle(title *string) error { - if title == nil { - return nil - } - if len(*title) == 0 || !lineWithin(*title, SourceTitleMax) || strings.TrimSpace(*title) != *title || strings.TrimSpace(*title) == "" || strings.ContainsAny(*title, "\u2028\u2029") { - return invalid("source title must be 1–200 bytes of trimmed single-line text") - } - return nil -} - -func ValidateSourceWhy(why string) error { - if len(why) == 0 || !textWithin(why, SourceWhyMax) { - return invalid("a source proposal needs why of 1–500 bytes") - } - return nil -} - -func sourcePath(path string, folder bool) error { - if len(path) == 0 || !lineWithin(path, SourcePathMax) || !filepath.IsAbs(path) || filepath.Clean(path) != path { - return invalid("source path must be an existing clean absolute path of 1–4096 bytes") - } - info, err := os.Stat(path) - if err != nil { - return invalid("nothing readable exists at the source path") - } - if folder && !info.IsDir() { - return invalid("a local repo must be a folder") - } - return nil -} - -func sourceRepoName(repo string) bool { - if !sourceRepoPattern.MatchString(repo) { - return false - } - _, name, _ := strings.Cut(repo, "/") - return name != "." && name != ".." -} - -func sourceURL(raw string) (string, string, error) { - if len(raw) == 0 || len(raw) > SourceURLMax || !utf8.ValidString(raw) || strings.IndexFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { - return "", "", invalid("source URL must be 1–2048 bytes without whitespace or controls") - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" || u.User != nil || !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { - return "", "", invalid("source URL needs an http or https scheme, a host and no credentials") - } - u.Scheme, u.Host = strings.ToLower(u.Scheme), strings.ToLower(u.Host) - stored := u.String() - if len(stored) > SourceURLMax { - return "", "", invalid("normalized source URL must be at most " + strconv.Itoa(SourceURLMax) + " bytes") - } - return stored, strings.TrimRight(u.Host+u.Path, "/"), nil -} - -func sourceFields(p SourceAdd) error { - if p.Path != nil && p.Kind != SourceFile || p.Repo != nil && p.Kind != SourceRepo && p.Kind != SourceIssue || p.Number != nil && p.Kind != SourceIssue || p.URL != nil && p.Kind != SourceLink || p.Ref != nil && p.Kind != SourceKept { - return invalid("source has a field used by another kind") - } - return sourceTitle(p.Title) -} - -func prepareSource(p SourceAdd) (preparedSource, error) { - out := preparedSource{SourceAdd: p} - if err := sourceFields(p); err != nil { - return out, err - } - var err error - switch p.Kind { - case SourceFile: - err = prepareFileSource(&out) - case SourceRepo: - err = prepareRepoSource(&out) - case SourceIssue: - err = prepareIssueSource(&out) - case SourceLink: - err = prepareLinkSource(&out) - case SourceKept: - err = prepareKeptSource(&out) - default: - err = invalid("unknown source kind") - } - if p.Title != nil { - out.title = *p.Title - } else { - out.title = sourceDerivedTitle(out.title) - } - return out, err -} - -func prepareFileSource(p *preparedSource) error { - if p.Path == nil { - return invalid("file source needs path") - } - if err := sourcePath(*p.Path, false); err != nil { - return err - } - p.target, p.title = *p.Path, filepath.Base(*p.Path) - return nil -} - -func prepareRepoSource(p *preparedSource) error { - if p.Repo == nil { - return invalid("repo source needs repo") - } - p.target, p.title = strings.ToLower(*p.Repo), *p.Repo - if sourceRepoName(*p.Repo) { - return nil - } - if err := sourcePath(*p.Repo, true); err != nil { - return err - } - p.target, p.title = *p.Repo, filepath.Base(*p.Repo) - return nil -} - -func prepareIssueSource(p *preparedSource) error { - if p.Repo == nil || !sourceRepoName(*p.Repo) || p.Number == nil || *p.Number < 1 || *p.Number > SourceIssueMax { - return invalid("issue needs owner/name and number 1–2147483647") - } - p.title = *p.Repo + "#" + strconv.Itoa(*p.Number) - p.target = strings.ToLower(p.title) - return nil -} - -func prepareLinkSource(p *preparedSource) error { - if p.URL == nil { - return invalid("link source needs url") - } - stored, title, err := sourceURL(*p.URL) - if err != nil { - return err - } - p.URL, p.target, p.title = &stored, stored, title - return nil -} - -func prepareKeptSource(p *preparedSource) error { - if err := sourceKeptRef(p.Ref); err != nil { - return err - } - b, err := json.Marshal(p.Ref) - p.target = string(b) - return err -} - -func sourceRefID(id string) bool { - return len(id) > 0 && len(id) <= SourceRefMax && utf8.ValidString(id) -} - -func sourceKeptRef(ref *KeptRef) error { - if ref == nil || !sourceRefID(ref.ID) { - return invalid("kept source needs a ref with an id of 1–128 bytes") - } - if ref.Kind != "message" && ref.ChatID != nil || ref.Kind != "factory_job" && ref.Factory != nil { - return invalid("kept ref has fields used by another kind") - } - switch ref.Kind { - case "record", "page": - case "message": - if ref.ChatID == nil || !sourceRefID(*ref.ChatID) { - return invalid("kept message needs chat_id of 1–128 bytes") - } - case "factory_job": - if ref.Factory == nil || !sourceRefID(*ref.Factory) { - return invalid("kept job needs factory of 1–128 bytes") - } - if _, err := sourceJobNumber(ref.ID); err != nil { - return err - } - default: - return invalid("unknown kept ref kind") - } - return nil -} - -func sourceJobNumber(id string) (int, error) { - if strings.IndexFunc(id, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { - return 0, invalid("kept job needs a positive decimal job number") - } - n, err := strconv.Atoi(id) - if err != nil || n < 1 { - return 0, invalid("kept job needs a positive decimal job number") - } - return n, nil -} - -func sourceKeptIn(ctx context.Context, tx *sql.Tx, id string, p *preparedSource) error { - if p.Kind != SourceKept { - return nil - } - var title string - switch p.Ref.Kind { - case "record": - if err := tx.QueryRowContext(ctx, "SELECT text FROM workstream_records WHERE id=? AND workstream_id=?", p.Ref.ID, id).Scan(&title); err != nil { - return found(err) - } - title, _, _ = strings.Cut(title, "\n") - case "page": - if err := tx.QueryRowContext(ctx, "SELECT title FROM workstream_pages WHERE id=? AND workstream_id=? AND deleted_at IS NULL", p.Ref.ID, id).Scan(&title); err != nil { - return found(err) - } - case "message": - title = "Kept message" - case "factory_job": - if err := sourceKeptJobIn(ctx, tx, id, p.Ref); err != nil { - return err - } - title = "Job " + p.Ref.ID - } - if p.Title == nil { - p.title = sourceDerivedTitle(title) - } - return nil -} - -func sourceKeptJobIn(ctx context.Context, tx *sql.Tx, id string, ref *KeptRef) error { - number, err := sourceJobNumber(ref.ID) - if err != nil { - return err - } - var member bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_jobs WHERE workstream_id=? AND factory=? AND job=?)", id, *ref.Factory, strconv.Itoa(number)).Scan(&member); err != nil { - return err - } - if !member { - return invalid("kept job is not a member of this workstream") - } - return nil -} - -func sourceDerivedTitle(title string) string { - title = strings.TrimSpace(strings.Map(func(r rune) rune { - if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' { - return ' ' - } - return r - }, title)) - if title == "" { - title = "Source" - } - if len(title) <= SourceTitleMax { - return title - } - end := SourceTitleMax - for end > 0 && !utf8.RuneStart(title[end]) { - end-- - } - return strings.TrimSpace(title[:end]) -} diff --git a/internal/workstream/store.go b/internal/workstream/store.go deleted file mode 100644 index ab9f0104fe..0000000000 --- a/internal/workstream/store.go +++ /dev/null @@ -1,565 +0,0 @@ -package workstream - -import ( - "context" - "crypto/rand" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math" - "strconv" - "strings" - "time" - "unicode" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -type Options struct { - Now func() time.Time - NewID func() (string, error) -} -type Store struct { - workspace *workspace.Store - now func() time.Time - newID func() (string, error) -} - -func New(w *workspace.Store, opts Options) *Store { - if opts.Now == nil { - opts.Now = time.Now - } - if opts.NewID == nil { - opts.NewID = func() (string, error) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return "", err - } - return hex.EncodeToString(b[:]), nil - } - } - return &Store{workspace: w, now: opts.Now, newID: opts.NewID} -} - -func instant(ms int64) time.Time { return time.UnixMilli(ms).UTC() } -func optionalInstant(ms sql.NullInt64) time.Time { - if !ms.Valid { - return time.Time{} - } - return instant(ms.Int64) -} -func nullString(s string) any { - if s == "" { - return nil - } - return s -} -func nullInt(n int) any { - if n == 0 { - return nil - } - return n -} -func invalid(message string) error { return fmt.Errorf("%w: %s", ErrInvalid, message) } -func textWithin(s string, limit int) bool { - return len(s) <= limit && utf8.ValidString(s) && - strings.IndexFunc(s, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\t' }) < 0 -} -func lineWithin(s string, limit int) bool { - return len(s) <= limit && utf8.ValidString(s) && strings.IndexFunc(s, unicode.IsControl) < 0 -} -func actor(by string) (string, error) { - if by == "" { - by = "you" - } - if by != "you" && by != "agent" { - return "", invalid("actor must be you or agent") - } - return by, nil -} -func validTitle(title string) error { - if strings.TrimSpace(title) == "" || !lineWithin(title, 200) { - return invalid("title must be 1–200 bytes") - } - return nil -} -func sourceJSON(source *Source) (any, error) { - if source == nil { - return nil, nil - } - if source.ID == "" || !textWithin(source.ID, 4096) || !lineWithin(source.Label, 200) { - return nil, invalid("source needs an id and a label of at most 200 bytes") - } - if source.Kind != "message" && source.ChatID != "" || source.Kind != "factory_job" && source.Factory != "" { - return nil, invalid("source chat_id is only for message and factory is only for factory_job") - } - if source.ChatID != "" { - if err := (workspace.Ref{Kind: workspace.ConversationKind, ID: source.ChatID}).Validate(); err != nil { - return nil, err - } - } - switch source.Kind { - case "chat", "page", "url": - case "message": - if source.ChatID == "" { - return nil, invalid("message source needs chat_id") - } - case "factory_job": - if err := (workspace.Ref{Kind: workspace.FactoryJobKind, ID: source.ID, Factory: source.Factory}).Validate(); err != nil { - return nil, err - } - default: - return nil, invalid("unknown source kind") - } - b, err := json.Marshal(source) - return string(b), err -} -func readSource(raw sql.NullString) (*Source, error) { - if !raw.Valid { - return nil, nil - } - var s Source - err := json.Unmarshal([]byte(raw.String), &s) - return &s, err -} -func found(err error) error { - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - return err -} -func touch(ctx context.Context, tx *sql.Tx, id string, at int64) error { - _, err := tx.ExecContext(ctx, "UPDATE workstreams SET updated_at=max(updated_at+1,?) WHERE id=?", at, id) - return err -} -func changed(id string, part ChangedPart) Change { - c := Change{ID: id, Parts: []ChangedPart{PartWorkstream}} - if part != PartWorkstream { - c.Parts = append(c.Parts, part) - } - return c -} - -const workstreamSelect = `SELECT w.id,c.name,w.why,w.origin_chat_id,w.agent_id,w.autonomy,w.wake_morning,w.wake_related_issues, - w.budget_usd,w.created_at,w.updated_at,w.started_at,w.done_at,w.how_it_went,w.client_id, - (SELECT count(*) FROM workstream_records r WHERE r.workstream_id=w.id AND r.state='proposed') - FROM workstreams w JOIN collections c ON c.id=w.id WHERE w.id=?` - -func getIn(ctx context.Context, tx *sql.Tx, id string) (Workstream, error) { - var w Workstream - var created, updated int64 - var start, done sql.NullInt64 - var budget sql.NullFloat64 - err := tx.QueryRowContext(ctx, workstreamSelect, id).Scan(&w.ID, &w.Name, &w.Why, &w.OriginChatID, &w.AgentID, &w.Autonomy, &w.Wake.Morning, &w.Wake.RelatedIssues, &budget, &created, &updated, &start, &done, &w.HowItWent, &w.ClientID, &w.Proposed) - if err != nil { - return w, found(err) - } - w.CreatedAt, w.UpdatedAt, w.StartedAt, w.DoneAt = instant(created), instant(updated), optionalInstant(start), optionalInstant(done) - if budget.Valid { - w.BudgetUSD = &budget.Float64 - } - w.Stage = Stage(w.DoneAt, w.StartedAt, nil) - rows, err := tx.QueryContext(ctx, "SELECT kind,ref_id,session_id,factory FROM memberships WHERE collection_id=? ORDER BY seq", id) - if err != nil { - return w, err - } - defer rows.Close() - w.Members = make([]workspace.Ref, 0) - for rows.Next() { - var ref workspace.Ref - if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID, &ref.Factory); err != nil { - return w, err - } - w.Members = append(w.Members, ref) - if ref.Kind == workspace.FactoryJobKind { - w.Total++ - } - } - return w, rows.Err() -} - -func (s *Store) Get(ctx context.Context, id string) (Workstream, error) { - var out Workstream - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - var err error - out, err = getIn(ctx, tx, id) - return err - }) - return out, err -} - -// ByClientID lets an adapter recognize a retry before creating its agent. -// Create checks it again inside the writer transaction to settle races. -func (s *Store) ByClientID(ctx context.Context, clientID string) (Workstream, error) { - var out Workstream - if clientID == "" { - return out, ErrNotFound - } - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - var id string - if err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", clientID).Scan(&id); err != nil { - return found(err) - } - var err error - out, err = getIn(ctx, tx, id) - return err - }) - return out, err -} - -func (s *Store) List(ctx context.Context) ([]Workstream, error) { - out := make([]Workstream, 0) - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return nil - } - rows, err := tx.QueryContext(ctx, "SELECT id FROM workstreams ORDER BY updated_at DESC,id") - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err - } - for _, id := range ids { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - out = append(out, w) - } - return nil - }) - return out, err -} - -func (s *Store) Create(ctx context.Context, p Create) (Workstream, Change, error) { - p.Name = strings.TrimSpace(p.Name) - if err := workspace.ValidateName(p.Name); err != nil { - return Workstream{}, Change{}, err - } - if !textWithin(p.Why, 4000) || !lineWithin(p.ClientID, 128) || len(p.Refs) > 200 { - return Workstream{}, Change{}, invalid("create exceeds workstream bounds") - } - // A retry does not insert sources, so their paths need no revalidation. - if p.ClientID != "" { - w, err := s.ByClientID(ctx, p.ClientID) - if err == nil { - return w, Change{}, nil - } - if !errors.Is(err, ErrNotFound) { - return Workstream{}, Change{}, err - } - } - sources, err := prepareCreateSources(p.Sources) - if err != nil { - return Workstream{}, Change{}, err - } - refs := append([]workspace.Ref(nil), p.Refs...) - refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.AgentID}) - if p.OriginChatID != "" { - refs = append(refs, workspace.Ref{Kind: workspace.ConversationKind, ID: p.OriginChatID}) - } - additions := make([]workspace.Addition, 0, len(refs)) - for _, ref := range refs { - if err := ref.Validate(); err != nil { - return Workstream{}, Change{}, err - } - additions = append(additions, workspace.Addition{Ref: ref}) - } - var out Workstream - var c Change - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if p.ClientID != "" { - var id string - err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE client_id=?", p.ClientID).Scan(&id) - if err == nil { - out, err = getIn(ctx, tx, id) - return err - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - } - id, err := s.newID() - if err != nil { - return err - } - at := s.now().UnixMilli() - if err := workspace.CreateIn(ctx, tx, id, p.Name, at); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstreams(id,why,origin_chat_id,agent_id,created_at,updated_at,client_id) VALUES (?,?,?,?,?,?,?)`, id, p.Why, p.OriginChatID, p.AgentID, at, at, p.ClientID); err != nil { - return err - } - if _, _, err := workspace.AddRefsIn(ctx, tx, id, additions, at); err != nil { - return err - } - pageID, err := s.newID() - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc,overview) VALUES (?,?,'Overview','you',0,1,?,'you',?,1)`, pageID, id, at, emptyDoc); err != nil { - return err - } - if err := saveVersion(ctx, tx, pageID); err != nil { - return err - } - for _, source := range sources { - if _, _, _, err := s.addSourceIn(ctx, tx, id, source, false, ""); err != nil { - return err - } - } - out, err = getIn(ctx, tx, id) - c = Change{ID: id, Parts: []ChangedPart{PartWorkstream, PartPages, PartJobs}} - if len(sources) > 0 { - c.Parts = append(c.Parts, PartSources) - } - return err - }) - if err != nil { - return Workstream{}, Change{}, err - } - return out, c, nil -} - -func (s *Store) Update(ctx context.Context, id string, p Patch) (Workstream, Change, error) { - if p.Name != nil { - name := strings.TrimSpace(*p.Name) - p.Name = &name - if err := workspace.ValidateName(name); err != nil { - return Workstream{}, Change{}, err - } - } - if p.Why != nil && !textWithin(*p.Why, 4000) { - return Workstream{}, Change{}, invalid("why exceeds 4000 bytes") - } - if p.Autonomy != nil && *p.Autonomy != "propose" && *p.Autonomy != "small" && *p.Autonomy != "off" { - return Workstream{}, Change{}, invalid("unknown autonomy") - } - if p.BudgetUSD != nil && (math.IsNaN(*p.BudgetUSD) || math.IsInf(*p.BudgetUSD, 0) || *p.BudgetUSD < 0) { - return Workstream{}, Change{}, invalid("budget must be finite and non-negative") - } - var out Workstream - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - before := w - if p.Name != nil { - w.Name = *p.Name - } - if p.Why != nil { - w.Why = *p.Why - } - if p.Autonomy != nil { - w.Autonomy = *p.Autonomy - } - if p.Wake != nil { - w.Wake = *p.Wake - } - if p.BudgetUSD != nil { - w.BudgetUSD = p.BudgetUSD - if *p.BudgetUSD == 0 { - w.BudgetUSD = nil - } - } - sameBudget := (w.BudgetUSD == nil && before.BudgetUSD == nil) || (w.BudgetUSD != nil && before.BudgetUSD != nil && *w.BudgetUSD == *before.BudgetUSD) - if w.Name == before.Name && w.Why == before.Why && w.Autonomy == before.Autonomy && w.Wake == before.Wake && sameBudget { - out = w - return nil - } - if w.Name != before.Name { - if err := workspace.RenameIn(ctx, tx, id, w.Name); err != nil { - return err - } - } - if _, err := tx.ExecContext(ctx, `UPDATE workstreams SET why=?,autonomy=?,wake_morning=?,wake_related_issues=?,budget_usd=?,updated_at=max(updated_at+1,?) WHERE id=?`, w.Why, w.Autonomy, w.Wake.Morning, w.Wake.RelatedIssues, w.BudgetUSD, s.now().UnixMilli(), id); err != nil { - return err - } - out, err = getIn(ctx, tx, id) - c = changed(id, PartWorkstream) - return err - }) - if err != nil { - return Workstream{}, Change{}, err - } - return out, c, nil -} - -// Stage follows the wire rule; dropped and unknown states do not start a workstream. -func Stage(done, started time.Time, states []string) string { - if !done.IsZero() { - return "done" - } - if !started.IsZero() { - return "building" - } - for _, state := range states { - switch state { - case "queued", "running", "needs_you", "done": - return "building" - } - } - return "shaping" -} - -// Jobs returns direct factory job members in collection order. -func (w Workstream) Jobs() []Job { - out := make([]Job, 0, w.Total) - for _, ref := range w.Members { - if ref.Kind == workspace.FactoryJobKind { - n, _ := strconv.Atoi(ref.ID) - out = append(out, Job{Factory: ref.Factory, Number: n}) - } - } - return out -} -func (s *Store) ObserveJobStates(ctx context.Context, id string, states map[Job]string) (Change, error) { - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if !w.StartedAt.IsZero() { - return nil - } - memberStates := make([]string, 0, w.Total) - for _, job := range w.Jobs() { - memberStates = append(memberStates, states[job]) - } - if Stage(time.Time{}, time.Time{}, memberStates) != "building" { - return nil - } - at := s.now().UnixMilli() - _, err = tx.ExecContext(ctx, "UPDATE workstreams SET started_at=?,updated_at=max(updated_at+1,?) WHERE id=?", at, at, id) - if err == nil { - c = changed(id, PartWorkstream) - } - return err - }) - if err != nil { - return Change{}, err - } - return c, nil -} -func (s *Store) MarkDone(ctx context.Context, id, how string) (Workstream, Change, error) { - if !textWithin(how, 4000) { - return Workstream{}, Change{}, invalid("how_it_went exceeds 4000 bytes") - } - return s.setDone(ctx, id, how, true) -} -func (s *Store) Reopen(ctx context.Context, id string) (Workstream, Change, error) { - return s.setDone(ctx, id, "", false) -} -func (s *Store) setDone(ctx context.Context, id, how string, done bool) (Workstream, Change, error) { - var out Workstream - var c Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if done == !w.DoneAt.IsZero() { - out = w - return nil - } - at := s.now().UnixMilli() - if done { - if _, err := tx.ExecContext(ctx, "UPDATE workstreams SET done_at=?,how_it_went=?,updated_at=max(updated_at+1,?) WHERE id=?", at, how, at, id); err != nil { - return err - } - result, err := tx.ExecContext(ctx, `UPDATE workstream_goaheads SET state='ended', - end_reason=CASE WHEN ends_at<=? THEN 'expired' ELSE 'workstream_done' END, - ended_at=CASE WHEN ends_at<=? THEN ends_at ELSE ? END - WHERE workstream_id=? AND state='active'`, at, at, at, id) - if err != nil { - return err - } - c = changed(id, PartWorkstream) - n, err := result.RowsAffected() - if err != nil { - return err - } - if n > 0 { - c.Parts = append(c.Parts, PartGoAheads) - } - } else { - if _, err := tx.ExecContext(ctx, "UPDATE workstreams SET done_at=NULL,started_at=coalesce(started_at,?),updated_at=max(updated_at+1,?) WHERE id=?", at, at, id); err != nil { - return err - } - c = changed(id, PartWorkstream) - } - out, err = getIn(ctx, tx, id) - return err - }) - if err != nil { - return Workstream{}, Change{}, err - } - return out, c, nil -} - -// CountNumbers retains measured zeroes and withholds any total needing unknown -// facts. Job state must be known even for spend, as required by the contract. -func CountNumbers(jobs []Job, facts map[Job]JobFacts, agentSpend *float64, proposed int) Numbers { - out := Numbers{Total: len(jobs)} - merged, needs, spend := 0, proposed, 0.0 - mergedKnown, statesKnown, costKnown := true, true, true - for _, job := range jobs { - f, ok := facts[job] - known := ok && (f.State == "backlog" || f.State == "queued" || f.State == "running" || f.State == "needs_you" || f.State == "done" || f.State == "dropped") - if !known { - statesKnown = false - } - if !known || f.Merged == nil { - mergedKnown = false - } else if *f.Merged { - merged++ - } - if f.State == "needs_you" { - needs++ - } - if !known || f.CostUSD == nil || math.IsNaN(*f.CostUSD) || math.IsInf(*f.CostUSD, 0) || *f.CostUSD < 0 { - costKnown = false - } else { - spend += *f.CostUSD - } - } - if mergedKnown { - out.Merged = &merged - } - if statesKnown { - out.NeedsYou = &needs - } - if costKnown && agentSpend != nil && !math.IsNaN(*agentSpend) && !math.IsInf(*agentSpend, 0) && *agentSpend >= 0 { - spend += *agentSpend - if !math.IsInf(spend, 0) { - out.SpendUSD = &spend - } - } - return out -} diff --git a/internal/workstream/store_test.go b/internal/workstream/store_test.go deleted file mode 100644 index 3995666199..0000000000 --- a/internal/workstream/store_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package workstream - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func testStore(t *testing.T) (*Store, *workspace.Store, *time.Time) { - t.Helper() - w, err := workspace.Open(filepath.Join(t.TempDir(), "collections.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := w.Close(); err != nil { - t.Error(err) - } - }) - now := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC) - n := 0 - s := New(w, Options{Now: func() time.Time { return now }, NewID: func() (string, error) { n++; return fmt.Sprintf("id-%d", n), nil }}) - return s, w, &now -} - -func createStream(t *testing.T, s *Store, name string, refs ...workspace.Ref) Workstream { - t.Helper() - row, _, err := s.Create(context.Background(), Create{Name: name, AgentID: "agent-" + name, Refs: refs}) - if err != nil { - t.Fatal(err) - } - return row -} - -// Part A create: storage creates exactly one collection, row and Overview; -// client retries and refused job ownership leave no partial creation. -func TestPartACreateIsAtomicAndIdempotent(t *testing.T) { - s, w, _ := testStore(t) - ctx := context.Background() - args := Create{Name: "Billing", AgentID: "agent", OriginChatID: "origin", ClientID: "retry", Refs: []workspace.Ref{{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"}}} - first, c, err := s.Create(ctx, args) - if err != nil || len(c.Parts) == 0 { - t.Fatalf("create: %+v %v", c, err) - } - indexed, err := s.ByClientID(ctx, "retry") - if err != nil || indexed.ID != first.ID { - t.Fatalf("client lookup: %+v %v", indexed, err) - } - if _, err := s.ByClientID(ctx, ""); !errors.Is(err, ErrNotFound) { - t.Fatalf("empty client id: %v", err) - } - args.Name = "Other" - args.AgentID = "other" - again, c, err := s.Create(ctx, args) - if err != nil || again.ID != first.ID || len(c.Parts) != 0 { - t.Fatalf("retry: %+v %+v %v", again, c, err) - } - all, err := s.List(ctx) - if err != nil || len(all) != 1 { - t.Fatalf("rows: %v %v", all, err) - } - collections, err := w.Collections(ctx) - if err != nil || len(collections) != 1 || !collections[0].Workstream { - t.Fatalf("collections: %v %v", collections, err) - } - pages, err := s.Pages(ctx, first.ID) - if err != nil || len(pages) != 1 || pages[0].Title != "Overview" || pages[0].Owner != "you" || pages[0].Version != 1 || pages[0].Position != 0 { - t.Fatalf("pages: %v %v", pages, err) - } - p, err := s.Page(ctx, pages[0].ID, true) - if err != nil || string(p.Doc) != `{"type":"doc","content":[]}` || p.Markdown == nil || *p.Markdown != "" { - t.Fatalf("overview: %+v %v", p, err) - } - members, err := w.Members(ctx, first.ID) - if err != nil || len(members) != 3 { - t.Fatalf("members: %v %v", members, err) - } - _, _, err = s.Create(ctx, Create{Name: "Conflict", AgentID: "third", Refs: args.Refs}) - var taken *workspace.WorkstreamTaken - if !errors.As(err, &taken) || taken.WorkstreamID != first.ID || taken.WorkstreamName != "Billing" { - t.Fatalf("taken: %v", err) - } - all, _ = s.List(ctx) - collections, _ = w.Collections(ctx) - if len(all) != 1 || len(collections) != 1 { - t.Fatal("failed create left state") - } -} - -// Part A (b): a batch add rolls back every ref and both workstream timestamps. -func TestPartAJobOwnershipRefusalAndPlainDuplicate(t *testing.T) { - s, w, now := testStore(t) - ctx := context.Background() - job := workspace.Ref{Kind: workspace.FactoryJobKind, ID: "17", Factory: "factory"} - a := createStream(t, s, "First", job) - b := createStream(t, s, "Second") - before, _ := w.Members(ctx, b.ID) - *now = now.Add(time.Hour) - _, _, err := w.AddRefs(ctx, b.ID, []workspace.Addition{{Ref: workspace.Ref{Kind: workspace.ConversationKind, ID: "extra"}}, {Ref: job}}) - var taken *workspace.WorkstreamTaken - if !errors.As(err, &taken) || taken.Factory != "factory" || taken.Job != 17 || taken.WorkstreamID != a.ID || taken.WorkstreamName != "First" { - t.Fatalf("taken: %+v %v", taken, err) - } - after, _ := w.Members(ctx, b.ID) - a2, _ := s.Get(ctx, a.ID) - b2, _ := s.Get(ctx, b.ID) - if !reflect.DeepEqual(before, after) || !a.UpdatedAt.Equal(a2.UpdatedAt) || !b.UpdatedAt.Equal(b2.UpdatedAt) { - t.Fatal("refused add changed state") - } - plain, err := w.Create(ctx, "Plain") - if err != nil { - t.Fatal(err) - } - if err := w.Add(ctx, plain.ID, job); err != nil { - t.Fatal(err) - } - duplicate, err := w.Duplicate(ctx, a.ID, "Copy") - if err != nil || duplicate.Workstream { - t.Fatalf("duplicate: %+v %v", duplicate, err) - } - if _, err := s.Get(ctx, duplicate.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("duplicate became workstream: %v", err) - } - if _, err := w.Delete(ctx, a.ID); !errors.Is(err, workspace.ErrWorkstreamDelete) { - t.Fatalf("delete: %v", err) - } - if err := w.Remove(ctx, a.ID, job); err != nil { - t.Fatal(err) - } - if err := w.Add(ctx, b.ID, job); err != nil { - t.Fatal(err) - } - b2, _ = s.Get(ctx, b.ID) - if !b2.UpdatedAt.After(b.UpdatedAt) { - t.Fatal("membership did not update timestamp") - } -} - -// Part A (d): stage persists the first departure, all merged is not Done, -// and reopening an unstarted workstream gives Building even with Backlog. -func TestPartAStageAndObservation(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - job := workspace.Ref{Kind: workspace.FactoryJobKind, ID: "1", Factory: "f"} - row := createStream(t, s, "Stage", job) - for _, state := range []string{"", "backlog", "dropped", "unknown"} { - if got := Stage(time.Time{}, time.Time{}, []string{state}); got != "shaping" { - t.Fatalf("%s: %s", state, got) - } - } - for _, state := range []string{"queued", "running", "needs_you", "done"} { - if got := Stage(time.Time{}, time.Time{}, []string{state}); got != "building" { - t.Fatalf("%s: %s", state, got) - } - } - c, err := s.ObserveJobStates(ctx, row.ID, map[Job]string{{Factory: "f", Number: 1}: "queued"}) - if err != nil || len(c.Parts) == 0 { - t.Fatalf("observe: %+v %v", c, err) - } - got, _ := s.Get(ctx, row.ID) - if got.StartedAt.IsZero() || got.Stage != "building" { - t.Fatalf("started: %+v", got) - } - c, err = s.ObserveJobStates(ctx, row.ID, map[Job]string{{Factory: "f", Number: 1}: "backlog"}) - if err != nil || len(c.Parts) != 0 { - t.Fatal("observation reset start") - } - done, _, err := s.MarkDone(ctx, row.ID, "") - if err != nil || done.Stage != "done" { - t.Fatalf("done: %+v %v", done, err) - } - open, _, err := s.Reopen(ctx, row.ID) - if err != nil || open.Stage != "building" { - t.Fatalf("reopen: %+v %v", open, err) - } - fresh := createStream(t, s, "Unstarted") - _, _, err = s.MarkDone(ctx, fresh.ID, "") - if err != nil { - t.Fatal(err) - } - open, _, err = s.Reopen(ctx, fresh.ID) - if err != nil || open.StartedAt.IsZero() || open.Stage != "building" { - t.Fatalf("unstarted reopen: %+v %v", open, err) - } -} - -func TestPartAUpdateClearsSettingsAndKeepsNameRules(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - row := createStream(t, s, "Settings") - why := "Because" - budget := 20.0 - small := "small" - row, _, err := s.Update(ctx, row.ID, Patch{Why: &why, BudgetUSD: &budget, Autonomy: &small, Wake: &Wake{Morning: true}}) - if err != nil || row.BudgetUSD == nil || *row.BudgetUSD != 20 || !row.Wake.Morning { - t.Fatalf("update: %+v %v", row, err) - } - why = "" - budget = 0 - row, _, err = s.Update(ctx, row.ID, Patch{Why: &why, BudgetUSD: &budget}) - if err != nil || row.Why != "" || row.BudgetUSD != nil { - t.Fatalf("clear: %+v %v", row, err) - } - createStream(t, s, "Taken") - name := "taken" - if _, _, err := s.Update(ctx, row.ID, Patch{Name: &name}); !errors.Is(err, workspace.ErrNameTaken) { - t.Fatalf("name: %v", err) - } -} diff --git a/internal/workstream/suggestions.go b/internal/workstream/suggestions.go deleted file mode 100644 index 2652c8548a..0000000000 --- a/internal/workstream/suggestions.go +++ /dev/null @@ -1,532 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strings" - "time" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -const ( - SuggestionsMax = 5_000 - SuggestionEditsMax = 50 - SuggestionTextMax = 4_000 - SuggestionWhyMax = 200 -) - -var ErrNotPersonPage = errors.New("That page is yours; change it with write_agent_page. Suggest edits only on the person's pages.") - -type Suggestion struct { - ID, WorkstreamID, PageID string - BaseVersion int - Why, By string - Source *Source - CreatedAt time.Time - Edits []SuggestionEdit -} - -type SuggestionEdit struct { - ID string - Block []int - Offset int - Delete, Insert, BlockText, Why, State string - Applies *bool - DecidedAt time.Time -} - -type SuggestionFind struct { - Find string `json:"find"` - Replace string `json:"replace"` - Why string `json:"why"` -} - -type SuggestionCreate struct { - WorkstreamID, PageID string - BaseVersion int - Why string - Edits []SuggestionFind - Attribution -} - -// ValidateSuggestion checks byte bounds independently of any provider schema. -func ValidateSuggestion(pageID string, base int, why string, edits []SuggestionFind) error { - if strings.TrimSpace(pageID) == "" || base < 1 { - return invalid("page_id and a positive base_version are required") - } - if len(why) < 1 || !lineWithin(why, SuggestionWhyMax) { - return invalid("why must be one line of 1–200 bytes") - } - if len(edits) < 1 || len(edits) > SuggestionEditsMax { - return invalid("edits must contain 1–50 edits") - } - for _, edit := range edits { - if len(edit.Find) < 1 || len(edit.Find) > SuggestionTextMax || !utf8.ValidString(edit.Find) || len(edit.Replace) > SuggestionTextMax || !utf8.ValidString(edit.Replace) { - return invalid("find must be 1–4000 bytes and replace 0–4000 bytes") - } - if len(edit.Why) < 1 || !lineWithin(edit.Why, SuggestionWhyMax) { - return invalid("edit why must be one line of 1–200 bytes") - } - } - return nil -} - -// SuggestionTextError is a normal tool refusal, with the caller's edit numbers. -type SuggestionTextError struct{ Message string } - -func (e *SuggestionTextError) Error() string { return e.Message } - -func suggestionTextError(n int, message string) error { - return &SuggestionTextError{Message: fmt.Sprintf("Edit %d: %s", n, message)} -} - -func resolveSuggestionEdit(blocks []pagedoc.TextBlock, ask SuggestionFind, n int) (SuggestionEdit, error) { - if ask.Find == ask.Replace { - return SuggestionEdit{}, suggestionTextError(n, "replace is the same as find.") - } - var match pagedoc.TextBlock - at, count := 0, 0 - for _, block := range blocks { - for from := 0; from <= len(block.Text)-len(ask.Find); { - index := strings.Index(block.Text[from:], ask.Find) - if index < 0 { - break - } - at, match = from+index, block - count++ - // Count overlapping occurrences too; an ambiguous quote is never guessed. - _, width := utf8.DecodeRuneInString(block.Text[at:]) - from = at + width - } - } - if count == 0 { - return SuggestionEdit{}, suggestionTextError(n, "that text is not on the page. Quote plain text from one paragraph, heading, list item or code block exactly as it reads, without markdown marks.") - } - if count != 1 { - return SuggestionEdit{}, suggestionTextError(n, "that text appears more than once. Quote more of it so it is unique.") - } - if match.Kind != "codeBlock" && strings.ContainsAny(ask.Replace, "\r\n") { - return SuggestionEdit{}, suggestionTextError(n, "only a code block's text may contain a line break.") - } - offset, del, ins := pagedoc.TrimEdit(ask.Find, ask.Replace) - return SuggestionEdit{Block: match.Path, Offset: utf8.RuneCountInString(match.Text[:at]) + offset, Delete: del, Insert: ins, BlockText: match.Text, Why: ask.Why, State: "proposed"}, nil -} - -func resolveSuggestion(doc json.RawMessage, asks []SuggestionFind) ([]SuggestionEdit, error) { - root, err := pagedoc.Decode(doc) - if err != nil { - return nil, err - } - blocks := pagedoc.TextBlocks(root) - edits := make([]SuggestionEdit, 0, len(asks)) - for i, ask := range asks { - edit, err := resolveSuggestionEdit(blocks, ask, i+1) - if err != nil { - return nil, err - } - for j, other := range edits { - if slices.Equal(edit.Block, other.Block) && edit.Offset <= other.Offset+utf8.RuneCountInString(other.Delete) && other.Offset <= edit.Offset+utf8.RuneCountInString(edit.Delete) { - return nil, &SuggestionTextError{Message: fmt.Sprintf("Edits %d and %d touch the same text. Merge them into one edit.", j+1, i+1)} - } - } - edits = append(edits, edit) - } - sort.SliceStable(edits, func(i, j int) bool { return suggestionEditLess(edits[i], edits[j]) }) - for i := range edits { - edits[i].ID = fmt.Sprintf("e%d", i+1) - } - return edits, nil -} - -func suggestionEditLess(a, b SuggestionEdit) bool { - if order := slices.Compare(a.Block, b.Block); order != 0 { - return order < 0 - } - return a.Offset < b.Offset -} - -func (s *Store) CreateSuggestion(ctx context.Context, p SuggestionCreate) (Suggestion, Change, error) { - var out Suggestion - if p.By != "agent" { - return out, Change{}, invalid("only the agent suggests edits") - } - if err := ValidateSuggestion(p.PageID, p.BaseVersion, p.Why, p.Edits); err != nil { - return out, Change{}, err - } - source, err := sourceJSON(p.From) - if err != nil { - return out, Change{}, err - } - err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - page, err := suggestionCreatePage(ctx, tx, p) - if err != nil { - return err - } - edits, err := resolveSuggestion(page.Doc, p.Edits) - if err != nil { - return err - } - id, err := s.newID() - if err != nil { - return err - } - out = Suggestion{ID: "sg_" + id, WorkstreamID: page.WorkstreamID, PageID: page.ID, BaseVersion: p.BaseVersion, Why: p.Why, By: "agent", Source: p.From, CreatedAt: instant(s.now().UnixMilli()), Edits: edits} - if err := insertSuggestion(ctx, tx, out, source); err != nil { - return err - } - if err := touch(ctx, tx, out.WorkstreamID, out.CreatedAt.UnixMilli()); err != nil { - return err - } - return locateSuggestion(&out, page.Doc) - }) - if err != nil { - return Suggestion{}, Change{}, err - } - return out, suggestionChanged(out.WorkstreamID), nil -} - -func suggestionCreatePage(ctx context.Context, tx *sql.Tx, p SuggestionCreate) (Page, error) { - page, _, err := livePageIn(ctx, tx, p.PageID) - if err != nil { - return page, err - } - if page.WorkstreamID != p.WorkstreamID { - return page, ErrNotFound - } - if err := workstreamWriteAllowed(ctx, tx, page.WorkstreamID, "agent"); err != nil { - return page, err - } - if page.Owner != "you" { - return page, ErrNotPersonPage - } - if page.Version != p.BaseVersion { - return page, suggestionVersionConflict(page) - } - var count int - if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM workstream_suggestions WHERE workstream_id=?", page.WorkstreamID).Scan(&count); err != nil { - return page, err - } - if count >= SuggestionsMax { - return page, ErrTooLarge - } - return page, nil -} - -func insertSuggestion(ctx context.Context, tx *sql.Tx, suggestion Suggestion, source any) error { - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,source,created_at) VALUES (?,?,?,?,?,?,?,?)`, suggestion.ID, suggestion.WorkstreamID, suggestion.PageID, suggestion.BaseVersion, suggestion.Why, suggestion.By, source, suggestion.CreatedAt.UnixMilli()); err != nil { - return err - } - for i, edit := range suggestion.Edits { - block, err := json.Marshal(edit.Block) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) VALUES (?,?,?,?,?,?,?,?,?,'proposed')`, suggestion.ID, edit.ID, i, string(block), edit.Offset, edit.Delete, edit.Insert, edit.BlockText, edit.Why); err != nil { - return err - } - } - return nil -} - -func suggestionChanged(id string) Change { return Change{ID: id, Parts: []ChangedPart{PartPages}} } -func suggestionVersionConflict(p Page) error { - return &VersionConflict{ID: p.ID, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} -} - -func suggestionIn(ctx context.Context, tx *sql.Tx, id string) (Suggestion, error) { - var out Suggestion - var source sql.NullString - var at int64 - err := tx.QueryRowContext(ctx, `SELECT id,workstream_id,page_id,base_version,why,actor,source,created_at FROM workstream_suggestions WHERE id=?`, id).Scan(&out.ID, &out.WorkstreamID, &out.PageID, &out.BaseVersion, &out.Why, &out.By, &source, &at) - if err != nil { - return out, found(err) - } - out.CreatedAt = instant(at) - out.Source, err = readSource(source) - if err != nil { - return out, err - } - rows, err := tx.QueryContext(ctx, `SELECT id,block,at_offset,del,ins,block_text,why,state,decided_at FROM workstream_suggestion_edits WHERE suggestion_id=? ORDER BY position`, id) - if err != nil { - return out, err - } - defer rows.Close() - out.Edits = []SuggestionEdit{} - for rows.Next() { - var edit SuggestionEdit - var block string - var decided sql.NullInt64 - if err := rows.Scan(&edit.ID, &block, &edit.Offset, &edit.Delete, &edit.Insert, &edit.BlockText, &edit.Why, &edit.State, &decided); err != nil { - return out, err - } - if err := json.Unmarshal([]byte(block), &edit.Block); err != nil { - return out, err - } - edit.DecidedAt = optionalInstant(decided) - out.Edits = append(out.Edits, edit) - } - return out, rows.Err() -} - -func locateSuggestion(suggestion *Suggestion, doc json.RawMessage) error { - root, err := pagedoc.Decode(doc) - if err != nil { - return err - } - for i := range suggestion.Edits { - edit := &suggestion.Edits[i] - edit.Applies = nil - if edit.State != "proposed" { - continue - } - block, ok := pagedoc.Locate(root, edit.Block, edit.BlockText) - edit.Applies = &ok - if ok { - edit.Block = block.Path - } - } - return nil -} - -func (s *Store) Suggestions(ctx context.Context, pageID string) ([]Suggestion, error) { - out := []Suggestion{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - page, _, err := livePageIn(ctx, tx, pageID) - if err != nil || version < workspace.WorkstreamSuggestionSchemaVersion { - return err - } - rows, err := tx.QueryContext(ctx, `SELECT s.id FROM workstream_suggestions s WHERE s.page_id=? AND EXISTS(SELECT 1 FROM workstream_suggestion_edits e WHERE e.suggestion_id=s.id AND e.state='proposed') ORDER BY s.created_at,s.rowid`, pageID) - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - err = rows.Err() - if closeErr := rows.Close(); err == nil { - err = closeErr - } - if err != nil { - return err - } - for _, id := range ids { - suggestion, err := suggestionIn(ctx, tx, id) - if err != nil { - return err - } - if err := locateSuggestion(&suggestion, page.Doc); err != nil { - return err - } - out = append(out, suggestion) - } - return nil - }) - return out, err -} - -func suggestionTargets(suggestion Suggestion, editID string) ([]int, error) { - targets := []int{} - found := editID == "" - for i, edit := range suggestion.Edits { - if editID != "" && edit.ID != editID { - continue - } - found = true - if edit.State == "proposed" { - targets = append(targets, i) - } else if editID != "" { - return nil, ErrConflict - } - } - if !found { - return nil, ErrNotFound - } - if len(targets) == 0 { - return nil, ErrConflict - } - return targets, nil -} - -func suggestionDecisionIn(ctx context.Context, tx *sql.Tx, id string) (Suggestion, Page, error) { - suggestion, err := suggestionIn(ctx, tx, id) - if err != nil { - return suggestion, Page{}, err - } - page, _, err := livePageIn(ctx, tx, suggestion.PageID) - if err != nil { - return suggestion, page, err - } - w, err := getIn(ctx, tx, suggestion.WorkstreamID) - if err == nil && !w.DoneAt.IsZero() { - err = ErrConflict - } - return suggestion, page, err -} - -// keepSuggestionDoc checks every selected anchor before applying any edit. -func keepSuggestionDoc(suggestion *Suggestion, page Page, targets []int, at time.Time) (json.RawMessage, error) { - if err := locateSuggestion(suggestion, page.Doc); err != nil { - return nil, err - } - for _, i := range targets { - if !*suggestion.Edits[i].Applies { - return nil, suggestionVersionConflict(page) - } - } - sort.SliceStable(targets, func(i, j int) bool { - return suggestionEditLess(suggestion.Edits[targets[i]], suggestion.Edits[targets[j]]) - }) - root, err := pagedoc.Decode(page.Doc) - if err != nil { - return nil, err - } - for _, i := range targets { - edit := &suggestion.Edits[i] - block, ok := pagedoc.Locate(root, edit.Block, edit.BlockText) - if !ok { - return nil, suggestionVersionConflict(page) - } - if err := pagedoc.Apply(block, edit.Offset, edit.Delete, edit.Insert); err != nil { - return nil, suggestionVersionConflict(page) - } - edit.Block, edit.State, edit.DecidedAt, edit.Applies = block.Path, "kept", at, nil - rebaseSuggestion(suggestion, *edit, block.Path) - } - doc, err := json.Marshal(root) - if err != nil { - return nil, err - } - if err := checkDoc(doc); err != nil { - return nil, err - } - return doc, locateSuggestion(suggestion, doc) -} - -func rebaseSuggestion(suggestion *Suggestion, kept SuggestionEdit, path []int) { - text := []rune(kept.BlockText) - end := kept.Offset + utf8.RuneCountInString(kept.Delete) - newText := string(text[:kept.Offset]) + kept.Insert + string(text[end:]) - delta := utf8.RuneCountInString(kept.Insert) - utf8.RuneCountInString(kept.Delete) - for i := range suggestion.Edits { - edit := &suggestion.Edits[i] - if edit.State != "proposed" || !slices.Equal(edit.Block, path) || edit.BlockText != kept.BlockText { - continue - } - edit.BlockText = newText - if edit.Offset > kept.Offset { - edit.Offset += delta - } - } -} - -func saveSuggestionEdits(ctx context.Context, tx *sql.Tx, suggestion Suggestion) error { - for _, edit := range suggestion.Edits { - block, err := json.Marshal(edit.Block) - if err != nil { - return err - } - var decided any - if !edit.DecidedAt.IsZero() { - decided = edit.DecidedAt.UnixMilli() - } - if _, err := tx.ExecContext(ctx, `UPDATE workstream_suggestion_edits SET block=?,at_offset=?,block_text=?,state=?,decided_at=? WHERE suggestion_id=? AND id=?`, string(block), edit.Offset, edit.BlockText, edit.State, decided, suggestion.ID, edit.ID); err != nil { - return err - } - } - return nil -} - -func (s *Store) KeepSuggestion(ctx context.Context, id, editID string) (Page, Suggestion, Change, error) { - var page Page - var suggestion Suggestion - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - var err error - suggestion, page, err = suggestionDecisionIn(ctx, tx, id) - if err != nil { - return err - } - targets, err := suggestionTargets(suggestion, editID) - if err != nil { - return err - } - label := suggestion.Why - if editID != "" { - label = suggestion.Edits[targets[0]].Why - } - at := instant(s.now().UnixMilli()) - doc, err := keepSuggestionDoc(&suggestion, page, targets, at) - if err != nil { - return err - } - source, err := json.Marshal(Source{Kind: "suggestion", ID: id, Label: label}) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `UPDATE workstream_pages SET doc=?,version=version+1,updated_at=?,updated_by='you',updated_from=? WHERE id=?`, string(doc), at.UnixMilli(), string(source), page.ID); err != nil { - return err - } - if err := saveVersion(ctx, tx, page.ID); err != nil { - return err - } - if err := saveSuggestionEdits(ctx, tx, suggestion); err != nil { - return err - } - if err := touch(ctx, tx, suggestion.WorkstreamID, at.UnixMilli()); err != nil { - return err - } - page, _, err = livePageIn(ctx, tx, page.ID) - return err - }) - if err != nil { - return Page{}, Suggestion{}, Change{}, err - } - return page, suggestion, suggestionChanged(suggestion.WorkstreamID), nil -} - -func (s *Store) DropSuggestion(ctx context.Context, id, editID string) (Suggestion, Change, error) { - var suggestion Suggestion - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - var page Page - var err error - suggestion, page, err = suggestionDecisionIn(ctx, tx, id) - if err != nil { - return err - } - targets, err := suggestionTargets(suggestion, editID) - if err != nil { - return err - } - at := instant(s.now().UnixMilli()) - for _, i := range targets { - suggestion.Edits[i].State, suggestion.Edits[i].DecidedAt = "dropped", at - } - if err := locateSuggestion(&suggestion, page.Doc); err != nil { - return err - } - if err := saveSuggestionEdits(ctx, tx, suggestion); err != nil { - return err - } - return touch(ctx, tx, suggestion.WorkstreamID, at.UnixMilli()) - }) - if err != nil { - return Suggestion{}, Change{}, err - } - return suggestion, suggestionChanged(suggestion.WorkstreamID), nil -} diff --git a/internal/workstream/suggestions_test.go b/internal/workstream/suggestions_test.go deleted file mode 100644 index f5c5ad047f..0000000000 --- a/internal/workstream/suggestions_test.go +++ /dev/null @@ -1,554 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -type workstreamSuggestionFixture struct { - Before json.RawMessage `json:"doc_before"` - After json.RawMessage `json:"doc_after"` - OnlyE3 json.RawMessage `json:"doc_after_e3"` - Edits []SuggestionFind `json:"edits"` -} - -func workstreamSuggestionSeed(t *testing.T, s *Store) (Page, SuggestionCreate, workstreamSuggestionFixture) { - t.Helper() - raw, err := os.ReadFile("pagedoc/testdata/suggestion-keep.json") - if err != nil { - t.Fatal(err) - } - var fixture workstreamSuggestionFixture - if err := json.Unmarshal(raw, &fixture); err != nil { - t.Fatal(err) - } - w := createStream(t, s, "Suggestions") - page, _, err := s.CreatePage(context.Background(), PageCreate{WorkstreamID: w.ID, Title: "Signing", Doc: fixture.Before}) - if err != nil { - t.Fatal(err) - } - ask := SuggestionCreate{WorkstreamID: w.ID, PageID: page.ID, BaseVersion: page.Version, Why: "Explain signing and retries", Edits: fixture.Edits, Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: w.AgentID}}} - return page, ask, fixture -} - -func workstreamSuggestionDocEqual(t *testing.T, got, want json.RawMessage) { - t.Helper() - a, err := pagedoc.Decode(got) - if err != nil { - t.Fatal(err) - } - b, err := pagedoc.Decode(want) - if err != nil || !reflect.DeepEqual(a, b) { - t.Fatalf("document differs: %s; want %s; %v", got, want, err) - } -} - -func workstreamSuggestionCreate(t *testing.T, s *Store, ask SuggestionCreate) Suggestion { - t.Helper() - suggestion, change, err := s.CreateSuggestion(context.Background(), ask) - if err != nil || !reflect.DeepEqual(change, suggestionChanged(ask.WorkstreamID)) { - t.Fatalf("create: %+v %v", change, err) - } - return suggestion -} - -// WP-6d (b): a single keep makes exactly one attributed version; drop leaves -// every page field and history untouched and decides only its selected edit. -func TestWorkstreamSuggestionKeepOneAndDropOne(t *testing.T) { - s, _, _ := testStore(t) - page, ask, fixture := workstreamSuggestionSeed(t, s) - suggestion := workstreamSuggestionCreate(t, s, ask) - ctx := context.Background() - historyBefore, err := s.PageHistory(ctx, page.ID, 50) - if err != nil { - t.Fatal(err) - } - kept, after, change, err := s.KeepSuggestion(ctx, suggestion.ID, "e3") - if err != nil || kept.Version != page.Version+1 || kept.UpdatedBy != "you" || !reflect.DeepEqual(change, suggestionChanged(page.WorkstreamID)) { - t.Fatalf("keep: %+v %+v %v", kept, change, err) - } - workstreamSuggestionDocEqual(t, kept.Doc, fixture.OnlyE3) - history, err := s.PageHistory(ctx, page.ID, 50) - wantSource := &Source{Kind: "suggestion", ID: suggestion.ID, Label: ask.Edits[2].Why} - if err != nil || len(history) != len(historyBefore)+1 || !reflect.DeepEqual(history[1:], historyBefore) || !reflect.DeepEqual(history[0].From, wantSource) || !reflect.DeepEqual(kept.UpdatedFrom, wantSource) || history[0].SavedBy != "you" { - t.Fatalf("history: %+v %v", history, err) - } - for i, edit := range after.Edits { - if i == 2 { - if edit.State != "kept" || edit.DecidedAt.IsZero() || edit.Applies != nil { - t.Fatalf("kept edit: %+v", edit) - } - } else if edit.State != "proposed" || edit.Applies == nil || !*edit.Applies || !edit.DecidedAt.IsZero() { - t.Fatalf("proposed edit: %+v", edit) - } - } - dropped, change, err := s.DropSuggestion(ctx, suggestion.ID, "e1") - if err != nil || dropped.Edits[0].State != "dropped" || dropped.Edits[0].DecidedAt.IsZero() || dropped.Edits[0].Applies != nil || dropped.Edits[1].State != "proposed" || dropped.Edits[2].State != "kept" || !reflect.DeepEqual(change, suggestionChanged(page.WorkstreamID)) { - t.Fatalf("drop: %+v %v", dropped, err) - } - unchanged, err := s.Page(ctx, page.ID, false) - if err != nil || !reflect.DeepEqual(unchanged, kept) { - t.Fatal("drop changed page", err) - } - historyAfter, err := s.PageHistory(ctx, page.ID, 50) - if err != nil || !reflect.DeepEqual(historyAfter, history) { - t.Fatal("drop changed history", err) - } -} - -// WP-6d (c): keep all saves one version and closes the suggestion; drop all -// closes it without touching the page, history or updated_at. -func TestWorkstreamSuggestionKeepAllAndDropAll(t *testing.T) { - for _, keep := range []bool{true, false} { - t.Run(map[bool]string{true: "keep", false: "drop"}[keep], func(t *testing.T) { - s, _, _ := testStore(t) - page, ask, fixture := workstreamSuggestionSeed(t, s) - suggestion := workstreamSuggestionCreate(t, s, ask) - ctx := context.Background() - before, err := s.PageHistory(ctx, page.ID, 50) - if err != nil { - t.Fatal(err) - } - var decided Suggestion - if keep { - var saved Page - saved, decided, _, err = s.KeepSuggestion(ctx, suggestion.ID, "") - if err != nil { - t.Fatal(err) - } - workstreamSuggestionDocEqual(t, saved.Doc, fixture.After) - want := &Source{Kind: "suggestion", ID: suggestion.ID, Label: ask.Why} - if saved.Version != page.Version+1 || saved.UpdatedBy != "you" || !reflect.DeepEqual(saved.UpdatedFrom, want) { - t.Fatalf("keep all: %+v", saved) - } - } else { - decided, _, err = s.DropSuggestion(ctx, suggestion.ID, "") - if err != nil { - t.Fatal(err) - } - saved, err := s.Page(ctx, page.ID, false) - if err != nil || !reflect.DeepEqual(saved, page) { - t.Fatal("drop all changed page", err) - } - } - for _, edit := range decided.Edits { - want := "dropped" - if keep { - want = "kept" - } - if edit.State != want || edit.DecidedAt.IsZero() || edit.Applies != nil { - t.Fatalf("decision: %+v", edit) - } - } - after, err := s.PageHistory(ctx, page.ID, 50) - if err != nil { - t.Fatal(err) - } - if keep { - if len(after) != len(before)+1 || after[0].From.Label != ask.Why { - t.Fatalf("keep all history: %+v", after) - } - } else if !reflect.DeepEqual(before, after) { - t.Fatal("drop all changed history") - } - open, err := s.Suggestions(ctx, page.ID) - if err != nil || open == nil || len(open) != 0 { - t.Fatalf("closed still open: %+v %v", open, err) - } - for _, editID := range []string{"e1", ""} { - if _, _, c, err := s.KeepSuggestion(ctx, suggestion.ID, editID); !errors.Is(err, ErrConflict) || len(c.Parts) != 0 { - t.Fatalf("repeat keep: %v", err) - } - if _, c, err := s.DropSuggestion(ctx, suggestion.ID, editID); !errors.Is(err, ErrConflict) || len(c.Parts) != 0 { - t.Fatalf("repeat drop: %v", err) - } - } - }) - } -} - -// WP-6d (c): keeping either end of one block rebases the remaining anchor. -func TestWorkstreamSuggestionEditsMoveWithText(t *testing.T) { - for _, first := range []string{"e1", "e2", ""} { - t.Run("first="+first, func(t *testing.T) { - s, _, _ := testStore(t) - page, ask, _ := workstreamSuggestionSeed(t, s) - doc := pagedoc.FromMarkdown("Alpha old words and omega old words.") - page, _, err := s.SavePage(context.Background(), page.ID, page.Version, doc, Attribution{}) - if err != nil { - t.Fatal(err) - } - ask.BaseVersion = page.Version - ask.Edits = []SuggestionFind{{Find: "Alpha old", Replace: "Alpha several new", Why: "Earlier"}, {Find: "omega old", Replace: "omega fresh", Why: "Later"}} - suggestion := workstreamSuggestionCreate(t, s, ask) - saved, after, _, err := s.KeepSuggestion(context.Background(), suggestion.ID, first) - if err != nil { - t.Fatal(err) - } - if first != "" { - remaining := "e2" - if first == "e2" { - remaining = "e1" - } - for _, edit := range after.Edits { - if edit.State == "proposed" && (edit.Applies == nil || !*edit.Applies) { - t.Fatalf("lost anchor: %+v", edit) - } - } - saved, _, _, err = s.KeepSuggestion(context.Background(), suggestion.ID, remaining) - if err != nil { - t.Fatal(err) - } - } - workstreamSuggestionDocEqual(t, saved.Doc, pagedoc.FromMarkdown("Alpha several new words and omega fresh words.")) - }) - } -} - -// WP-6d (d): stale single/all keeps are atomic, carry page details, and expose -// applies false. Moving an intact paragraph reports and persists its new path. -func TestWorkstreamSuggestionStaleAndMovedBlocks(t *testing.T) { - for _, moved := range []bool{false, true} { - t.Run(map[bool]string{true: "moved", false: "stale"}[moved], func(t *testing.T) { - s, _, _ := testStore(t) - page, ask, fixture := workstreamSuggestionSeed(t, s) - suggestion := workstreamSuggestionCreate(t, s, ask) - root, err := pagedoc.Decode(fixture.Before) - if err != nil { - t.Fatal(err) - } - content := root["content"].([]any) - if moved { - root["content"] = append([]any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "Inserted above"}}}}, content...) - } else { - content[1].(map[string]any)["content"] = []any{map[string]any{"type": "text", "text": "Person changed the paragraph"}} - } - doc, err := json.Marshal(root) - if err != nil { - t.Fatal(err) - } - page, _, err = s.SavePage(context.Background(), page.ID, page.Version, doc, Attribution{}) - if err != nil { - t.Fatal(err) - } - open, err := s.Suggestions(context.Background(), page.ID) - if err != nil || len(open) != 1 { - t.Fatal("list", err) - } - if moved { - if !*open[0].Edits[0].Applies || !reflect.DeepEqual(open[0].Edits[0].Block, []int{1}) { - t.Fatalf("path did not move: %+v", open) - } - _, after, _, err := s.KeepSuggestion(context.Background(), suggestion.ID, "e1") - if err != nil || !reflect.DeepEqual(after.Edits[0].Block, []int{1}) || !reflect.DeepEqual(after.Edits[1].Block, []int{2}) { - t.Fatalf("located paths not saved: %+v %v", after, err) - } - return - } - if !*open[0].Edits[0].Applies || *open[0].Edits[1].Applies { - t.Fatalf("applies: %+v", open) - } - history, err := s.PageHistory(context.Background(), page.ID, 50) - if err != nil { - t.Fatal(err) - } - for _, target := range []string{"e2", ""} { - _, _, change, err := s.KeepSuggestion(context.Background(), suggestion.ID, target) - var stale *VersionConflict - if !errors.As(err, &stale) || stale.ID != page.ID || stale.Version != page.Version || !stale.UpdatedAt.Equal(page.UpdatedAt) || stale.UpdatedBy != page.UpdatedBy || len(change.Parts) != 0 { - t.Fatalf("stale: %+v %v", stale, err) - } - after, _ := s.Page(context.Background(), page.ID, false) - afterHistory, _ := s.PageHistory(context.Background(), page.ID, 50) - afterOpen, _ := s.Suggestions(context.Background(), page.ID) - if !reflect.DeepEqual(after, page) || !reflect.DeepEqual(afterHistory, history) || !reflect.DeepEqual(afterOpen, open) { - t.Fatal("refused keep changed state") - } - } - }) - } -} - -// WP-6d (a), (h), Tool: ownership, actor, version, done, missing targets, -// deletion and the all-states suggestion cap refuse without a receipt. -func TestWorkstreamSuggestionRefusals(t *testing.T) { - s, workspace, _ := testStore(t) - page, ask, _ := workstreamSuggestionSeed(t, s) - ctx := context.Background() - owner := "agent" - if _, _, err := s.UpdatePage(ctx, page.ID, PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - if _, c, err := s.CreateSuggestion(ctx, ask); !errors.Is(err, ErrNotPersonPage) || len(c.Parts) != 0 { - t.Fatalf("own page: %v", err) - } - open, err := s.Suggestions(ctx, page.ID) - if err != nil || len(open) != 0 { - t.Fatal("ownership refusal stored suggestion", err) - } - owner = "you" - if _, _, err := s.UpdatePage(ctx, page.ID, PagePatch{Owner: &owner}); err != nil { - t.Fatal(err) - } - stale := ask - stale.BaseVersion++ - if _, c, err := s.CreateSuggestion(ctx, stale); err == nil || len(c.Parts) != 0 { - t.Fatal("accepted stale create") - } else { - var conflict *VersionConflict - if !errors.As(err, &conflict) || conflict.ID != page.ID || conflict.Version != page.Version { - t.Fatalf("create conflict: %v", err) - } - } - person := ask - person.By = "you" - if _, _, err := s.CreateSuggestion(ctx, person); !errors.Is(err, ErrInvalid) { - t.Fatal("person created suggestion", err) - } - suggestion := workstreamSuggestionCreate(t, s, ask) - for _, ids := range [][2]string{{"missing", "e1"}, {suggestion.ID, "missing"}} { - if _, _, _, err := s.KeepSuggestion(ctx, ids[0], ids[1]); !errors.Is(err, ErrNotFound) { - t.Fatalf("missing keep: %v", err) - } - if _, _, err := s.DropSuggestion(ctx, ids[0], ids[1]); !errors.Is(err, ErrNotFound) { - t.Fatalf("missing drop: %v", err) - } - } - if err := workspace.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.Exec(`WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x 0 { - return len(p.Waves) - 1 - } - for i, w := range p.Waves { - if w.N == n { - return i - } - } - return -1 -} -func workstreamWakeTarget(p Plan, item WakeItem) (int, int) { - for wi, w := range p.Waves { - for pos, v := range w.Items { - if item.Factory != "" && v.Kind == "job" && v.Factory == item.Factory && v.Job == item.Job { - return wi, pos - } - if item.DraftID != "" && v.Kind == "draft" && v.DraftID == item.DraftID { - return wi, pos - } - } - } - return -1, -1 -} -func (s *Store) workstreamWakeSmallIn(ctx context.Context, tx *sql.Tx, w Workstream, item WakeItem) (bool, error) { - switch item.Kind { - case "page": - return true, nil - case "draft", "move": - p, err := planIn(ctx, tx, w.ID) - if err != nil { - return false, err - } - target := workstreamWakeWave(p, item.Wave) - if item.Kind == "draft" { - budget := item.BudgetUSD - if budget == nil || *budget <= 0 || *budget >= 5 { - return false, nil - } - if len(p.Waves) == 0 { - return item.Wave == 0 || item.Wave == 1, nil - } - return target >= 0 && !p.Waves[target].AutoStart, nil - } - from, _ := workstreamWakeTarget(p, item) - if target < 0 || from < 0 || target == from || p.Waves[target].AutoStart { - return false, nil - } - var granted bool - err = tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_goaheads WHERE workstream_id=? AND scope='wave' AND wave=? AND state='active' AND ends_at>?)", w.ID, item.Wave, s.now().UnixMilli()).Scan(&granted) - return !granted, err - } - return false, nil -} - -func workstreamWakeSource(w Workstream, event WakeEvent) *Source { - for _, job := range w.Jobs() { - if event.Kind != "morning" && job.Factory == event.Factory && job.Number == event.Job { - return &Source{Kind: "factory_job", Factory: job.Factory, ID: strconv.Itoa(job.Number), Label: event.Label} - } - } - return &Source{Kind: "chat", ID: w.AgentID, Label: event.Label} -} - -func (s *Store) workstreamWakeApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) (ChangedPart, error) { - var part ChangedPart - var err error - switch item.Kind { - case "move", "draft": - part = PartPlan - err = s.workstreamWakePlanApplyIn(ctx, tx, w, event, item) - case "page": - part = PartPages - err = s.workstreamWakePageApplyIn(ctx, tx, w, event, item) - case "decision", "question": - part = PartRecords - err = s.workstreamWakeRecordApplyIn(ctx, tx, w, event, item) - default: - return "", ErrConflict - } - if err != nil { - return "", err - } - item.State = "applied" - item.Note = "" - item.AppliedAt = instant(s.now().UnixMilli()) - item.Text = workstreamWakeItemText(*item) - return part, nil -} - -func (s *Store) workstreamWakeSavePlanIn(ctx context.Context, tx *sql.Tx, p Plan) error { - if len(p.Waves) > PlanWavesMax { - return ErrLimit - } - count := 0 - for _, w := range p.Waves { - count += len(w.Items) - } - if count > PlanItemsMax { - return ErrLimit - } - milestones, err := json.Marshal(p.Milestones) - if err != nil { - return err - } - waves, err := json.Marshal(p.Waves) - if err != nil { - return err - } - if len(milestones)+len(waves) > PlanEncodedMax { - return ErrLimit - } - _, err = tx.ExecContext(ctx, `INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) VALUES (?,?,?,'agent',?,?) - ON CONFLICT(workstream_id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,updated_by='agent',milestones=excluded.milestones,waves=excluded.waves`, p.WorkstreamID, p.Version+1, s.now().UnixMilli(), string(milestones), string(waves)) - return err -} - -func (s *Store) workstreamWakePlanApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { - p, err := planIn(ctx, tx, w.ID) - if err != nil { - return err - } - target := workstreamWakeWave(p, item.Wave) - if item.Kind == "move" { - from, pos := workstreamWakeTarget(p, *item) - if target < 0 || from < 0 || from == target { - return ErrConflict - } - moved := p.Waves[from].Items[pos] - item.Undo = workstreamWakeUndo{Wave: p.Waves[from].N, Position: pos, PlanItem: moved} - p.Waves[from].Items = append(p.Waves[from].Items[:pos], p.Waves[from].Items[pos+1:]...) - p.Waves[target].Items = append(p.Waves[target].Items, moved) - } else { - if len(p.Waves) == 0 && (item.Wave == 0 || item.Wave == 1) { - p.Waves = append(p.Waves, Wave{N: 1, Items: []PlanItem{}}) - target = 0 - item.Undo.CreatedWave = true - wave := p.Waves[target] - item.Undo.CreatedWaveImage = &wave - } - if target < 0 { - return ErrConflict - } - id, err := s.newID() - if err != nil { - return err - } - item.DraftID = "draft_" + id - item.Wave = p.Waves[target].N - v := PlanItem{Kind: "draft", DraftID: item.DraftID, Title: item.Proposal.Title, Repo: item.Proposal.Repo, Text: item.Proposal.Text, Size: item.Proposal.Size, Source: workstreamWakeSource(w, event)} - if item.BudgetUSD != nil { - v.BudgetUSD = *item.BudgetUSD - } - item.Undo.PlanItem = v - p.Waves[target].Items = append(p.Waves[target].Items, v) - } - return s.workstreamWakeSavePlanIn(ctx, tx, p) -} - -func (s *Store) workstreamWakePageApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { - source := workstreamWakeSource(w, event) - if source.Label == "" { - source.Label = "Workstream wake" - } - who := Attribution{By: "agent", From: source} - doc := pagedoc.FromMarkdown(item.Proposal.Markdown) - if item.PageID == "" { - ask, raw, err := workstreamPreparePageCreate(PageCreate{WorkstreamID: w.ID, Title: item.Proposal.Title, Owner: "agent", ReadByJobs: ReadByJobsDefault(item.Proposal.Title), Doc: doc, Attribution: who}) - if err != nil { - return err - } - page, err := s.workstreamCreatePageIn(ctx, tx, ask, raw) - if err != nil { - return err - } - item.PageID = page.ID - item.Undo = workstreamWakeUndo{Created: true, Version: page.Version, Page: &page.PageNode} - return nil - } - page, _, err := livePageIn(ctx, tx, item.PageID) - if errors.Is(err, ErrNotFound) { - return ErrConflict - } - if err != nil { - return err - } - if page.WorkstreamID != w.ID || page.Owner != "agent" { - return ErrConflict - } - previousVersion := page.Version - raw, err := sourceJSON(source) - if err != nil { - return err - } - if err := workstreamSavePageIn(ctx, tx, page, page.Title, doc, "agent", raw, s.now().UnixMilli()); err != nil { - return err - } - item.Proposal.Title = page.Title - page, _, err = livePageIn(ctx, tx, page.ID) - if err != nil { - return err - } - item.Undo = workstreamWakeUndo{Version: page.Version, PreviousVersion: previousVersion, Page: &page.PageNode} - return nil -} - -func (s *Store) workstreamWakeRecordApplyIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { - if err := workstreamRecordLimitIn(ctx, tx, w.ID); err != nil { - return err - } - source, err := sourceJSON(workstreamWakeSource(w, event)) - if err != nil { - return err - } - id, err := s.newID() - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, "INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,source,created_at,decided_at) VALUES (?,?,?,?,'kept','agent',?,?,?)", id, w.ID, item.Kind, item.Proposal.Text, source, s.now().UnixMilli(), s.now().UnixMilli()) - if err == nil { - item.RecordID = id - } - return err -} - -func (s *Store) workstreamWakeUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) (ChangedPart, error) { - var part ChangedPart - var err error - switch item.Kind { - case "move", "draft": - part = PartPlan - err = s.workstreamWakePlanUndoIn(ctx, tx, w, item) - case "page": - part = PartPages - err = s.workstreamWakePageUndoIn(ctx, tx, w, event, item) - case "decision", "question": - part = PartRecords - r, readErr := recordIn(ctx, tx, item.RecordID) - if errors.Is(readErr, ErrNotFound) { - return "", ErrConflict - } - if readErr != nil { - return "", readErr - } - if r.WorkstreamID != w.ID || r.State != "kept" || r.SupersededBy != "" { - return "", ErrConflict - } - _, err = tx.ExecContext(ctx, "UPDATE workstream_records SET state='dropped',decided_at=? WHERE id=?", s.now().UnixMilli(), r.ID) - default: - return "", ErrConflict - } - if err != nil { - return "", err - } - item.State = "undone" - item.Note = "" - item.UndoneAt = instant(s.now().UnixMilli()) - item.Text = workstreamWakeItemText(*item) - return part, nil -} -func (s *Store) workstreamWakePlanUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, item *WakeItem) error { - p, err := planIn(ctx, tx, w.ID) - if err != nil { - return err - } - from, pos := workstreamWakeTarget(p, *item) - if from < 0 { - return ErrConflict - } - if p.Waves[from].N != item.Wave { - return ErrConflict - } - if item.Kind == "draft" && !reflect.DeepEqual(p.Waves[from].Items[pos], item.Undo.PlanItem) { - return ErrConflict - } - target := -1 - if item.Kind == "move" { - target = workstreamWakeWave(p, item.Undo.Wave) - if target < 0 { - return ErrConflict - } - } - v := p.Waves[from].Items[pos] - p.Waves[from].Items = append(p.Waves[from].Items[:pos], p.Waves[from].Items[pos+1:]...) - if item.Kind == "move" { - items := p.Waves[target].Items - at := min(item.Undo.Position, len(items)) - items = append(items, PlanItem{}) - copy(items[at+1:], items[at:]) - items[at] = v - p.Waves[target].Items = items - } else if item.Undo.CreatedWave && item.Undo.CreatedWaveImage != nil && len(p.Waves[from].Items) == 0 && reflect.DeepEqual(p.Waves[from], *item.Undo.CreatedWaveImage) { - p.Waves = append(p.Waves[:from], p.Waves[from+1:]...) - } - return s.workstreamWakeSavePlanIn(ctx, tx, p) -} -func (s *Store) workstreamWakePageUndoIn(ctx context.Context, tx *sql.Tx, w Workstream, event WakeEvent, item *WakeItem) error { - p, _, err := livePageIn(ctx, tx, item.PageID) - if errors.Is(err, ErrNotFound) { - return ErrConflict - } - if err != nil { - return err - } - if p.WorkstreamID != w.ID || p.Owner != "agent" || p.Version != item.Undo.Version || item.Undo.Page == nil { - return ErrConflict - } - // Sibling compaction or reordering alone must not block undo. - postImage := *item.Undo.Page - postImage.Position = p.Position - if !reflect.DeepEqual(p.PageNode, postImage) { - return ErrConflict - } - if item.Undo.Created { - // A newly attached child is a later change; never delete another person's page. - var children bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_pages WHERE parent_id=? AND deleted_at IS NULL)", p.ID).Scan(&children); err != nil { - return err - } - if children { - return ErrConflict - } - if _, err := tx.ExecContext(ctx, "UPDATE workstream_pages SET deleted_at=?,deleted_root=? WHERE id=?", s.now().UnixMilli(), p.ID, p.ID); err != nil { - return err - } - _, err = tx.ExecContext(ctx, "UPDATE workstream_pages SET position=position-1 WHERE workstream_id=? AND parent_id IS ? AND position>? AND deleted_at IS NULL", w.ID, nullString(p.ParentID), p.Position) - return err - } - // Wake updates never change titles, so undo restores only the document. - var doc string - if err := tx.QueryRowContext(ctx, "SELECT doc FROM workstream_page_versions WHERE page_id=? AND version=?", p.ID, item.Undo.PreviousVersion).Scan(&doc); err != nil { - return found(err) - } - source := workstreamWakeSource(w, event) - if source.Label == "" { - source.Label = "Workstream wake" - } - raw, err := sourceJSON(source) - if err != nil { - return err - } - return workstreamSavePageIn(ctx, tx, p, p.Title, json.RawMessage(doc), "agent", raw, s.now().UnixMilli()) -} - -// ActOnWake applies one item atomically or all eligible items best effort. -// Undo walks backwards so dependent changes on the same target unwind in order. -func (s *Store) ActOnWake(ctx context.Context, id, itemID, action string) (WakeRun, Change, error) { - var out WakeRun - var change Change - if action != "apply" && action != "undo" && action != "not_now" { - return out, change, invalid("unknown wake action") - } - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - run, err := workstreamWakeIn(ctx, tx, id) - if err != nil { - return err - } - w, err := getIn(ctx, tx, run.WorkstreamID) - if err != nil { - return err - } - if itemID != "" { - found := false - for _, item := range run.Items { - found = found || item.ID == itemID - } - if !found { - return ErrNotFound - } - } - if action != "not_now" && w.Stage == "done" { - return ErrConflict - } - parts := map[ChangedPart]bool{} - changed := false - for step := range run.Items { - pos := step - if action == "undo" { - pos = len(run.Items) - 1 - step - } - item := run.Items[pos] - if itemID != "" && item.ID != itemID { - continue - } - source := "proposed" - if action == "undo" { - source = "applied" - } - if item.State != source { - continue - } - before := item - if action == "not_now" { - item.State = "dismissed" - run.DismissedAt = instant(s.now().UnixMilli()) - } else { - var part ChangedPart - if action == "apply" { - part, err = s.workstreamWakeApplyIn(ctx, tx, w, run.Event, &item) - } else { - part, err = s.workstreamWakeUndoIn(ctx, tx, w, run.Event, &item) - } - if errors.Is(err, ErrConflict) && itemID == "" { - item = before - item.Note = "Its target is gone; not applied." - if action == "undo" { - item.Note = "Changed since; not undone." - } - err = nil - } else if err != nil { - return err - } else { - parts[part] = true - } - } - if item == before { - continue - } - if err := workstreamSaveWakeItemIn(ctx, tx, id, pos, item); err != nil { - return err - } - run.Items[pos] = item - changed = true - } - if changed { - if _, err := tx.ExecContext(ctx, "UPDATE workstream_wakes SET dismissed_at=? WHERE id=?", workstreamWakeTime(run.DismissedAt), id); err != nil { - return err - } - if err := touch(ctx, tx, w.ID, s.now().UnixMilli()); err != nil { - return err - } - change = workstreamWakeChange(w.ID, parts) - } - out = run - return nil - }) - if err != nil { - return WakeRun{}, Change{}, err - } - return out, change, nil -} diff --git a/internal/workstream/wake_answer.go b/internal/workstream/wake_answer.go deleted file mode 100644 index 7b74bcdf46..0000000000 --- a/internal/workstream/wake_answer.go +++ /dev/null @@ -1,132 +0,0 @@ -package workstream - -import ( - "encoding/json" - "errors" - "math" - "strings" - "unicode/utf8" - - "github.com/Agent-Field/codeaf/internal/workspace" - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -const WakeItemsMax = 10 - -// WakeProposal contains only model suggestions; result references are engine owned. -type WakeProposal struct { - Kind string `json:"kind"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - DraftID string `json:"draft_id,omitempty"` - ToWave int `json:"to_wave,omitempty"` - Wave int `json:"wave,omitempty"` - Title string `json:"title,omitempty"` - Repo string `json:"repo,omitempty"` - Text string `json:"text,omitempty"` - Size string `json:"size,omitempty"` - BudgetUSD *float64 `json:"budget_usd,omitempty"` - PageID string `json:"page_id,omitempty"` - Markdown string `json:"markdown,omitempty"` - Why string `json:"why,omitempty"` -} - -type WakeAnswer struct { - Summary string - Items []WakeProposal -} - -// ParseWakeAnswer reads the first object, including an object inside a JSON fence. -// Invalid items are dropped independently so one bad suggestion cannot hide good ones. -func ParseWakeAnswer(text string) (WakeAnswer, error) { - start := strings.IndexByte(text, '{') - if start < 0 { - return WakeAnswer{}, errors.New("no answer object") - } - var raw struct { - Summary *string `json:"summary"` - Items []json.RawMessage `json:"items"` - } - if err := json.NewDecoder(strings.NewReader(text[start:])).Decode(&raw); err != nil { - return WakeAnswer{}, err - } - if raw.Summary == nil || raw.Items == nil { - return WakeAnswer{}, errors.New("answer needs summary and items") - } - out := WakeAnswer{Summary: workstreamWakeClip(*raw.Summary, 2000), Items: []WakeProposal{}} - for _, item := range raw.Items { - var p WakeProposal - if json.Unmarshal(item, &p) != nil { - continue - } - p = workstreamWakeProposal(p) - if !workstreamWakeValid(p) { - continue - } - p.Why = workstreamWakeClip(strings.Join(strings.Fields(p.Why), " "), 200) - out.Items = append(out.Items, p) - if len(out.Items) == WakeItemsMax { - break - } - } - return out, nil -} - -func workstreamWakeClip(text string, limit int) string { - if !utf8.ValidString(text) { - text = strings.ToValidUTF8(text, "") - } - if len(text) <= limit { - return text - } - for limit > 0 && !utf8.RuneStart(text[limit]) { - limit-- - } - return text[:limit] -} - -func workstreamWakeValid(p WakeProposal) bool { - switch p.Kind { - case "move": - job := p.Factory != "" && p.Job > 0 && p.DraftID == "" && lineWithin(p.Factory, 4096) - draft := p.DraftID != "" && p.Factory == "" && p.Job == 0 && lineWithin(p.DraftID, 4096) - return p.ToWave > 0 && (job || draft) - case "draft": - return workstreamWakeDraftValid(p) - case "page": - return (p.PageID != "" || validTitle(p.Title) == nil) && (p.Title == "" || validTitle(p.Title) == nil) && lineWithin(p.PageID, 4096) && textWithin(p.Markdown, DocMax) && len(pagedoc.FromMarkdown(p.Markdown)) <= DocMax - case "decision", "question": - return recordText(p.Kind, p.Text) == nil - } - return false -} - -// Fields belonging to another kind are ignored, never result identities or undo targets. -func workstreamWakeProposal(p WakeProposal) WakeProposal { - base := WakeProposal{Kind: p.Kind, Why: p.Why} - switch p.Kind { - case "move": - base.Factory, base.Job, base.DraftID, base.ToWave = p.Factory, p.Job, p.DraftID, p.ToWave - case "draft": - base.Title, base.Repo, base.Text, base.Size, base.BudgetUSD, base.Wave = p.Title, p.Repo, p.Text, p.Size, p.BudgetUSD, p.Wave - case "page": - base.PageID, base.Title, base.Markdown = p.PageID, p.Title, p.Markdown - case "decision", "question": - base.Text = p.Text - } - return base -} - -func workstreamWakeDraftValid(p WakeProposal) bool { - if validTitle(p.Title) != nil || !lineWithin(p.Repo, 200) || !textWithin(p.Text, PlanDraftTextMax) || p.Wave < 0 { - return false - } - if p.Size != "" && p.Size != "S" && p.Size != "M" && p.Size != "L" { - return false - } - if p.BudgetUSD == nil { - return true - } - b := *p.BudgetUSD - return !math.IsNaN(b) && !math.IsInf(b, 0) && b > 0 && b <= workspace.WorkstreamGoAheadCapMax -} diff --git a/internal/workstream/wake_answer_test.go b/internal/workstream/wake_answer_test.go deleted file mode 100644 index 77e0618e9b..0000000000 --- a/internal/workstream/wake_answer_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package workstream - -import ( - "strings" - "testing" -) - -func TestWakeC_ParseAnswer(t *testing.T) { - answer, err := ParseWakeAnswer("Words ```json\n" + `{"summary":"Next steps","ignored":true,"items":[{"kind":"move","factory":"f","job":1,"to_wave":3,"why":"one\nline"},{"kind":"draft","title":"Follow up","budget_usd":3},{"kind":"draft","title":"Invalid","budget_usd":0},{"kind":"page","page_id":"p","markdown":"Updated"},{"kind":"decision","text":"Keep this"},{"kind":"unknown"}]}` + "\n```") - if err != nil || len(answer.Items) != 4 || answer.Items[0].Why != "one line" { - t.Fatalf("answer=%+v err=%v", answer, err) - } - if _, err := ParseWakeAnswer("no answer"); err == nil { - t.Fatal("unreadable answer accepted") - } - if _, err := ParseWakeAnswer(`{"summary":"oops"`); err == nil { - t.Fatal("partial answer accepted") - } -} - -func TestWakeC_ParseBoundsAndOrder(t *testing.T) { - text := `{"summary":"` + strings.Repeat("é", 1100) + `","items":[` - for i := 0; i < 12; i++ { - if i > 0 { - text += "," - } - text += `{"kind":"question","text":"What next?","why":"` + strings.Repeat("é", 110) + `"}` - } - answer, err := ParseWakeAnswer(text + `],"unknown":123}`) - if err != nil || len(answer.Items) != 10 || len(answer.Summary) > 2000 || len(answer.Items[0].Why) > 200 { - t.Fatalf("bounds: %+v %v", answer, err) - } -} - -func TestWakeE_AnswerFieldsCannotChooseAnotherKindsUndoTarget(t *testing.T) { - answer, err := ParseWakeAnswer(`{"summary":"Next","items":[{"kind":"draft","title":"Follow up","factory":"f","job":7,"draft_id":"existing","page_id":"person","budget_usd":3},{"kind":"decision","text":"Review","page_id":"person"}]}`) - if err != nil || len(answer.Items) != 2 { - t.Fatal(answer, err) - } - p := answer.Items[0] - if p.Factory != "" || p.Job != 0 || p.DraftID != "" || p.PageID != "" || answer.Items[1].PageID != "" { - t.Fatal("foreign kind fields survived", answer) - } -} diff --git a/internal/workstream/wakes.go b/internal/workstream/wakes.go deleted file mode 100644 index 0f1fb6644f..0000000000 --- a/internal/workstream/wakes.go +++ /dev/null @@ -1,500 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "math" - "sort" - "strconv" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const ( - PartWakes ChangedPart = "wakes" - WakeWorkstreamDayMax = 24 - WakeHomeDayMax = 200 - WakeAbandonedAfter = 15 * time.Minute - WakeStoppedError = "Stopped before it finished." - WakeAnswerError = "The agent's answer could not be read." -) - -var ErrWakeCap = errors.New("workstream wake daily cap reached") - -type WakeEvent struct { - Kind string `json:"kind"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - Label string `json:"label,omitempty"` - Detail string `json:"detail,omitempty"` - At time.Time `json:"at"` -} - -type WakeItem struct { - ID string - Kind string - State string - Text string - Why string - Factory string - Job int - DraftID string - Wave int - PageID string - RecordID string - BudgetUSD *float64 - Note string - AppliedAt time.Time - UndoneAt time.Time - Proposal WakeProposal - Undo workstreamWakeUndo -} - -type WakeRun struct { - ID string - WorkstreamID string - Event WakeEvent - State string - StartedAt time.Time - SettledAt time.Time - CostUSD *float64 - Summary string - Error string - DismissedAt time.Time - Items []WakeItem -} - -type workstreamWakeUndo struct { - Wave int - Position int - Version int - PreviousVersion int - Created bool - CreatedWave bool - CreatedWaveImage *Wave - PlanItem PlanItem - Page *PageNode -} - -// BeginWake claims the event before any model call, under the stage and cost guards. -// A losing claimant returns false, including another process using the same home. -func (s *Store) BeginWake(ctx context.Context, id, key string, event WakeEvent) (WakeRun, bool, error) { - var out WakeRun - if key == "" || !lineWithin(event.Label, 200) || !textWithin(event.Detail, 4000) { - return out, false, invalid("invalid wake event") - } - switch event.Kind { - case "morning", "finished", "merged", "failed", "needs_you": - default: - return out, false, invalid("unknown wake kind") - } - woke := false - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - if event.Kind != "morning" { - if event.Factory == "" || event.Job <= 0 { - return invalid("job event needs factory and positive job") - } - var owner string - err := tx.QueryRowContext(ctx, "SELECT workstream_id FROM workstream_jobs WHERE factory=? AND job=?", event.Factory, strconv.Itoa(event.Job)).Scan(&owner) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return err - } - if id != "" && id != owner { - return nil - } - id = owner - } - w, err := getIn(ctx, tx, id) - if errors.Is(err, ErrNotFound) { - return nil - } - if err != nil { - return err - } - if w.Stage == "done" || w.Autonomy == "off" { - return nil - } - if event.Kind == "morning" && (!w.Wake.Morning || event.At.Before(w.CreatedAt)) { - return nil - } - var exists bool - if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workstream_wakes WHERE workstream_id=? AND event_key=?)", id, key).Scan(&exists); err != nil { - return err - } - if exists { - return nil - } - now := s.now() - local := now.In(time.Local) - day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local) - var home, stream int - if err := tx.QueryRowContext(ctx, "SELECT count(*),coalesce(sum(workstream_id=?),0) FROM workstream_wakes WHERE started_at>=? AND started_at= WakeHomeDayMax { - return fmt.Errorf("%w: at most %d per home per local day", ErrWakeCap, WakeHomeDayMax) - } - if stream >= WakeWorkstreamDayMax { - return fmt.Errorf("%w: at most %d per local day for %s", ErrWakeCap, WakeWorkstreamDayMax, id) - } - wakeID, err := s.newID() - if err != nil { - return err - } - raw, err := json.Marshal(event) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, "INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,started_at) VALUES (?,?,?,?,'running',?)", wakeID, id, key, string(raw), now.UnixMilli()); err != nil { - return err - } - out = WakeRun{ID: wakeID, WorkstreamID: id, Event: event, State: "running", StartedAt: instant(now.UnixMilli()), Items: []WakeItem{}} - woke = true - return nil - }) - return out, woke, err -} - -func workstreamWakeIn(ctx context.Context, tx *sql.Tx, id string) (WakeRun, error) { - out := WakeRun{Items: []WakeItem{}} - var raw string - var started int64 - var settled, dismissed sql.NullInt64 - var cost sql.NullFloat64 - err := tx.QueryRowContext(ctx, "SELECT id,workstream_id,event_json,state,summary,error,cost_usd,started_at,settled_at,dismissed_at FROM workstream_wakes WHERE id=?", id).Scan(&out.ID, &out.WorkstreamID, &raw, &out.State, &out.Summary, &out.Error, &cost, &started, &settled, &dismissed) - if err != nil { - return out, found(err) - } - if err := json.Unmarshal([]byte(raw), &out.Event); err != nil { - return out, err - } - out.StartedAt, out.SettledAt, out.DismissedAt = instant(started), optionalInstant(settled), optionalInstant(dismissed) - if cost.Valid { - out.CostUSD = &cost.Float64 - } - rows, err := tx.QueryContext(ctx, "SELECT item_json FROM workstream_wake_items WHERE wake_id=? ORDER BY position", id) - if err != nil { - return out, err - } - defer rows.Close() - for rows.Next() { - var raw string - if err := rows.Scan(&raw); err != nil { - return out, err - } - var item WakeItem - if err := json.Unmarshal([]byte(raw), &item); err != nil { - return out, err - } - out.Items = append(out.Items, item) - } - return out, rows.Err() -} - -func workstreamSaveWakeItemIn(ctx context.Context, tx *sql.Tx, wake string, pos int, item WakeItem) error { - raw, err := json.Marshal(item) - if err != nil { - return err - } - undo, err := json.Marshal(item.Undo) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,why,item_json,undo_json,note,applied_at,undone_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(id) DO UPDATE SET position=excluded.position,state=excluded.state,item_json=excluded.item_json,undo_json=excluded.undo_json,note=excluded.note,applied_at=excluded.applied_at,undone_at=excluded.undone_at`, item.ID, wake, pos, item.Kind, item.State, item.Why, string(raw), string(undo), item.Note, workstreamWakeTime(item.AppliedAt), workstreamWakeTime(item.UndoneAt)) - return err -} -func workstreamWakeTime(at time.Time) any { - if at.IsZero() { - return nil - } - return at.UnixMilli() -} - -// WakeRun lets the worker recheck a durable claim after waiting in its queue. -func (s *Store) WakeRun(ctx context.Context, id string) (WakeRun, error) { - var out WakeRun - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < workspace.WorkstreamWakeSchemaVersion { - return ErrNotFound - } - var err error - out, err = workstreamWakeIn(ctx, tx, id) - return err - }) - return out, err -} - -func (s *Store) Wakes(ctx context.Context, id string, limit int) ([]WakeRun, error) { - if limit == 0 { - limit = 20 - } - if limit < 1 || limit > 100 { - return nil, invalid("wake limit must be 1–100") - } - out := []WakeRun{} - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < 3 { - return ErrNotFound - } - if _, err := getIn(ctx, tx, id); err != nil { - return err - } - if v < workspace.WorkstreamWakeSchemaVersion { - return nil - } - rows, err := tx.QueryContext(ctx, "SELECT id FROM workstream_wakes WHERE workstream_id=? ORDER BY started_at DESC,rowid DESC LIMIT ?", id, limit) - if err != nil { - return err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return err - } - ids = append(ids, id) - } - err = rows.Err() - rows.Close() - if err != nil { - return err - } - for _, id := range ids { - w, err := workstreamWakeIn(ctx, tx, id) - if err != nil { - return err - } - out = append(out, w) - } - return nil - }) - return out, err -} - -// WakeSpendByAgent preserves unknown conversation spend at the adapter boundary. -func (s *Store) WakeSpendByAgent(ctx context.Context, agent string) (float64, error) { - var cost float64 - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < workspace.WorkstreamWakeSchemaVersion { - return nil - } - return tx.QueryRowContext(ctx, "SELECT coalesce(sum(r.cost_usd),0) FROM workstream_wakes r JOIN workstreams w ON w.id=r.workstream_id WHERE w.agent_id=?", agent).Scan(&cost) - }) - return cost, err -} - -// ExpireWakes does not take a writer or migrate a schema-3 file unless a stale row exists. -func (s *Store) ExpireWakes(ctx context.Context) ([]Change, error) { - ids := []string{} - cutoff := s.now().Add(-WakeAbandonedAfter).UnixMilli() - err := s.workspace.Read(ctx, func(tx *sql.Tx, v int) error { - if v < workspace.WorkstreamWakeSchemaVersion { - return nil - } - rows, err := tx.QueryContext(ctx, "SELECT DISTINCT workstream_id FROM workstream_wakes WHERE state='running' AND started_at 0 { - if err := touch(ctx, tx, id, s.now().UnixMilli()); err != nil { - return err - } - changes = append(changes, changed(id, PartWakes)) - } - } - return nil - }) - return changes, err -} - -func workstreamWakeChange(id string, parts map[ChangedPart]bool) Change { - c := Change{ID: id, Parts: []ChangedPart{PartWakes}} - for _, p := range []ChangedPart{PartWorkstream, PartPlan, PartPages, PartRecords} { - if parts[p] { - c.Parts = append(c.Parts, p) - } - } - return c -} - -func workstreamWakeCost(cost *float64) *float64 { - if cost == nil || math.IsNaN(*cost) || math.IsInf(*cost, 0) || *cost < 0 { - return nil - } - return cost -} - -func (s *Store) SettleWake(ctx context.Context, id string, answer WakeAnswer, cost *float64, failure string) (WakeRun, Change, error) { - var out WakeRun - var change Change - err := s.workspace.Write(ctx, func(tx *sql.Tx) error { - run, err := workstreamWakeIn(ctx, tx, id) - if err != nil { - return err - } - out = run - if run.State != "running" { - return nil - } - w, err := getIn(ctx, tx, run.WorkstreamID) - if err != nil { - return err - } - parts := map[ChangedPart]bool{PartWorkstream: true} - if failure == "" { - pageTargets := map[string]bool{} - for _, p := range answer.Items { - p = workstreamWakeProposal(p) - if len(run.Items) == WakeItemsMax { - break - } - if !workstreamWakeValid(p) { - continue - } - if p.Kind == "page" && p.PageID != "" { - page, _, err := livePageIn(ctx, tx, p.PageID) - if errors.Is(err, ErrNotFound) { - continue - } - if err != nil { - return err - } - if page.WorkstreamID != w.ID || page.Owner != "agent" { - continue - } - p.Title = page.Title - } - if p.Kind == "page" { - target := "title:" + p.Title - if p.PageID != "" { - target = "id:" + p.PageID - } - if pageTargets[target] { - continue - } - pageTargets[target] = true - } - itemID, err := s.newID() - if err != nil { - return err - } - item := WakeItem{ID: itemID, Kind: p.Kind, State: "proposed", Why: p.Why, Factory: p.Factory, Job: p.Job, DraftID: p.DraftID, Wave: p.Wave, PageID: p.PageID, BudgetUSD: p.BudgetUSD, Proposal: p} - if p.Kind == "move" { - item.Wave = p.ToWave - } - if w.Autonomy == "small" && w.Stage != "done" { - ok, err := s.workstreamWakeSmallIn(ctx, tx, w, item) - if err != nil { - return err - } - if ok { - part, err := s.workstreamWakeApplyIn(ctx, tx, w, run.Event, &item) - if err != nil { - return err - } - parts[part] = true - } - } - item.Text = workstreamWakeItemText(item) - run.Items = append(run.Items, item) - } - sort.SliceStable(run.Items, func(i, j int) bool { return run.Items[i].State == "applied" && run.Items[j].State != "applied" }) - for i, item := range run.Items { - if err := workstreamSaveWakeItemIn(ctx, tx, id, i, item); err != nil { - return err - } - } - } - run.State = "settled" - if failure != "" { - run.State = "failed" - } - run.Summary = workstreamWakeClip(answer.Summary, 2000) - run.Error = failure - run.CostUSD = workstreamWakeCost(cost) - run.SettledAt = instant(s.now().UnixMilli()) - if _, err := tx.ExecContext(ctx, "UPDATE workstream_wakes SET state=?,summary=?,error=?,cost_usd=?,settled_at=? WHERE id=?", run.State, run.Summary, run.Error, run.CostUSD, run.SettledAt.UnixMilli(), id); err != nil { - return err - } - if err := touch(ctx, tx, w.ID, s.now().UnixMilli()); err != nil { - return err - } - out = run - change = workstreamWakeChange(w.ID, parts) - return nil - }) - if err != nil { - return WakeRun{}, Change{}, err - } - return out, change, nil -} - -func workstreamWakeItemText(item WakeItem) string { - applied := item.State == "applied" - switch item.Kind { - case "move": - if !applied { - return "Move the plan item to the proposed wave." - } - target := item.DraftID - if item.Factory != "" { - target = fmt.Sprintf("%s#%d", item.Factory, item.Job) - } - verb := "Move" - if applied { - verb = "Moved" - } - return fmt.Sprintf("%s %s to wave %d.", verb, target, item.Wave) - case "draft": - verb := "Add draft: " - if applied { - verb = "Added draft: " - } - return verb + item.Proposal.Title + "." - case "page": - verb := "Update agent page: " - if applied { - verb = "Updated agent page: " - } - return verb + item.Proposal.Title + "." - default: - verb := "Keep " - if applied { - verb = "Kept " - } - return verb + item.Kind + ": " + item.Proposal.Text - } -} diff --git a/internal/workstream/wakes_test.go b/internal/workstream/wakes_test.go deleted file mode 100644 index 159ef773b1..0000000000 --- a/internal/workstream/wakes_test.go +++ /dev/null @@ -1,434 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -func workstreamWakeForTest(t *testing.T, s *Store, w Workstream, key string, items ...WakeProposal) WakeRun { - t.Helper() - ctx := context.Background() - run, woke, err := s.BeginWake(ctx, w.ID, key, WakeEvent{Kind: "merged", Factory: "f", Job: 7, At: s.now(), Label: "api#7 merged"}) - if err != nil || !woke { - t.Fatalf("begin: %+v %v %v", run, woke, err) - } - run, c, err := s.SettleWake(ctx, run.ID, WakeAnswer{Summary: "Review next steps.", Items: items}, nil, "") - if err != nil || c.ID != w.ID { - t.Fatalf("settle: %+v %+v %v", run, c, err) - } - return run -} -func workstreamWakeMember() workspace.Ref { - return workspace.Ref{Kind: workspace.FactoryJobKind, ID: "7", Factory: "f"} -} -func workstreamWakeBudget(n float64) *float64 { return &n } - -func TestWakeC_ProposeChangesNothing(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Proposals", workstreamWakeMember()) - before, _ := s.Plan(ctx, w.ID) - pages, _ := s.Pages(ctx, w.ID) - run := workstreamWakeForTest(t, s, w, "c", WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}, WakeProposal{Kind: "draft", Title: "Follow up", BudgetUSD: workstreamWakeBudget(3)}, WakeProposal{Kind: "page", Title: "Where it stands", Markdown: "Review"}, WakeProposal{Kind: "decision", Text: "Review first"}) - if len(run.Items) != 4 { - t.Fatal(run) - } - for _, i := range run.Items { - if i.State != "proposed" { - t.Fatal(i) - } - } - after, _ := s.Plan(ctx, w.ID) - afterPages, _ := s.Pages(ctx, w.ID) - records, _ := s.Records(ctx, w.ID, "", "") - if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(pages, afterPages) || len(records) != 0 { - t.Fatal("propose changed workstream content") - } -} - -func TestWakeD_SmallClassificationAndUndo(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Small", workstreamWakeMember()) - small := "small" - s.Update(ctx, w.ID, Patch{Autonomy: &small}) - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Where it stands", Owner: "agent"}) - if err != nil { - t.Fatal(err) - } - waves := []Wave{{N: 1, Items: []PlanItem{{Kind: "draft", DraftID: "d_before", Title: "Before"}, {Kind: "job", Factory: "f", Job: 7}, {Kind: "draft", DraftID: "d_after", Title: "After"}}}, {N: 2, Items: []PlanItem{}}, {N: 3, AutoStart: true, Items: []PlanItem{}}, {N: 4, Items: []PlanItem{}}} - before, _, err := s.SetPlan(ctx, w.ID, 0, nil, waves, Attribution{}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.GrantGoAhead(ctx, GoAheadGrant{WorkstreamID: w.ID, Scope: "wave", Wave: 4, CapUSD: 20, EndsAt: s.now().Add(time.Hour)}); err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "d", - WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}, - WakeProposal{Kind: "draft", Title: "Small follow-up", BudgetUSD: workstreamWakeBudget(3), Wave: 2}, - WakeProposal{Kind: "page", PageID: page.ID, Markdown: "Updated"}, - WakeProposal{Kind: "page", Title: "Agent notes", Markdown: "New"}, - WakeProposal{Kind: "draft", Title: "Large follow-up", BudgetUSD: workstreamWakeBudget(7), Wave: 2}, - WakeProposal{Kind: "draft", Title: "Unknown budget", Wave: 2}, - WakeProposal{Kind: "move", DraftID: "d_before", ToWave: 3}, - WakeProposal{Kind: "move", DraftID: "d_after", ToWave: 4}, - WakeProposal{Kind: "decision", Text: "Keep the scope"}, - WakeProposal{Kind: "question", Text: "Ready?"}) - for i, item := range run.Items { - want := "proposed" - if i < 4 { - want = "applied" - } - if item.State != want { - t.Fatalf("%d: %+v", i, item) - } - } - after, _ := s.Plan(ctx, w.ID) - if after.Waves[1].Items[1].Kind != "draft" || after.Waves[1].Items[1].Source.Kind != "factory_job" { - t.Fatal(after) - } - undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID { - t.Fatalf("undo: %+v %v", c, err) - } - restored, _ := s.Plan(ctx, w.ID) - restoredPage, _ := s.Page(ctx, page.ID, false) - pages, _ := s.Pages(ctx, w.ID) - if !reflect.DeepEqual(restored.Waves, before.Waves) || string(restoredPage.Doc) != emptyDoc || restoredPage.Version != 3 || len(pages) != 2 { - t.Fatalf("restore: %+v %+v %+v", restored, restoredPage, pages) - } - for i := 0; i < 4; i++ { - if undone.Items[i].State != "undone" { - t.Fatal(undone.Items) - } - } - if _, c, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil || c.ID != "" { - t.Fatalf("repeat undo: %+v %v", c, err) - } -} - -func TestWakeD_LaterPageAndPlanChangesRefuseUndo(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Stale", workstreamWakeMember()) - small := "small" - s.Update(ctx, w.ID, Patch{Autonomy: &small}) - p, _, _ := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent"}) - s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 7}}}, {N: 2, Items: []PlanItem{}}, {N: 3, Items: []PlanItem{}}}, Attribution{}) - run := workstreamWakeForTest(t, s, w, "stale", WakeProposal{Kind: "page", PageID: p.ID, Markdown: "Wake"}, WakeProposal{Kind: "move", Factory: "f", Job: 7, ToWave: 2}) - s.SavePage(ctx, p.ID, 2, []byte(emptyDoc), Attribution{}) - plan, _ := s.Plan(ctx, w.ID) - plan.Waves[2].Items = plan.Waves[1].Items - plan.Waves[1].Items = []PlanItem{} - s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}) - for _, item := range run.Items { - if _, c, err := s.ActOnWake(ctx, run.ID, item.ID, "undo"); !errors.Is(err, ErrConflict) || c.ID != "" { - t.Fatalf("single stale: %+v %v", c, err) - } - } - noted, _, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil { - t.Fatal(err) - } - for _, item := range noted.Items { - if item.State != "applied" || item.Note != "Changed since; not undone." { - t.Fatal(item) - } - } - _, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != "" { - t.Fatalf("same notes wrote again: %+v %v", c, err) - } -} - -func TestWakeE_PersonAndDeletedPagesAreDropped(t *testing.T) { - for _, autonomy := range []string{"propose", "small"} { - t.Run(autonomy, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Private", workstreamWakeMember()) - s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}) - pages, _ := s.Pages(ctx, w.ID) - person, _ := s.Page(ctx, pages[0].ID, false) - deleted, _, _ := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Deleted", Owner: "agent"}) - s.DeletePage(ctx, deleted.ID) - run := workstreamWakeForTest(t, s, w, "e", WakeProposal{Kind: "page", PageID: person.ID, Markdown: "Changed"}, WakeProposal{Kind: "page", PageID: deleted.ID, Markdown: "Changed"}) - after, _ := s.Page(ctx, person.ID, false) - if len(run.Items) != 0 || !reflect.DeepEqual(person, after) { - t.Fatal("page boundary violated") - } - if _, c, err := s.ActOnWake(ctx, run.ID, "", "apply"); err != nil || c.ID != "" { - t.Fatalf("empty apply: %+v %v", c, err) - } - }) - } -} - -func TestWakeG_ActionsAreAtomicAndIdempotent(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Actions", workstreamWakeMember()) - run := workstreamWakeForTest(t, s, w, "g", WakeProposal{Kind: "decision", Text: "Keep it"}, WakeProposal{Kind: "question", Text: "Ready?"}, WakeProposal{Kind: "move", DraftID: "gone", ToWave: 9}) - applied, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply") - if err != nil || c.ID != w.ID || applied.Items[0].RecordID == "" { - t.Fatalf("apply: %+v %v", applied, err) - } - row, _ := s.Get(ctx, w.ID) - again, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply") - after, _ := s.Get(ctx, w.ID) - if err != nil || c.ID != "" || !reflect.DeepEqual(again, applied) || !row.UpdatedAt.Equal(after.UpdatedAt) { - t.Fatal("repeated apply changed state") - } - all, _, err := s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil || all.Items[1].State != "applied" || all.Items[2].Note != "Its target is gone; not applied." { - t.Fatalf("all: %+v %v", all, err) - } - dismissed, _, err := s.ActOnWake(ctx, run.ID, "", "not_now") - if err != nil || dismissed.DismissedAt.IsZero() || dismissed.Items[2].State != "dismissed" { - t.Fatal(dismissed, err) - } - if _, c, err := s.ActOnWake(ctx, run.ID, "", "not_now"); err != nil || c.ID != "" { - t.Fatal("repeat dismissal changed state") - } - if _, _, err := s.ActOnWake(ctx, "unknown", "", "apply"); !errors.Is(err, ErrNotFound) { - t.Fatal(err) - } - if _, _, err := s.ActOnWake(ctx, run.ID, "unknown", "apply"); !errors.Is(err, ErrNotFound) { - t.Fatal(err) - } - if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { - t.Fatal(err) - } - records, _ := s.Records(ctx, w.ID, "", "") - for _, r := range records { - if r.State != "dropped" { - t.Fatal(r) - } - } - s.MarkDone(ctx, w.ID, "") - for _, action := range []string{"apply", "undo"} { - if _, _, err := s.ActOnWake(ctx, run.ID, "", action); !errors.Is(err, ErrConflict) { - t.Fatal(err) - } - } -} - -func TestWakeB_DeduplicationCapsAndAbandonment(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Events", workstreamWakeMember()) - event := WakeEvent{Kind: "finished", Factory: "f", Job: 7, At: *now} - first, woke, err := s.BeginWake(ctx, "", "once", event) - if err != nil || !woke { - t.Fatal(err) - } - if _, woke, err := s.BeginWake(ctx, "", "once", event); err != nil || woke { - t.Fatal("duplicate woke", err) - } - for i := 1; i < WakeWorkstreamDayMax; i++ { - if _, woke, err := s.BeginWake(ctx, w.ID, fmt.Sprint(i), event); err != nil || !woke { - t.Fatal(i, err) - } - } - if _, woke, err := s.BeginWake(ctx, w.ID, "capped", event); !errors.Is(err, ErrWakeCap) || woke { - t.Fatal("cap not enforced", err) - } - *now = now.Add(WakeAbandonedAfter + time.Millisecond) - changes, err := s.ExpireWakes(ctx) - if err != nil || len(changes) != 1 { - t.Fatal(changes, err) - } - runs, _ := s.Wakes(ctx, w.ID, 100) - if len(runs) != WakeWorkstreamDayMax { - t.Fatal(len(runs)) - } - for _, r := range runs { - if r.State != "failed" || r.Error != WakeStoppedError || r.CostUSD != nil { - t.Fatal(r) - } - } - if run, c, err := s.SettleWake(ctx, first.ID, WakeAnswer{}, workstreamWakeBudget(.04), ""); err != nil || c.ID != "" || run.CostUSD != nil { - t.Fatal("late worker overwrote expired wake") - } -} - -func TestWakeI_SchemaThreeWakeListIsReadOnly(t *testing.T) { - path := filepath.Join(t.TempDir(), "three.db") - ws, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - s := New(ws, Options{}) - w := createStream(t, s, "Earlier") - if err := ws.Write(context.Background(), func(tx *sql.Tx) error { - _, err := tx.Exec("DROP TABLE workstream_wake_items; DROP TABLE workstream_wakes; PRAGMA user_version=3") - return err - }); err != nil { - t.Fatal(err) - } - ws.Close() - before, _ := os.ReadFile(path) - ws, err = workspace.Open(path) - if err != nil { - t.Fatal(err) - } - s = New(ws, Options{}) - wakes, err := s.Wakes(context.Background(), w.ID, 20) - if err != nil || len(wakes) != 0 { - t.Fatal(wakes, err) - } - if changes, err := s.ExpireWakes(context.Background()); err != nil || len(changes) != 0 { - t.Fatal(changes, err) - } - ws.Close() - after, _ := os.ReadFile(path) - if string(before) != string(after) { - t.Fatal("wake read migrated schema 3") - } -} - -func TestWakeB_HomeCapAndLocalDayReset(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - event := WakeEvent{Kind: "morning", At: *now} - var last Workstream - for i := 0; i < 9; i++ { - last = createStream(t, s, fmt.Sprintf("Stream %d", i)) - s.Update(ctx, last.ID, Patch{Wake: &Wake{Morning: true}}) - n := WakeWorkstreamDayMax - if i == 8 { - n = 8 - } - for j := 0; j < n; j++ { - if _, woke, err := s.BeginWake(ctx, last.ID, fmt.Sprintf("%d/%d", i, j), event); err != nil || !woke { - t.Fatal(i, j, err) - } - } - } - if _, woke, err := s.BeginWake(ctx, last.ID, "home-capped", event); !errors.Is(err, ErrWakeCap) || woke { - t.Fatal("home cap ignored", err) - } - *now = now.In(time.Local).AddDate(0, 0, 1) - event.At = *now - if _, woke, err := s.BeginWake(ctx, last.ID, "next-day", event); err != nil || !woke { - t.Fatal("cap did not reset", err) - } -} - -func TestWakeB_DuplicateAcrossStoreHandles(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "claims.db") - a, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer a.Close() - first := New(a, Options{}) - w := createStream(t, first, "Shared", workstreamWakeMember()) - b, err := workspace.Open(path) - if err != nil { - t.Fatal(err) - } - defer b.Close() - second := New(b, Options{}) - event := WakeEvent{Kind: "failed", Factory: "f", Job: 7, At: time.Now()} - if _, woke, err := first.BeginWake(ctx, w.ID, "same", event); err != nil || !woke { - t.Fatal(err) - } - if _, woke, err := second.BeginWake(ctx, w.ID, "same", event); err != nil || woke { - t.Fatal("second handle won", err) - } - runs, err := second.Wakes(ctx, w.ID, 20) - if err != nil || len(runs) != 1 { - t.Fatal(runs, err) - } -} - -func TestWakeD_ExplicitApplyKeepsRecordsAndAgentSources(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Explicit", workstreamWakeMember()) - run := workstreamWakeForTest(t, s, w, "explicit", WakeProposal{Kind: "draft", Title: "Costly follow-up", BudgetUSD: workstreamWakeBudget(7)}, WakeProposal{Kind: "page", Title: "Spec", Markdown: "Keep these words"}, WakeProposal{Kind: "decision", Text: "The rollout is ready"}) - off := "off" - s.Update(ctx, w.ID, Patch{Autonomy: &off}) - applied, _, err := s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil { - t.Fatal(err) - } - for _, item := range applied.Items { - if item.State != "applied" { - t.Fatal(item) - } - } - plan, _ := s.Plan(ctx, w.ID) - if len(plan.Waves) != 1 || plan.Waves[0].AutoStart || plan.Waves[0].Items[0].Source.Kind != "factory_job" { - t.Fatal(plan) - } - page, _ := s.Page(ctx, applied.Items[1].PageID, false) - if page.Owner != "agent" || page.UpdatedBy != "agent" || page.UpdatedFrom.Factory != "f" || !page.ReadByJobs { - t.Fatal(page) - } - r, err := s.Records(ctx, w.ID, "decision", "kept") - if err != nil || len(r) != 1 || r[0].By != "agent" || r[0].Source.Kind != "factory_job" { - t.Fatal(r, err) - } - if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { - t.Fatal(err) - } - restored, _ := s.Plan(ctx, w.ID) - if len(restored.Waves) != 0 { - t.Fatal("created wave remained", restored) - } - deleted, err := s.Page(ctx, page.ID, false) - if err != nil || deleted.DeletedAt.IsZero() { - t.Fatal("new page was not restorably deleted", deleted, err) - } -} - -func TestWakeG_NamedApplyConflictRollsBackAndAllKeepsNote(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Gone", workstreamWakeMember()) - run := workstreamWakeForTest(t, s, w, "gone", WakeProposal{Kind: "move", DraftID: "missing", ToWave: 7}) - if _, _, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "apply"); !errors.Is(err, ErrConflict) { - t.Fatal(err) - } - runs, _ := s.Wakes(ctx, w.ID, 20) - if !reflect.DeepEqual(runs[0], run) { - t.Fatal("conflict wrote") - } - noted, _, err := s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil || noted.Items[0].State != "proposed" || noted.Items[0].Note != "Its target is gone; not applied." { - t.Fatal(noted, err) - } -} - -func TestWakeE_DraftUndoCannotTargetAMemberJob(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Identities", workstreamWakeMember()) - if _, _, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}, Attribution{}); err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "identities", WakeProposal{Kind: "draft", Title: "Follow up", Factory: "f", Job: 7, DraftID: "untrusted", BudgetUSD: workstreamWakeBudget(3)}) - if _, _, err := s.ActOnWake(ctx, run.ID, "", "apply"); err != nil { - t.Fatal(err) - } - if _, _, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil { - t.Fatal(err) - } - plan, _ := s.Plan(ctx, w.ID) - if len(plan.Waves[0].Items) != 1 || plan.Waves[0].Items[0].Kind != "job" || plan.Waves[0].Items[0].Job != 7 { - t.Fatal("undo touched member job", plan) - } -} diff --git a/internal/workstream/workstream.go b/internal/workstream/workstream.go deleted file mode 100644 index f9dc634ab8..0000000000 --- a/internal/workstream/workstream.go +++ /dev/null @@ -1,264 +0,0 @@ -// Package workstream owns workstream rules and durable state. Adapters supply -// job observations and recorded spend; execution owners stay outside this package. -package workstream - -import ( - "encoding/json" - "errors" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -const ( - DocMax = 1 << 20 - PlanDraftTextMax = 80_000 - PlanMilestonesMax = 500 - PlanWavesMax = 500 - PlanItemsMax = 5_000 - PlanEncodedMax = 4 << 20 - PagesMax = 5_000 - RecordsMax = 5_000 - emptyDoc = `{"type":"doc","content":[]}` -) - -var ( - ErrInvalid = workspace.ErrInvalid - ErrNotFound = errors.New("workstream target not found") - ErrConflict = errors.New("workstream conflict") - ErrCycle = errors.New("page move would form a cycle") - ErrTooLarge = errors.New("encoded document exceeds 1 MiB") - ErrLimit = errors.New("workstream limit exceeded") -) - -type ChangedPart string - -const ( - PartWorkstream ChangedPart = "workstream" - PartPlan ChangedPart = "plan" - PartPages ChangedPart = "pages" - PartRecords ChangedPart = "records" - PartGoAheads ChangedPart = "goaheads" - PartJobs ChangedPart = "jobs" - PartSources ChangedPart = "sources" -) - -// Change is empty for an unchanged write. Adapters emit it only after success. -type Change struct { - ID string - Parts []ChangedPart -} - -type VersionConflict struct { - ID string - Version int - UpdatedAt time.Time - UpdatedBy string -} - -func (e *VersionConflict) Error() string { return "the version changed since it was read" } - -type Wake struct{ Morning, RelatedIssues bool } -type Workstream struct { - ID string - Name string - Why string - OriginChatID string - AgentID string - Autonomy string - HowItWent string - ClientID string - Wake Wake - BudgetUSD *float64 - CreatedAt time.Time - UpdatedAt time.Time - StartedAt time.Time - DoneAt time.Time - Stage string - Members []workspace.Ref - Proposed int - Total int -} -type Create struct { - Name string - Why string - OriginChatID string - AgentID string - ClientID string - Refs []workspace.Ref - Sources []SourceAdd -} -type Patch struct { - Name, Why, Autonomy *string - Wake *Wake - BudgetUSD *float64 -} - -type Job struct { - Factory string - Number int -} - -// JobFacts is absent from the facts map until a job was successfully read. -// Individual facts may remain unknown even when its state is known. -type JobFacts struct { - State string - Merged *bool - CostUSD *float64 -} -type Numbers struct { - Total int - Merged, NeedsYou *int - SpendUSD *float64 -} - -type Source struct { - Kind string `json:"kind"` - ID string `json:"id"` - ChatID string `json:"chat_id,omitempty"` - Factory string `json:"factory,omitempty"` - Label string `json:"label,omitempty"` -} - -// Attribution permits later agent tools to use the same domain operations. -// A zero attribution means the person; proposal authors default to agent. -type Attribution struct { - By string - From *Source -} - -type Milestone struct { - ID string `json:"id"` - Title string `json:"title"` -} -type PlanItem struct { - Kind string `json:"kind"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - DraftID string `json:"draft_id,omitempty"` - Title string `json:"title,omitempty"` - Repo string `json:"repo,omitempty"` - Text string `json:"text,omitempty"` - Size string `json:"size,omitempty"` - BudgetUSD float64 `json:"budget_usd,omitempty"` - Source *Source `json:"source,omitempty"` -} -type Wave struct { - N int `json:"n"` - Title string `json:"title,omitempty"` - MilestoneID string `json:"milestone_id,omitempty"` - AutoStart bool `json:"auto_start"` - Items []PlanItem `json:"items"` -} -type Plan struct { - WorkstreamID string - Version int - UpdatedAt time.Time - UpdatedBy string - Milestones []Milestone - Waves []Wave -} - -type PageNode struct { - ID string - WorkstreamID string - ParentID string - Title string - Owner string - ReadByJobs bool - Position, Version int - UpdatedAt time.Time - UpdatedBy string - UpdatedFrom *Source - DeletedAt time.Time -} -type Page struct { - PageNode - Doc json.RawMessage - Markdown *string -} -type PageVersion struct { - Version int - SavedAt time.Time - SavedBy string - From *Source - Title string - Bytes int -} -type PageCreate struct { - WorkstreamID string - ParentID string - AfterID string - Title string - Owner string - ReadByJobs bool - Doc json.RawMessage - Attribution -} -type PagePatch struct { - Title, Owner *string - ReadByJobs *bool - Attribution -} - -type Record struct { - ID string - WorkstreamID string - Kind string - Text string - State string - By string - Source *Source - CreatedAt, DecidedAt time.Time - Supersedes, SupersededBy string - GoAhead *GoAheadProposal - GoAheadID string -} -type RecordPropose struct { - WorkstreamID, Kind, Text string - Source *Source - Kept bool - By string - GoAhead *GoAheadProposal -} -type RecordReplacement struct { - Kind, Text string - Source *Source - By string -} - -type GoAhead struct { - ID string - WorkstreamID string - Scope string - Factory string - MilestoneID string - Job, Wave int - CapUSD float64 - EndsAt time.Time - State, EndReason string - GrantedAt, EndedAt time.Time -} -type GoAheadGrant struct { - WorkstreamID string - Scope string - Factory string - MilestoneID string - Job, Wave int - CapUSD float64 - EndsAt time.Time -} - -type GoAheadProposal struct { - Scope string `json:"scope"` - Factory string `json:"factory,omitempty"` - Job int `json:"job,omitempty"` - Wave int `json:"wave,omitempty"` - MilestoneID string `json:"milestone_id,omitempty"` - CapUSD float64 `json:"cap_usd"` - EndsAt time.Time `json:"ends_at"` -} - -func (p GoAheadProposal) grant(id string) GoAheadGrant { - return GoAheadGrant{WorkstreamID: id, Scope: p.Scope, Factory: p.Factory, Job: p.Job, Wave: p.Wave, MilestoneID: p.MilestoneID, CapUSD: p.CapUSD, EndsAt: p.EndsAt} -} diff --git a/internal/workstream/workstream_agent.go b/internal/workstream/workstream_agent.go deleted file mode 100644 index 395c02d8c0..0000000000 --- a/internal/workstream/workstream_agent.go +++ /dev/null @@ -1,57 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "errors" -) - -var ErrNotAgentPage = errors.New("the agent writes only its own pages") - -var ErrDone = errors.New("the workstream is done") - -// workstreamWriteAllowed checks done in the transaction that owns the write. -func workstreamWriteAllowed(ctx context.Context, tx *sql.Tx, id, by string) error { - w, err := getIn(ctx, tx, id) - if err != nil { - return err - } - if by == "agent" && !w.DoneAt.IsZero() { - return ErrDone - } - return nil -} - -func workstreamPageWriteAllowed(ctx context.Context, tx *sql.Tx, p Page, by string) error { - if by != "agent" { - return nil - } - if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, by); err != nil { - return err - } - if p.Owner != "agent" { - return ErrNotAgentPage - } - return nil -} - -// ByAgent resolves the owner at call time because its conversation opens first. -func (s *Store) ByAgent(ctx context.Context, agentID string) (Workstream, error) { - var out Workstream - if agentID == "" { - return out, ErrNotFound - } - err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { - if version < 3 { - return ErrNotFound - } - var id string - if err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE agent_id=?", agentID).Scan(&id); err != nil { - return found(err) - } - var err error - out, err = getIn(ctx, tx, id) - return err - }) - return out, err -} diff --git a/internal/workstream/workstream_agent_done_test.go b/internal/workstream/workstream_agent_done_test.go deleted file mode 100644 index c7e3f44300..0000000000 --- a/internal/workstream/workstream_agent_done_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package workstream - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "testing" - "time" -) - -type workstreamWriteSnapshot struct { - stream Workstream - records []Record - pages []Page - history map[string][]PageVersion -} - -func workstreamSnapshotWrites(t *testing.T, s *Store, id string) workstreamWriteSnapshot { - t.Helper() - ctx := context.Background() - w, err := s.Get(ctx, id) - if err != nil { - t.Fatal(err) - } - records, err := s.Records(ctx, id, "", "") - if err != nil { - t.Fatal(err) - } - nodes, err := s.Pages(ctx, id) - if err != nil { - t.Fatal(err) - } - out := workstreamWriteSnapshot{stream: w, records: records, history: map[string][]PageVersion{}} - for _, node := range nodes { - p, err := s.Page(ctx, node.ID, false) - if err != nil { - t.Fatal(err) - } - out.pages = append(out.pages, p) - history, err := s.PageHistory(ctx, node.ID, 50) - if err != nil { - t.Fatal(err) - } - out.history[node.ID] = history - } - return out -} - -func TestWorkstreamDoneRefusesAgentWritesInsideTheirTransactions(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Done writes") - p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes", Owner: "agent"}) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { - t.Fatal(err) - } - before := workstreamSnapshotWrites(t, s, w.ID) - *now = now.Add(time.Hour) - who := Attribution{By: "agent"} - writes := []struct { - name string - call func() (Change, error) - }{ - {"record", func() (Change, error) { - _, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Late proposal", By: "agent"}) - return c, err - }}, - {"create", func() (Change, error) { - _, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Late page", Owner: "agent", Attribution: who}) - return c, err - }}, - {"save", func() (Change, error) { - _, c, err := s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), who) - return c, err - }}, - {"restore", func() (Change, error) { - _, c, err := s.RestorePage(ctx, p.ID, p.Version, p.Version, who) - return c, err - }}, - } - for _, write := range writes { - t.Run(write.name, func(t *testing.T) { - c, err := write.call() - if !errors.Is(err, ErrDone) || !reflect.DeepEqual(c, Change{}) { - t.Fatalf("done write: %+v %v", c, err) - } - if after := workstreamSnapshotWrites(t, s, w.ID); !reflect.DeepEqual(before, after) { - t.Fatal("a refused write changed records, pages, history or the workstream") - } - }) - } -} - -func TestWorkstreamDoneStillAllowsThePersonsPageWrites(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Person pages") - if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - p, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person notes"}) - if err != nil || c.ID != w.ID || p.Owner != "you" || p.UpdatedBy != "you" { - t.Fatalf("person create: %+v %+v %v", p, c, err) - } - p, c, err = s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), Attribution{}) - if err != nil || c.ID != w.ID || p.Version != 2 || p.UpdatedBy != "you" { - t.Fatalf("person save: %+v %+v %v", p, c, err) - } - history, err := s.PageHistory(ctx, p.ID, 50) - if err != nil || len(history) != 2 || history[0].SavedBy != "you" { - t.Fatalf("person history: %+v %v", history, err) - } -} - -func TestWorkstreamDoneStillAllowsThePersonsRecords(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Person records") - if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - r, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Person decision", Kept: true}) - if err != nil || c.ID != w.ID || r.By != "you" || r.State != "kept" { - t.Fatalf("person record: %+v %+v %v", r, c, err) - } - after, err := s.Get(ctx, w.ID) - if err != nil || !after.UpdatedAt.Equal(*now) || after.DoneAt.IsZero() { - t.Fatalf("person write timestamp: %+v %v", after, err) - } -} - -// The wire's unattributed proposal still lands on a done workstream as it always -// has; only an explicitly attributed agent write is refused there. -func TestWorkstreamDoneStillAllowsTheUnattributedProposal(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Unattributed records") - if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { - t.Fatal(err) - } - *now = now.Add(time.Hour) - r, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Follow-up"}) - if err != nil || c.ID != w.ID || r.By != "agent" || r.State != "proposed" { - t.Fatalf("unattributed proposal: %+v %+v %v", r, c, err) - } - if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Agent", By: "agent"}); !errors.Is(err, ErrDone) { - t.Fatalf("explicit agent proposal on a done workstream: %v", err) - } -} diff --git a/internal/workstream/workstream_agent_test.go b/internal/workstream/workstream_agent_test.go deleted file mode 100644 index a657498f83..0000000000 --- a/internal/workstream/workstream_agent_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package workstream - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "testing" - "time" -) - -// Contract (a) resolves a conversation to its workstream without creating one. -func TestWorkstreamByAgentResolvesOnlyItsOwner(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - for _, id := range []string{"", "missing"} { - if _, err := s.ByAgent(ctx, id); !errors.Is(err, ErrNotFound) { - t.Fatalf("unheld agent %q: %v", id, err) - } - } - w := createStream(t, s, "Agent") - got, err := s.ByAgent(ctx, w.AgentID) - if err != nil || !reflect.DeepEqual(got, w) { - t.Fatalf("owner: %+v %v", got, err) - } -} - -// Contract (d) includes ownership changing after the version was read. -func TestWorkstreamAgentCannotSaveThePersonsPage(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Ownership") - for _, race := range []bool{false, true} { - owner := "you" - if race { - owner = "agent" - } - p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes", Owner: owner}) - if err != nil { - t.Fatal(err) - } - base := p.Version - if race { - you := "you" - if _, _, err := s.UpdatePage(ctx, p.ID, PagePatch{Owner: &you}); err != nil { - t.Fatal(err) - } - } - before, _ := s.Page(ctx, p.ID, false) - history, _ := s.PageHistory(ctx, p.ID, 50) - stream, _ := s.Get(ctx, w.ID) - _, change, err := s.SavePage(ctx, p.ID, base, json.RawMessage(`{"type":"doc","content":[]}`), Attribution{By: "agent"}) - if !errors.Is(err, ErrNotAgentPage) || !reflect.DeepEqual(change, Change{}) { - t.Fatalf("race=%v: %+v %v", race, change, err) - } - after, _ := s.Page(ctx, p.ID, false) - afterHistory, _ := s.PageHistory(ctx, p.ID, 50) - afterStream, _ := s.Get(ctx, w.ID) - if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(history, afterHistory) || !reflect.DeepEqual(stream, afterStream) { - t.Fatalf("race=%v changed a refused page", race) - } - } - _, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Wrong owner", Owner: "you", Attribution: Attribution{By: "agent"}}) - if !errors.Is(err, ErrInvalid) || c.ID != "" { - t.Fatalf("agent creation with person owner: %+v %v", c, err) - } -} - -// Contract (e) keeps the same source and clock in the page and its history. -func TestWorkstreamAgentPagesCarryAttributionAndTime(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Attribution") - source := &Source{Kind: "chat", ID: w.AgentID, Label: "Plan discussion"} - who := Attribution{By: "agent", From: source} - p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent", Attribution: who}) - if err != nil { - t.Fatal(err) - } - workstreamCheckAgentPage(t, p, *now, source) - *now = now.Add(time.Minute) - p, _, err = s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), who) - if err != nil { - t.Fatal(err) - } - workstreamCheckAgentPage(t, p, *now, source) - history, err := s.PageHistory(ctx, p.ID, 50) - if err != nil || len(history) != 2 { - t.Fatalf("history: %+v %v", history, err) - } - for _, v := range history { - if v.SavedBy != "agent" || !reflect.DeepEqual(v.From, source) { - t.Fatalf("history attribution: %+v", v) - } - } - if !history[0].SavedAt.Equal(*now) { - t.Fatalf("history clock: %+v", history[0]) - } -} - -func workstreamCheckAgentPage(t *testing.T, p Page, now time.Time, source *Source) { - t.Helper() - if p.Owner != "agent" || p.ReadByJobs || p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(now) || !reflect.DeepEqual(p.UpdatedFrom, source) { - t.Fatalf("agent mark: %+v", p) - } -} diff --git a/internal/workstream/workstream_fields_v02_test.go b/internal/workstream/workstream_fields_v02_test.go deleted file mode 100644 index e4256d3a71..0000000000 --- a/internal/workstream/workstream_fields_v02_test.go +++ /dev/null @@ -1,398 +0,0 @@ -package workstream - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "math" - "reflect" - "slices" - "strings" - "testing" - "time" - - "github.com/Agent-Field/codeaf/internal/workspace" -) - -// WP-F2 (a): draft metadata survives storage, while old plan JSON stays optional. -func TestWorkstreamDraftFieldsRoundTripAndOldJSON(t *testing.T) { - s, db, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Drafts") - item := PlanItem{Kind: "draft", DraftID: "draft", Title: "Draft", Size: "M", BudgetUSD: 10000, Source: &Source{Kind: "message", ID: "message", ChatID: "chat", Label: "Scope"}} - waves := []Wave{{N: 1, Items: []PlanItem{item}}} - p, _, err := s.SetPlan(ctx, w.ID, 0, nil, waves, Attribution{}) - if err != nil || !reflect.DeepEqual(p.Waves, waves) { - t.Fatalf("set: %+v %v", p, err) - } - p, err = s.Plan(ctx, w.ID) - if err != nil || !reflect.DeepEqual(p.Waves, waves) { - t.Fatalf("get: %+v %v", p, err) - } - err = db.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `UPDATE workstream_plans SET waves=? WHERE workstream_id=?`, `[{"n":1,"auto_start":false,"items":[{"kind":"draft","draft_id":"old","title":"Old"}]}]`, w.ID) - return err - }) - if err != nil { - t.Fatal(err) - } - p, err = s.Plan(ctx, w.ID) - if err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(p.Waves[0].Items[0]) - if err != nil || strings.Contains(string(raw), "size") || strings.Contains(string(raw), "budget_usd") || strings.Contains(string(raw), "source") { - t.Fatalf("old fields must stay absent: %s %v", raw, err) - } -} - -// WP-F2 (a): each invalid draft field refuses the entire plan without a write. -func TestWorkstreamDraftFieldRefusalsAreAtomic(t *testing.T) { - for name, item := range map[string]PlanItem{ - "job size": {Kind: "job", Factory: "f", Job: 7, Size: "S"}, - "job budget": {Kind: "job", Factory: "f", Job: 7, BudgetUSD: 1}, - "job source": {Kind: "job", Factory: "f", Job: 7, Source: &Source{Kind: "chat", ID: "chat"}}, - "size XL": {Kind: "draft", DraftID: "d", Title: "Draft", Size: "XL"}, - "negative": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: -1}, - "over cap": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: 10000.01}, - "NaN": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: math.NaN()}, - "infinite": {Kind: "draft", DraftID: "d", Title: "Draft", BudgetUSD: math.Inf(1)}, - "message without chat": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "message", ID: "msg"}}, - "chat with factory": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: "chat", Factory: "f"}}, - "page with chat": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "page", ID: "p", ChatID: "chat"}}, - "bad source kind": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "other", ID: "p"}}, - "empty source id": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat"}}, - "long source id": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: strings.Repeat("x", 4097)}}, - "multiline label": {Kind: "draft", DraftID: "d", Title: "Draft", Source: &Source{Kind: "chat", ID: "chat", Label: "a\nb"}}, - } { - t.Run(name, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Refusals", workspace.Ref{Kind: workspace.FactoryJobKind, Factory: "f", ID: "7"}) - before, _ := s.Plan(ctx, w.ID) - _, c, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{item}}}, Attribution{}) - if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("refusal: %+v %v", c, err) - } - after, err := s.Plan(ctx, w.ID) - row, rowErr := s.Get(ctx, w.ID) - if err != nil || rowErr != nil || !reflect.DeepEqual(before, after) || !row.UpdatedAt.Equal(w.UpdatedAt) { - t.Fatalf("refusal changed plan: %+v %v %v", after, err, rowErr) - } - }) - } -} - -func workstreamProposalFixture(t *testing.T, s *Store, now time.Time) (Workstream, GoAheadProposal) { - t.Helper() - w := createStream(t, s, "Proposal", workspace.Ref{Kind: workspace.FactoryJobKind, Factory: "f", ID: "7"}) - if _, _, err := s.SetPlan(context.Background(), w.ID, 0, []Milestone{{ID: "ship", Title: "Ship"}}, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { - t.Fatal(err) - } - return w, GoAheadProposal{Scope: "wave", Wave: 1, CapUSD: 12.5, EndsAt: now.Add(time.Hour)} -} - -// WP-F2 (d): keeping grants once, dropping revokes, re-keeping replaces the id. -func TestWorkstreamGoAheadRecordLifecycle(t *testing.T) { - for _, target := range []GoAheadProposal{{Scope: "job", Factory: "f", Job: 7}, {Scope: "wave", Wave: 1}, {Scope: "milestone", MilestoneID: "ship"}} { - t.Run(target.Scope, func(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - target.CapUSD, target.EndsAt = p.CapUSD, p.EndsAt - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &target}) - if err != nil || r.State != "proposed" || r.GoAheadID != "" || !reflect.DeepEqual(r.GoAhead, &target) { - t.Fatalf("propose: %+v %v", r, err) - } - if grants, err := s.GoAheads(ctx, w.ID, true); err != nil || len(grants) != 0 { - t.Fatalf("proposal granted: %+v %v", grants, err) - } - r, c, err := s.KeepRecord(ctx, r.ID) - if err != nil || r.State != "kept" || r.DecidedAt.IsZero() || !slices.Contains(c.Parts, PartRecords) || !slices.Contains(c.Parts, PartGoAheads) { - t.Fatalf("keep: %+v %+v %v", r, c, err) - } - grants, err := s.GoAheads(ctx, w.ID, false) - if err != nil || len(grants) != 1 { - t.Fatalf("grants: %+v %v", grants, err) - } - g := grants[0] - if g.ID != r.GoAheadID || g.Scope != target.Scope || g.Factory != target.Factory || g.Job != target.Job || g.Wave != target.Wave || g.MilestoneID != target.MilestoneID || g.CapUSD != target.CapUSD || !g.EndsAt.Equal(target.EndsAt) { - t.Fatalf("grant differs: %+v %+v", g, target) - } - again, c, err := s.KeepRecord(ctx, r.ID) - grants, listErr := s.GoAheads(ctx, w.ID, true) - if err != nil || listErr != nil || !reflect.DeepEqual(again, r) || len(c.Parts) != 0 || len(grants) != 1 { - t.Fatalf("repeat keep: %+v %+v %v %v", again, c, err, listErr) - } - dropped, c, err := s.DropRecord(ctx, r.ID) - grants, listErr = s.GoAheads(ctx, w.ID, true) - if err != nil || listErr != nil || dropped.State != "dropped" || dropped.GoAheadID != r.GoAheadID || len(grants) != 1 || grants[0].State != "ended" || grants[0].EndReason != "revoked" || !slices.Contains(c.Parts, PartGoAheads) { - t.Fatalf("drop: %+v %+v %+v %v %v", dropped, c, grants, err, listErr) - } - kept, _, err := s.KeepRecord(ctx, r.ID) - if err != nil || kept.GoAheadID == r.GoAheadID || kept.GoAheadID == "" { - t.Fatalf("re-keep: %+v %v", kept, err) - } - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - grants, err = s.GoAheads(ctx, w.ID, true) - if err != nil || len(grants) != 2 || grants[0].ID != kept.GoAheadID || grants[0].EndReason != "workstream_done" { - t.Fatalf("done: %+v %v", grants, err) - } - records, err := s.Records(ctx, w.ID, "goahead", "") - if err != nil || len(records) != 1 || !reflect.DeepEqual(records[0], kept) { - t.Fatalf("done changed kept record: %+v %v", records, err) - } - again, c, err = s.KeepRecord(ctx, r.ID) - if err != nil || len(c.Parts) != 0 || !reflect.DeepEqual(again, kept) { - t.Fatalf("already kept on done: %+v %+v %v", again, c, err) - } - }) - } -} - -// WP-F2 (d): keep checks current state in its transaction and rolls back refusal. -func TestWorkstreamGoAheadKeepRefusalsAreAtomic(t *testing.T) { - for _, reason := range []string{"done", "wave removed", "expired"} { - t.Run(reason, func(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if err != nil { - t.Fatal(err) - } - want := ErrInvalid - switch reason { - case "done": - _, _, err = s.MarkDone(ctx, w.ID, "") - want = ErrConflict - case "wave removed": - _, _, err = s.SetPlan(ctx, w.ID, 1, nil, nil, Attribution{}) - case "expired": - *now = p.EndsAt - } - if err != nil { - t.Fatal(err) - } - before, _ := s.Get(ctx, w.ID) - _, c, err := s.KeepRecord(ctx, r.ID) - if !errors.Is(err, want) || len(c.Parts) != 0 { - t.Fatalf("keep: %+v %v", c, err) - } - records, err := s.Records(ctx, w.ID, "goahead", "") - grants, grantErr := s.GoAheads(ctx, w.ID, true) - after, rowErr := s.Get(ctx, w.ID) - if err != nil || grantErr != nil || rowErr != nil || len(records) != 1 || !reflect.DeepEqual(records[0], r) || len(grants) != 0 || !after.UpdatedAt.Equal(before.UpdatedAt) { - t.Fatalf("refusal wrote: %+v %+v %v %v %v", records, grants, err, grantErr, rowErr) - } - }) - } -} - -// WP-F2 (d), (f): invalid proposals/supersedes leave records and grants unchanged. -func TestWorkstreamGoAheadProposeRefusalsAndListFilters(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - for _, kind := range []string{"decision", "question", "goahead"} { - ask := RecordPropose{WorkstreamID: w.ID, Kind: kind, Text: "Why"} - if kind == "goahead" { - ask.GoAhead = &p - } - if _, _, err := s.ProposeRecord(ctx, ask); err != nil { - t.Fatal(err) - } - } - for _, tc := range []struct { - kind string - include bool - count int - }{{"", false, 2}, {"", true, 3}, {"goahead", false, 1}, {"question", true, 1}} { - records, err := s.Records(ctx, w.ID, tc.kind, "", tc.include) - if err != nil || len(records) != tc.count { - t.Fatalf("filter %+v: %+v %v", tc, records, err) - } - } - before, _ := s.Records(ctx, w.ID, "", "", true) - defaults, err := s.Records(ctx, w.ID, "", "") - if err != nil || len(defaults) != 2 { - t.Fatalf("default list included goaheads: %+v %v", defaults, err) - } - missing := p - missing.Wave = 2 - for _, ask := range []RecordPropose{ - {Kind: "goahead"}, {Kind: "goahead", GoAhead: &p, Kept: true}, - {Kind: "decision", GoAhead: &p}, {Kind: "question", GoAhead: &p}, - {Kind: "goahead", GoAhead: &missing}, - } { - ask.WorkstreamID, ask.Text = w.ID, "Why" - if _, c, err := s.ProposeRecord(ctx, ask); !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("propose %+v: %+v %v", ask, c, err) - } - } - for _, tc := range []struct{ id, kind string }{{before[0].ID, ""}, {before[0].ID, "decision"}, {before[1].ID, "goahead"}} { - if _, _, c, err := s.SupersedeRecord(ctx, tc.id, RecordReplacement{Kind: tc.kind, Text: "Replacement"}); !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("supersede %+v: %+v %v", tc, c, err) - } - } - after, err := s.Records(ctx, w.ID, "", "", true) - if err != nil || !reflect.DeepEqual(before, after) { - t.Fatalf("refusals wrote: %+v %v", after, err) - } - if _, _, err := s.MarkDone(ctx, w.ID, ""); err != nil { - t.Fatal(err) - } - for _, by := range []string{"", "you", "agent"} { - if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p, By: by}); !errors.Is(err, ErrConflict) { - t.Fatalf("done attribution %q: %v", by, err) - } - } -} - -// WP-F2 (d): proposal validation uses the same cap, end and scope rules as grants. -func TestWorkstreamGoAheadProposalsUseGrantBounds(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, good := workstreamProposalFixture(t, s, *now) - for name, mutate := range map[string]func(*GoAheadProposal){ - "cap zero": func(p *GoAheadProposal) { p.CapUSD = 0 }, - "cap negative": func(p *GoAheadProposal) { p.CapUSD = -1 }, - "cap too large": func(p *GoAheadProposal) { p.CapUSD = 10000.01 }, - "cap NaN": func(p *GoAheadProposal) { p.CapUSD = math.NaN() }, - "cap infinite": func(p *GoAheadProposal) { p.CapUSD = math.Inf(1) }, - "end zero": func(p *GoAheadProposal) { p.EndsAt = time.Time{} }, - "end now": func(p *GoAheadProposal) { p.EndsAt = *now }, - "end too far": func(p *GoAheadProposal) { p.EndsAt = now.Add(90*24*time.Hour + time.Millisecond) }, - "mixed scope": func(p *GoAheadProposal) { p.Factory = "f" }, - "missing job": func(p *GoAheadProposal) { p.Scope = "job"; p.Factory = "f"; p.Job = 8; p.Wave = 0 }, - "missing milestone": func(p *GoAheadProposal) { p.Scope = "milestone"; p.MilestoneID = "gone"; p.Wave = 0 }, - } { - t.Run(name, func(t *testing.T) { - p := good - mutate(&p) - _, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if !errors.Is(err, ErrInvalid) || len(c.Parts) != 0 { - t.Fatalf("proposal: %+v %v", c, err) - } - }) - } - all, err := s.Records(ctx, w.ID, "", "", true) - grants, grantErr := s.GoAheads(ctx, w.ID, true) - if err != nil || grantErr != nil || len(all) != 0 || len(grants) != 0 { - t.Fatalf("refusals wrote: %+v %+v %v %v", all, grants, err, grantErr) - } - good.CapUSD, good.EndsAt = 10000, now.Add(90*24*time.Hour) - if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &good}); err != nil { - t.Fatalf("inclusive bounds: %v", err) - } -} - -// WP-F2 (d): a failed record update rolls back the preceding grant or revocation. -func TestWorkstreamGoAheadRecordTransactionRollsBackGrantChanges(t *testing.T) { - s, db, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if err != nil { - t.Fatal(err) - } - for _, state := range []string{"kept", "dropped"} { - if state == "dropped" { - r, _, err = s.KeepRecord(ctx, r.ID) - if err != nil { - t.Fatal(err) - } - } - before, err := s.GoAheads(ctx, w.ID, true) - if err != nil { - t.Fatal(err) - } - err = db.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `CREATE TRIGGER workstream_record_fail BEFORE UPDATE ON workstream_records BEGIN SELECT RAISE(ABORT,'fixture refuses record write'); END`) - return err - }) - if err != nil { - t.Fatal(err) - } - var c Change - if state == "kept" { - _, c, err = s.KeepRecord(ctx, r.ID) - } else { - _, c, err = s.DropRecord(ctx, r.ID) - } - if err == nil || len(c.Parts) != 0 { - t.Fatalf("failed record write: %+v %v", c, err) - } - after, err := s.GoAheads(ctx, w.ID, true) - records, recordErr := s.Records(ctx, w.ID, "goahead", "") - if err != nil || recordErr != nil || !reflect.DeepEqual(before, after) || len(records) != 1 || !reflect.DeepEqual(records[0], r) { - t.Fatalf("partial commit: %+v %+v %v %v", after, records, err, recordErr) - } - err = db.Write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, "DROP TRIGGER workstream_record_fail") - return err - }) - if err != nil { - t.Fatal(err) - } - } -} - -// WP-F2 (d): expiry and explicit revocation leave a kept record unchanged. -func TestWorkstreamGoAheadDropAfterExpiryOrRevocation(t *testing.T) { - for _, reason := range []string{"expired", "revoked"} { - t.Run(reason, func(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if err != nil { - t.Fatal(err) - } - r, _, err = s.KeepRecord(ctx, r.ID) - if err != nil { - t.Fatal(err) - } - if reason == "expired" { - *now = p.EndsAt - } else { - _, _, err = s.RevokeGoAhead(ctx, r.GoAheadID) - } - if err != nil { - t.Fatal(err) - } - records, err := s.Records(ctx, w.ID, "goahead", "") - if err != nil || len(records) != 1 || !reflect.DeepEqual(records[0], r) { - t.Fatalf("ended changed record: %+v %v", records, err) - } - dropped, c, err := s.DropRecord(ctx, r.ID) - grants, grantErr := s.GoAheads(ctx, w.ID, true) - if err != nil || grantErr != nil || dropped.GoAheadID != r.GoAheadID || slices.Contains(c.Parts, PartGoAheads) || len(grants) != 1 || grants[0].EndReason != reason { - t.Fatalf("drop: %+v %+v %+v %v %v", dropped, c, grants, err, grantErr) - } - }) - } -} - -// WP-F2 (d): a proposal's end is stored at the grant's precision, so keep grants the same end. -func TestWorkstreamGoAheadKeepGrantsTheProposedEndExactly(t *testing.T) { - s, _, now := testStore(t) - ctx := context.Background() - w, p := workstreamProposalFixture(t, s, *now) - p.EndsAt = p.EndsAt.Add(123456 * time.Microsecond) - r, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "goahead", Text: "Why", GoAhead: &p}) - if err != nil { - t.Fatal(err) - } - r, _, err = s.KeepRecord(ctx, r.ID) - grants, listErr := s.GoAheads(ctx, w.ID, false) - if err != nil || listErr != nil || len(grants) != 1 || r.GoAhead == nil { - t.Fatalf("keep: %+v %+v %v %v", r, grants, err, listErr) - } - if !grants[0].EndsAt.Equal(r.GoAhead.EndsAt) || r.GoAhead.EndsAt.Nanosecond() != 123000000 { - t.Fatalf("proposal end %v, grant end %v", r.GoAhead.EndsAt, grants[0].EndsAt) - } -} diff --git a/internal/workstream/workstream_wake_fix1_test.go b/internal/workstream/workstream_wake_fix1_test.go deleted file mode 100644 index 32b95961ff..0000000000 --- a/internal/workstream/workstream_wake_fix1_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package workstream - -import ( - "context" - "errors" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -func workstreamWakeUndoConflictForTest(t *testing.T, s *Store, run WakeRun) { - t.Helper() - ctx := context.Background() - if len(run.Items) != 1 || run.Items[0].State != "applied" { - t.Fatalf("expected one applied item: %+v", run) - } - if _, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "undo"); !errors.Is(err, ErrConflict) || c.ID != "" { - t.Fatalf("named undo: %+v %v", c, err) - } - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || !reflect.DeepEqual(stored, run) { - t.Fatalf("named conflict changed the wake: %+v %v", stored, err) - } - noted, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != run.WorkstreamID || len(noted.Items) != 1 || noted.Items[0].State != "applied" || noted.Items[0].Note != "Changed since; not undone." { - t.Fatalf("undo all: %+v %+v %v", noted, c, err) - } - again, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != "" || !reflect.DeepEqual(again, noted) { - t.Fatalf("repeated undo all changed the wake: %+v %+v %v", again, c, err) - } -} - -func TestWakeD_M1_ChangedDraftRefusesUndo(t *testing.T) { - for _, change := range []string{"title", "repo", "text", "size", "budget", "source", "wave"} { - t.Run(change, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Draft edits", workstreamWakeMember()) - small := "small" - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "draft-edits", WakeProposal{Kind: "draft", Title: "Follow up", Repo: "api", Text: "Original", Size: "S", BudgetUSD: workstreamWakeBudget(3)}) - plan, err := s.Plan(ctx, w.ID) - if err != nil || len(plan.Waves) != 1 || len(plan.Waves[0].Items) != 1 { - t.Fatalf("wake plan: %+v %v", plan, err) - } - draft := &plan.Waves[0].Items[0] - switch change { - case "title": - draft.Title = "Edited follow-up" - case "repo": - draft.Repo = "web" - case "text": - draft.Text = "Edited contents" - case "size": - draft.Size = "M" - case "budget": - draft.BudgetUSD = 4 - case "source": - draft.Source = &Source{Kind: "chat", ID: w.AgentID, Label: "Person's edit"} - case "wave": - plan.Waves = append(plan.Waves, Wave{N: 2, Title: "Person's wave", Items: plan.Waves[0].Items}) - plan.Waves[0].Items = []PlanItem{} - } - before, _, err := s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}) - if err != nil { - t.Fatal(err) - } - workstreamWakeUndoConflictForTest(t, s, run) - after, err := s.Plan(ctx, w.ID) - if err != nil || !reflect.DeepEqual(after, before) { - t.Fatalf("undo changed the edited draft or its waves: %+v %v", after, err) - } - }) - } -} - -func TestWakeD_M1_OnlyUnchangedWakeCreatedWaveIsRemoved(t *testing.T) { - for _, change := range []string{"unchanged", "renamed", "auto_start", "milestone", "another_draft", "person_created"} { - t.Run(change, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Wave cleanup", workstreamWakeMember()) - small := "small" - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - if change == "person_created" { - if _, _, err := s.SetPlan(ctx, w.ID, 0, nil, []Wave{{N: 1, Items: []PlanItem{}}}, Attribution{}); err != nil { - t.Fatal(err) - } - } - run := workstreamWakeForTest(t, s, w, "wave-cleanup", WakeProposal{Kind: "draft", Title: "Follow up", BudgetUSD: workstreamWakeBudget(3)}) - plan, err := s.Plan(ctx, w.ID) - if err != nil || len(run.Items) != 1 || run.Items[0].State != "applied" || len(plan.Waves) != 1 || len(plan.Waves[0].Items) != 1 { - t.Fatalf("wake plan: %+v %+v %v", run, plan, err) - } - switch change { - case "renamed": - plan.Waves[0].Title = "Person's title" - case "auto_start": - plan.Waves[0].AutoStart = true - case "milestone": - plan.Milestones = []Milestone{{ID: "m", Title: "Release"}} - plan.Waves[0].MilestoneID = "m" - case "another_draft": - plan.Waves[0].Items = append(plan.Waves[0].Items, PlanItem{Kind: "draft", DraftID: "person-draft", Title: "Person's draft"}) - } - if change != "unchanged" && change != "person_created" { - if _, _, err := s.SetPlan(ctx, w.ID, plan.Version, plan.Milestones, plan.Waves, Attribution{}); err != nil { - t.Fatal(err) - } - } - want := []Wave{} - if change != "unchanged" { - want = plan.Waves - want[0].Items = want[0].Items[1:] - } - undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID || len(undone.Items) != 1 || undone.Items[0].State != "undone" { - t.Fatalf("undo: %+v %+v %v", undone, c, err) - } - after, err := s.Plan(ctx, w.ID) - if err != nil || !reflect.DeepEqual(after.Waves, want) || !reflect.DeepEqual(after.Milestones, plan.Milestones) { - t.Fatalf("wave cleanup: %+v, want %+v (%v)", after, want, err) - } - }) - } -} - -func TestWakeDE_M2_PageMetadataChangesRefuseUndo(t *testing.T) { - for _, operation := range []string{"wake_update", "wake_creation"} { - t.Run(operation, func(t *testing.T) { - for _, change := range []string{"page.update_title", "page.update_owner", "page.update_read_by_jobs", "page.move_parent", "page.move_position", "page.delete"} { - t.Run(change, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Page metadata", workstreamWakeMember()) - small := "small" - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - parent, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person's folder", Owner: "you"}) - if err != nil { - t.Fatal(err) - } - proposal := WakeProposal{Kind: "page", Title: "Agent notes", Markdown: "Wake contents"} - if operation == "wake_update" { - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: proposal.Title, Owner: "agent", Doc: pagedoc.FromMarkdown("Original")}) - if err != nil { - t.Fatal(err) - } - proposal.PageID = page.ID - } - run := workstreamWakeForTest(t, s, w, "page-metadata", proposal) - if len(run.Items) != 1 || run.Items[0].State != "applied" { - t.Fatal(run) - } - id := run.Items[0].PageID - switch change { - case "page.update_title": - title := "Person's title" - _, _, err = s.UpdatePage(ctx, id, PagePatch{Title: &title}) - case "page.update_owner": - owner := "you" - _, _, err = s.UpdatePage(ctx, id, PagePatch{Owner: &owner}) - case "page.update_read_by_jobs": - read := true - _, _, err = s.UpdatePage(ctx, id, PagePatch{ReadByJobs: &read}) - case "page.move_parent": - _, _, err = s.MovePage(ctx, id, parent.ID, "") - case "page.move_position": - _, _, err = s.MovePage(ctx, id, "", "") - case "page.delete": - _, _, err = s.DeletePage(ctx, id) - } - if err != nil { - t.Fatal(err) - } - before, err := s.Page(ctx, id, false) - if err != nil || before.Version != run.Items[0].Undo.Version { - t.Fatalf("metadata change advanced the content version: %+v %v", before, err) - } - history, historyErr := s.PageHistory(ctx, id, 100) - if change == "page.delete" { - if !errors.Is(historyErr, ErrNotFound) { - t.Fatal(historyErr) - } - } else if historyErr != nil { - t.Fatal(historyErr) - } - personBefore, err := s.Page(ctx, parent.ID, false) - if err != nil { - t.Fatal(err) - } - workstreamWakeUndoConflictForTest(t, s, run) - after, err := s.Page(ctx, id, false) - if err != nil || !reflect.DeepEqual(after, before) { - t.Fatalf("undo changed the later page edit: %+v %v", after, err) - } - afterHistory, err := s.PageHistory(ctx, id, 100) - if !errors.Is(err, historyErr) || !reflect.DeepEqual(afterHistory, history) { - t.Fatalf("undo wrote page history: %+v %v", afterHistory, err) - } - person, err := s.Page(ctx, parent.ID, false) - if err != nil || !reflect.DeepEqual(person, personBefore) { - t.Fatalf("undo changed the person's page: %+v %v", person, err) - } - }) - } - }) - } -} - -func TestWakeD_m1_DuplicatePageItemsKeepFirstAndUndoOriginal(t *testing.T) { - for _, autonomy := range []string{"small", "propose"} { - t.Run(autonomy, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Duplicate pages", workstreamWakeMember()) - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}); err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent", Doc: pagedoc.FromMarkdown("Original")}) - if err != nil { - t.Fatal(err) - } - answer, err := ParseWakeAnswer(`{"summary":"Next","items":[` + - `{"kind":"page","page_id":"` + page.ID + `","markdown":"First update"},` + - `{"kind":"page","page_id":"` + page.ID + `","markdown":"Later update"}]}`) - if err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "duplicate-pages", answer.Items...) - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || len(stored.Items) != 1 || stored.Items[0].PageID != page.ID || stored.Items[0].Proposal.Markdown != "First update" { - t.Fatalf("page items were not deduplicated: %+v %v", stored, err) - } - if autonomy == "propose" { - for _, item := range stored.Items { - if item.State != "proposed" { - t.Fatal(item) - } - } - stored, _, err = s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil { - t.Fatal(err) - } - } - for _, item := range stored.Items { - if item.State != "applied" { - t.Fatal(item) - } - } - updated, err := s.Page(ctx, page.ID, false) - if err != nil || updated.Version != page.Version+1 || string(updated.Doc) != string(pagedoc.FromMarkdown("First update")) { - t.Fatalf("first update was not the only write: %+v %v", updated, err) - } - undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID { - t.Fatalf("undo: %+v %v", c, err) - } - for _, item := range undone.Items { - if item.State != "undone" || item.Note != "" { - t.Fatal(item) - } - } - restored, err := s.Page(ctx, page.ID, false) - if err != nil || restored.Version != page.Version+2 || string(restored.Doc) != string(page.Doc) || restored.Title != page.Title { - t.Fatalf("undo did not restore the original document: %+v %v", restored, err) - } - }) - } -} - -func TestWakeD_m1_DuplicateNewPageTitlesKeepFirst(t *testing.T) { - for _, autonomy := range []string{"small", "propose"} { - t.Run(autonomy, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Duplicate new pages", workstreamWakeMember()) - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &autonomy}); err != nil { - t.Fatal(err) - } - run := workstreamWakeForTest(t, s, w, "duplicate-new-pages", WakeProposal{Kind: "page", Title: "New notes", Markdown: "First creation"}, WakeProposal{Kind: "page", Title: "New notes", Markdown: "Later creation"}) - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || len(stored.Items) != 1 || stored.Items[0].Proposal.Markdown != "First creation" { - t.Fatalf("new-page items were not deduplicated: %+v %v", stored, err) - } - if autonomy == "propose" { - if stored.Items[0].State != "proposed" { - t.Fatal(stored) - } - stored, _, err = s.ActOnWake(ctx, run.ID, "", "apply") - if err != nil { - t.Fatal(err) - } - } - pages, err := s.Pages(ctx, w.ID) - if err != nil || len(pages) != 2 || stored.Items[0].State != "applied" { - t.Fatalf("expected one new agent page: %+v %+v %v", pages, stored, err) - } - page, err := s.Page(ctx, stored.Items[0].PageID, false) - if err != nil || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown("First creation")) { - t.Fatalf("first new-page suggestion was not kept: %+v %v", page, err) - } - undone, c, err := s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID || undone.Items[0].State != "undone" { - t.Fatalf("undo: %+v %+v %v", undone, c, err) - } - deleted, err := s.Page(ctx, page.ID, false) - if err != nil || deleted.DeletedAt.IsZero() { - t.Fatalf("new page was not restorably deleted: %+v %v", deleted, err) - } - }) - } -} diff --git a/internal/workstream/workstream_wake_fix2_test.go b/internal/workstream/workstream_wake_fix2_test.go deleted file mode 100644 index f29c87c71b..0000000000 --- a/internal/workstream/workstream_wake_fix2_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package workstream - -import ( - "context" - "reflect" - "testing" - - "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" -) - -func TestWakeD_R2_M2_UndoKeepsRenameBetweenClaimAndSettlement(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Rename during wake", workstreamWakeMember()) - small := "small" - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &small}); err != nil { - t.Fatal(err) - } - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Old title", Owner: "agent", Doc: pagedoc.FromMarkdown("Original document")}) - if err != nil { - t.Fatal(err) - } - run, woke, err := s.BeginWake(ctx, w.ID, "rename-during-wake", WakeEvent{Kind: "merged", Factory: "f", Job: 7, At: s.now()}) - if err != nil || !woke { - t.Fatalf("claim: %+v %v %v", run, woke, err) - } - title := "Person's title" - renamed, c, err := s.UpdatePage(ctx, page.ID, PagePatch{Title: &title}) - if err != nil || c.ID != w.ID || renamed.Title != title || renamed.Version != page.Version { - t.Fatalf("rename between claim and settlement: %+v %+v %v", renamed, c, err) - } - history, err := s.PageHistory(ctx, page.ID, 100) - if err != nil || len(history) != 1 || history[0].Title != page.Title { - t.Fatalf("rename changed the original content history: %+v %v", history, err) - } - run, c, err = s.SettleWake(ctx, run.ID, WakeAnswer{Items: []WakeProposal{{Kind: "page", PageID: page.ID, Markdown: "Wake document"}}}, nil, "") - if err != nil || c.ID != w.ID || len(run.Items) != 1 || run.Items[0].State != "applied" { - t.Fatalf("settle: %+v %+v %v", run, c, err) - } - applied, err := s.Page(ctx, page.ID, false) - if err != nil || applied.Title != title || applied.Version != page.Version+1 || string(applied.Doc) != string(pagedoc.FromMarkdown("Wake document")) { - t.Fatalf("wake did not preserve the rename: %+v %v", applied, err) - } - undone, c, err := s.ActOnWake(ctx, run.ID, run.Items[0].ID, "undo") - if err != nil || c.ID != w.ID || len(undone.Items) != 1 || undone.Items[0].State != "undone" || undone.Items[0].Note != "" { - t.Fatalf("undo: %+v %+v %v", undone, c, err) - } - restored, err := s.Page(ctx, page.ID, false) - if err != nil || restored.Title != title || restored.Version != page.Version+2 || restored.UpdatedBy != "agent" || string(restored.Doc) != string(page.Doc) { - t.Fatalf("undo did not restore the original document and keep the rename: %+v %v", restored, err) - } - history, err = s.PageHistory(ctx, page.ID, 100) - if err != nil || len(history) != 3 || history[0].Version != restored.Version || history[0].Title != title || history[0].SavedBy != "agent" || history[2].Title != page.Title { - t.Fatalf("undo history lost the rename or rewrote the original version: %+v %v", history, err) - } - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || !reflect.DeepEqual(stored, undone) { - t.Fatalf("undo was not persisted: %+v %v", stored, err) - } -} - -func TestWakeD_R2_m1_CreatedSiblingPagesUndoInAnyOrder(t *testing.T) { - for _, tc := range []struct { - name string - autonomy string - reverseApply bool - reorderSibling bool - }{ - {name: "named_undo_after_sibling_compaction", autonomy: "small"}, - {name: "undo_all_after_reverse_individual_apply", autonomy: "propose", reverseApply: true}, - {name: "named_undo_after_person_reorders_siblings", autonomy: "small", reorderSibling: true}, - } { - t.Run(tc.name, func(t *testing.T) { - s, _, _ := testStore(t) - ctx := context.Background() - w := createStream(t, s, "Sibling pages", workstreamWakeMember()) - if _, _, err := s.Update(ctx, w.ID, Patch{Autonomy: &tc.autonomy}); err != nil { - t.Fatal(err) - } - before, err := s.Pages(ctx, w.ID) - if err != nil || len(before) != 1 { - t.Fatalf("initial pages: %+v %v", before, err) - } - run := workstreamWakeForTest(t, s, w, "sibling-pages", - WakeProposal{Kind: "page", Title: "A", Markdown: "Document A"}, - WakeProposal{Kind: "page", Title: "B", Markdown: "Document B"}) - if len(run.Items) != 2 || run.Items[0].Proposal.Title != "A" || run.Items[1].Proposal.Title != "B" { - t.Fatalf("expected two pages in card order A, B: %+v", run) - } - if tc.reverseApply { - for _, item := range run.Items { - if item.State != "proposed" { - t.Fatalf("page was applied before individual Apply: %+v", item) - } - } - for i := len(run.Items) - 1; i >= 0; i-- { - var c Change - run, c, err = s.ActOnWake(ctx, run.ID, run.Items[i].ID, "apply") - if err != nil || c.ID != w.ID || run.Items[i].State != "applied" { - t.Fatalf("individual Apply in reverse card order: %+v %+v %v", run, c, err) - } - } - } - for i, item := range run.Items { - position := i + 1 - if tc.reverseApply { - position = len(run.Items) - i - } - page, err := s.Page(ctx, item.PageID, false) - if err != nil || item.State != "applied" || page.ParentID != "" || page.Position != position || page.Owner != "agent" || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown(item.Proposal.Markdown)) { - t.Fatalf("applied root page: %+v %+v %v", item, page, err) - } - } - var sibling PageNode - if tc.reorderSibling { - page, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person's page", Owner: "you"}) - if err != nil { - t.Fatal(err) - } - var c Change - sibling, c, err = s.MovePage(ctx, page.ID, "", before[0].ID) - if err != nil || c.ID != w.ID || sibling.Position != 1 { - t.Fatalf("reorder sibling: %+v %+v %v", sibling, c, err) - } - for i, item := range run.Items { - page, err := s.Page(ctx, item.PageID, false) - if err != nil || page.Position != i+2 || item.Undo.Page == nil { - t.Fatalf("sibling reorder did not shift the wake page: %+v %v", page, err) - } - postImage := *item.Undo.Page - postImage.Position = page.Position - if !reflect.DeepEqual(page.PageNode, postImage) { - t.Fatalf("sibling reorder changed more than position: %+v", page) - } - } - } - if tc.reverseApply { - var c Change - run, c, err = s.ActOnWake(ctx, run.ID, "", "undo") - if err != nil || c.ID != w.ID { - t.Fatalf("Undo all: %+v %+v %v", run, c, err) - } - } else { - b, err := s.Page(ctx, run.Items[1].PageID, false) - if err != nil { - t.Fatal(err) - } - for i := range run.Items { - var c Change - run, c, err = s.ActOnWake(ctx, run.ID, run.Items[i].ID, "undo") - if err != nil || c.ID != w.ID || run.Items[i].State != "undone" || run.Items[i].Note != "" { - t.Fatalf("named Undo %d: %+v %+v %v", i, run, c, err) - } - if i == 0 { - compacted, err := s.Page(ctx, b.ID, false) - if err != nil || compacted.Position != b.Position-1 || !compacted.DeletedAt.IsZero() || run.Items[1].State != "applied" { - t.Fatalf("Undo A did not compact the still-applied B: %+v %+v %v", compacted, run, err) - } - } - } - } - for _, item := range run.Items { - page, err := s.Page(ctx, item.PageID, false) - if err != nil || item.State != "undone" || item.Note != "" || page.DeletedAt.IsZero() || page.Version != 1 || string(page.Doc) != string(pagedoc.FromMarkdown(item.Proposal.Markdown)) { - t.Fatalf("wake page was not restorably deleted: %+v %+v %v", item, page, err) - } - } - want := append([]PageNode(nil), before...) - if tc.reorderSibling { - want = append(want, sibling) - } - after, err := s.Pages(ctx, w.ID) - if err != nil || !reflect.DeepEqual(after, want) { - t.Fatalf("Undo changed the person's pages: %+v %v", after, err) - } - stored, err := s.WakeRun(ctx, run.ID) - if err != nil || !reflect.DeepEqual(stored, run) { - t.Fatalf("Undo was not persisted: %+v %v", stored, err) - } - if again, c, err := s.ActOnWake(ctx, run.ID, "", "undo"); err != nil || c.ID != "" || !reflect.DeepEqual(again, run) { - t.Fatalf("repeated Undo changed the wake: %+v %+v %v", again, c, err) - } - }) - } -} From 9d82ea62afb1ef7699f6700268bdc3706af11bee Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 17:55:41 -0500 Subject: [PATCH 3/5] docs: protocol and manual keep Factory refs and client tools; workstream entries go The protocol and the collections manual lose the workstream methods, events, features and schemas, and keep the factory_job and client-tools sections. The unreleased workstream entries described features that will not ship from the engine; one entry for the generic pieces replaces them and the client-tools entry. The CI-timeout entry no longer mentions the deleted conformance case. Co-Authored-By: Claude Opus 5.5 --- .../2263-app-workstreams-contract.md | 10 -- .../unreleased/2270-app-workstreams-store.md | 27 ---- .../2277-app-workstream-page-markdown.md | 9 -- .../2288-workstreams-wire-conformance.md | 7 - .../2289-app-workstream-agent-tools.md | 25 ---- ...90-app-workstream-page-versions-restore.md | 24 ---- .../2296-codeaf-leg-finishes-in-ci.md | 2 +- .../2297-app-workstreams-wire-v02.md | 24 ---- .../2303-workstreams-stability-fixes.md | 13 -- .../2310-workstreams-size-limits.md | 13 -- .../2318-app-workstream-suggestions.md | 23 ---- .../2319-workstreams-watching-agent.md | 23 ---- .../unreleased/2320-app-workstream-sources.md | 28 ---- docs/changes/unreleased/2334-client-tools.md | 24 ---- docs/design/app-v3/PROTOCOL.md | 128 ++---------------- 15 files changed, 9 insertions(+), 371 deletions(-) delete mode 100644 docs/changes/unreleased/2263-app-workstreams-contract.md delete mode 100644 docs/changes/unreleased/2270-app-workstreams-store.md delete mode 100644 docs/changes/unreleased/2277-app-workstream-page-markdown.md delete mode 100644 docs/changes/unreleased/2288-workstreams-wire-conformance.md delete mode 100644 docs/changes/unreleased/2289-app-workstream-agent-tools.md delete mode 100644 docs/changes/unreleased/2290-app-workstream-page-versions-restore.md delete mode 100644 docs/changes/unreleased/2297-app-workstreams-wire-v02.md delete mode 100644 docs/changes/unreleased/2303-workstreams-stability-fixes.md delete mode 100644 docs/changes/unreleased/2310-workstreams-size-limits.md delete mode 100644 docs/changes/unreleased/2318-app-workstream-suggestions.md delete mode 100644 docs/changes/unreleased/2319-workstreams-watching-agent.md delete mode 100644 docs/changes/unreleased/2320-app-workstream-sources.md delete mode 100644 docs/changes/unreleased/2334-client-tools.md diff --git a/docs/changes/unreleased/2263-app-workstreams-contract.md b/docs/changes/unreleased/2263-app-workstreams-contract.md deleted file mode 100644 index 7a31edbb2c..0000000000 --- a/docs/changes/unreleased/2263-app-workstreams-contract.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -kind: added -title: codeaf app registers the workstream methods (contract only; they answer not implemented yet) -pr: 2263 -surface: [app, docs] -invalidates: [] ---- - -The 25 workstream, page, record and go-ahead methods and `workstream.changed` exist on the wire with their types, answering `unknown_method` with `workstreams: not implemented yet`. -Hello carries no `workstreams` feature until the store lands. diff --git a/docs/changes/unreleased/2270-app-workstreams-store.md b/docs/changes/unreleased/2270-app-workstreams-store.md deleted file mode 100644 index 1966231869..0000000000 --- a/docs/changes/unreleased/2270-app-workstreams-store.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -kind: added -title: codeaf app serves persistent workstreams with pages, records and go-aheads -pr: 2270 -surface: [app] -invalidates: - - Workstream methods previously answered unknown_method; they now serve persistent state and announce the workstreams feature. ---- - -Workstreams share collection identity, preserve page history, version plans, -and keep decisions, questions and bounded go-aheads in SQLite. Creating one -opens one bare agent without a first model turn; a repeated client id reuses it. -Factory jobs belong to at most one workstream, and changes notify watching apps. -List/get derive stage and numbers from bounded Factory reads and recorded agent -spend. Unknown readings are omitted; measured zero remains present. Mark done -ends active go-aheads and can archive member chats while keeping the agent record. -Go-aheads and wave auto-start settings are stored; Factory enforcement and -automatic starts are separate work. - -Update, mark done and reopen results include the same available live numbers as -get. Single-line fields reject control characters; prose permits newline and -tab only. Repeating mark done preserves the original done time and report. - -Faults while reading a queue release concurrent readers, cache an unavailable -result and permit retries after the cache window. Update, mark done and reopen -persist their first observed start, return the refreshed row and announce one -combined change. diff --git a/docs/changes/unreleased/2277-app-workstream-page-markdown.md b/docs/changes/unreleased/2277-app-workstream-page-markdown.md deleted file mode 100644 index a9d24c601a..0000000000 --- a/docs/changes/unreleased/2277-app-workstream-page-markdown.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -kind: internal -title: Workstream pages render from editor JSON to markdown in internal/workstream/pagedoc -pr: 2277 -surface: [app] -invalidates: [] ---- - -`pagedoc.Markdown` renders a page's editor JSON by the page document format rules, the same bytes as the desktop app's renderer. Nothing calls it yet; the Factory plan step that reads pages for job briefs will. diff --git a/docs/changes/unreleased/2288-workstreams-wire-conformance.md b/docs/changes/unreleased/2288-workstreams-wire-conformance.md deleted file mode 100644 index 1aad7e6fc0..0000000000 --- a/docs/changes/unreleased/2288-workstreams-wire-conformance.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -kind: internal -title: Workstream methods are checked against the wire contract's examples -pr: 2288 -surface: [app] -invalidates: [] ---- diff --git a/docs/changes/unreleased/2289-app-workstream-agent-tools.md b/docs/changes/unreleased/2289-app-workstream-agent-tools.md deleted file mode 100644 index 365cd05003..0000000000 --- a/docs/changes/unreleased/2289-app-workstream-agent-tools.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -kind: added -title: Workstream agents propose records and draft jobs and write their own pages -pr: 2289 -surface: [app, chat] -invalidates: - - A workstream's agent previously had no workstream tools or prompt; it now receives both in the app. ---- - -Each workstream's own agent gets six tools and a short workstream prompt. -Decisions and questions remain proposals; job proposals append plan drafts -without starting jobs. Go-ahead proposals are decision records and grant -nothing. Page writes enforce agent ownership in the store transaction and -preserve the agent mark, source and time in page history. Reads use recorded -facts, and settled agent turns notify the app to refresh workstream spend. -Ordinary conversations and child workers receive no workstream capability. - -Prompt briefs use a non-blocking cached snapshot and fall back when unavailable. -Agent record and page writes check done inside the write transaction, including -when the person marks done after the agent's read. Markdown list items always -start with a paragraph, and malformed inline delimiters take linear work. -Tool arguments refuse repeated keys at every object depth before calling a tool. -Workstream tools use the shared argument decoder and mark input refusals before -running. Whole-valued decimal job and wave numbers are accepted; proposed caps -retain fractional dollars. A plan version conflict gets one fresh read and save. diff --git a/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md b/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md deleted file mode 100644 index 7a5cda50b7..0000000000 --- a/docs/changes/unreleased/2290-app-workstream-page-versions-restore.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -kind: added -title: Workstream pages support version reads, restorable deletion and title-based read defaults -pr: 2290 -surface: [app] -invalidates: - - page.delete used to destroy pages and their history; it now marks pages deleted and restorable while retaining saved versions. - - page.create used to default read_by_jobs to false for every title; it now defaults to true for Spec and Decisions unless explicitly overridden. ---- - -`page.get` accepts a saved version and renders that version's document when -markdown is requested. Deleted pages remain readable, but leave the live tree -and refuse edits and history reads. `page.list` with `include_deleted: true` -adds deletion roots, newest first, including an empty list when none remain. - -`page.restore` with only an id restores a deleted page and the descendants -removed by the same deletion, preserving saved versions, nesting and order. -It appends under its live old parent or at the root. Restoring an old version -of a live page still requires both version parameters and creates a new version. - -Creation compares trimmed titles without regard to case when choosing Read by -jobs. Explicit choices win and renames keep the existing setting; the initial -Overview remains off. Deleted pages remain stored until their collection is -deleted. diff --git a/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md b/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md index 43ebe1f0cd..f99f06de54 100644 --- a/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md +++ b/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md @@ -1,6 +1,6 @@ --- kind: internal -title: The cmd/codeaf touched leg gets a 25-minute timeout; the conformance busy case waits 200 ms +title: The cmd/codeaf touched leg gets a 25-minute timeout pr: 2296 surface: [build] invalidates: [] diff --git a/docs/changes/unreleased/2297-app-workstreams-wire-v02.md b/docs/changes/unreleased/2297-app-workstreams-wire-v02.md deleted file mode 100644 index cde1cbc7bd..0000000000 --- a/docs/changes/unreleased/2297-app-workstreams-wire-v02.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -kind: added -title: Workstream drafts and pages carry sources and kept proposals grant go-aheads -pr: 2297 -surface: [app] -invalidates: - - The agent's go-ahead proposals used to be decision records with a text prefix and are now goahead records. - - record.list without a kind used to return every record and now leaves goahead records out unless asked. - - collections.db files from earlier workstreams builds are now refused because schema 3 changed in place. ---- - -The wire contract adds draft size, budget and source, source attribution on page -creation and saves, and agent and origin conversation IDs on workstream rows. -Draft fields round-trip through plans; job items refuse them. Page history keeps -the source of each version, and a save without one clears the current source. - -Go-ahead records carry structured proposals. Keeping one grants exactly once in -the same transaction after checking the current target, end and workstream state. -Dropping it revokes its active grant; keeping it again grants a new one. Go-ahead -records cannot be superseded. Marking done ends grants without changing records. - -The agent uses these structured proposals, with end dates at most 89 UTC days -ahead, and stamps added draft jobs with a validated source. Agent reads include -go-ahead records; ordinary record lists include them only when requested. diff --git a/docs/changes/unreleased/2303-workstreams-stability-fixes.md b/docs/changes/unreleased/2303-workstreams-stability-fixes.md deleted file mode 100644 index a42d7db78e..0000000000 --- a/docs/changes/unreleased/2303-workstreams-stability-fixes.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -kind: fixed -title: A workstream's collection cannot be deleted by any writer; chat resume skips per-chat database reads -pr: 2303 -surface: [app, chat] -invalidates: [] ---- - -An engine that opened the collections database before another engine -migrated it could delete a workstream's collection and with it the -workstream's pages, history, records, go-aheads and plan. The database now -refuses that delete itself. Resuming a chat no longer opens the collections -database once for every saved conversation; it asks only about empty ones. diff --git a/docs/changes/unreleased/2310-workstreams-size-limits.md b/docs/changes/unreleased/2310-workstreams-size-limits.md deleted file mode 100644 index 468087fb53..0000000000 --- a/docs/changes/unreleased/2310-workstreams-size-limits.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -kind: fixed -title: Workstream plans, pages and records have size limits; agent Spec pages are read by jobs -pr: 2310 -surface: [app] -invalidates: [] ---- - -A plan's draft text is at most 80,000 bytes; a plan holds at most 500 -milestones, 500 waves and 5,000 items in 4 MiB; a workstream holds at most -5,000 pages (deleted included) and 5,000 records. Past a limit the app gets -`too_large` and nothing changes. Pages the workstream agent creates titled -Spec or Decisions are read by jobs, as pages the person creates are. diff --git a/docs/changes/unreleased/2318-app-workstream-suggestions.md b/docs/changes/unreleased/2318-app-workstream-suggestions.md deleted file mode 100644 index faf048b112..0000000000 --- a/docs/changes/unreleased/2318-app-workstream-suggestions.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -kind: added -title: Workstream agents suggest edits on your pages for you to keep or drop -pr: 2318 -surface: [app] -invalidates: - - "The agent could not act on the person's pages at all; it now suggests edits there that the person keeps or drops." - - "collections.db previously wrote schema 5 for Sources; its first successful write now upgrades it to schema 6, which engines built before this change refuse." ---- - -The agent's `suggest_page_edit` tool proposes changes on a person's page without -editing it. Keep or drop individual edits or all proposed edits through the wire -contract. Each keep saves one page version attributed to the person and its -suggestion; dropping leaves the page unchanged. Stale edits refuse atomically, -while remaining edits in an unchanged block move with kept text. - -The Suggestions addition (v0.5) adds the suggestion methods to the wire contract. -The first successful database write upgrades schema-5 files from the Sources -build to schema 6, adding two suggestion tables and preserving wake and source -rows. Schemas 1–4 apply each missing migration in order. Reads leave older files -unchanged; a failed write rolls back migration too. -Engines built before this change refuse schema-6 collections databases with -the existing unsupported-database error. diff --git a/docs/changes/unreleased/2319-workstreams-watching-agent.md b/docs/changes/unreleased/2319-workstreams-watching-agent.md deleted file mode 100644 index 137499e3c5..0000000000 --- a/docs/changes/unreleased/2319-workstreams-watching-agent.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -kind: added -title: Workstream agents wake for morning slots and member job events with undoable changes -pr: 2319 -surface: [app, engine] -invalidates: [] ---- - -Workstreams can produce one costed, ranked batch per morning slot or delivered -member-job event. Propose only keeps suggestions; Small changes can edit the -plan and agent pages, with Apply, Undo applied and Not now for each card. -Wakes never start Factory jobs or write the person's pages. Recorded wake costs -count toward workstream spend. Daily caps bound background calls. - -The additive wire contract is v0.3, with four wake methods and a wakes change -part. Storage migrates additively from schema 3 to 4 on the first write. -Related-issue wakes are not built. The job event hook awaits the Factory event -source; queue reads do not trigger it. - -Morning wakes consider only today's local 08:00 slot; opening before it never -catches up yesterday. Undo preserves later draft edits and page metadata -changes, and removes a wake-created wave only while it is unchanged and empty. -A wake keeps only the first page suggestion per existing page or new-page title. diff --git a/docs/changes/unreleased/2320-app-workstream-sources.md b/docs/changes/unreleased/2320-app-workstream-sources.md deleted file mode 100644 index f737b72118..0000000000 --- a/docs/changes/unreleased/2320-app-workstream-sources.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -kind: added -title: Keep sources and agent source proposals in app workstreams -pr: 2320 -surface: [app] -invalidates: - - Collections database files now become schema 5 on their first successful write; an engine built before this change refuses them, leaving collections unavailable there and all data intact. - - Workstream inputs can now be kept as separate sources instead of adding Repos or Files lines to why. ---- - -Workstreams keep file, repo, issue, link and kept-work pointers through the wire -contract's v0.4 source methods, including atomic initial sources on creation. The -agent reads sources and proposes additions for the person to keep or drop. -Removing a source leaves its target intact. Sources are separate from collection -members, with at most 2,000 per workstream; done workstreams refuse source writes. - -Agent source reads refuse final-component links, including links planted after -a file was added, while allowing intermediate directory links. Create retries -return the original workstream even after an initial source disappears. Invalid -UTF-8 source request bytes are refused before decoding, and links obey the -2,048-byte bound both before and after URL normalization. - -Schemas 3 and 4 remain readable without migration. The first successful write adds -the sources table and advances to schema 5 atomically, preserving existing rows. -Schema 3 first gains the wake tables; schema 4 already has them and keeps every wake. -Older engines refuse that newer database with their existing unsupported-database -error; nothing is lost. Source indexing and jobs consuming Read by jobs selections -are separate follow-ups. diff --git a/docs/changes/unreleased/2334-client-tools.md b/docs/changes/unreleased/2334-client-tools.md deleted file mode 100644 index b08d04aa68..0000000000 --- a/docs/changes/unreleased/2334-client-tools.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -kind: added -title: Apps can attach their own tools to a conversation, and collections keep blank conversations -pr: 2334 -surface: [app, chat] -invalidates: - - A never-spoken conversation was removed at launch unless a team referenced it; a conversation any collection references is now kept as well. ---- - -`codeaf app` clients can attach named sets of tools to a conversation with -`agents.tools_set`. The model sees them after every built-in tool, for as long -as that client stays connected. Each call reaches the client as one -`agents.tool_call` event, and the client answers it with `agents.tool_result`. -A disconnect, the deadline (30 seconds by default, at most 120) or the end of the -turn answers the model with an error that says the outcome is unknown. Late or -repeated answers are ignored. A conversation holds at most 4 sets and 64 tools; -built-in tool names are refused, and child tasks never receive client tools. The -hello carries `agents.client_tools`. Conversations held by a remote engine are -refused for now. - -Launch cleanup, the blank-chat sweep and the background sweep now keep a -never-spoken conversation that any collection references. An unreadable -collections database keeps it too. Opening such a conversation by id after a -restart restores its saved details. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index bdd16a7a14..948c863319 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -224,15 +224,14 @@ engine retry: Collection refs include `factory_job`, with a canonical positive decimal job `id` and a required `factory` field. The pair addresses the same job as `factory.job`. -Other ref kinds have no `factory`. Collection results carry `workstream: true` -when their id is a workstream. `collections.delete` refuses these collections -with `bad_params` and the exact message `mark a workstream done instead`; -duplicating one makes a plain collection. A job can belong to only one workstream: -adding a job held by another returns `in_other_workstream` with factory, integer -job, workstream_id and workstream_name detail, and changes nothing in the batch. -Membership changes and renames send `workstream.changed` as well as -`collection.changed`. Factory job targets carry the recorded title, state word -and repository; an unavailable Factory produces an `unknown` target and status. +Other ref kinds have no `factory`. Several collections can reference the same job. +Factory job targets carry the recorded title, state word and repository; an +unavailable Factory produces an `unknown` target and status. A missing job +produces a `deleted` target. Resolution uses one bounded batch across nested +collections: each distinct job is asked once, with a three-second deadline, +up to eight concurrent Factory queue reads and 20 missing-job fallbacks. Queue +reads, including unavailable results, share a ten-second cache of at most +32 factories. A failed queue read never triggers per-job fallbacks. ## competence methods @@ -920,14 +919,6 @@ continue to apply; no method, parameter or reply field is added. | `files.read` | `{agent, limit, offset, path}` | `FileReadResult` | | `files.stat` | `{agent, paths}` | `FilesResult` | -## goahead methods - -| method | params | result | -|---|---|---| -| `goahead.grant` | `in.WorkstreamGoAheadGrantParams` | `WorkstreamGoAheadGrantResult` | -| `goahead.list` | `in.WorkstreamGoAheadListParams` | `WorkstreamGoAheadListResult` | -| `goahead.revoke` | `in.WorkstreamGoAheadRevokeParams` | `WorkstreamGoAheadRevokeResult` | - ## harness methods | method | params | result | @@ -1060,20 +1051,6 @@ $0.10 goes out as `0.1`. | `orders.runs` | `{id, limit}` | `RunsResult` | | `orders.stop` | `in.orderAt` | `OrderResult` | -## page methods - -| method | params | result | -|---|---|---| -| `page.create` | `in.WorkstreamPageCreateParams` | `WorkstreamPageCreateResult` | -| `page.delete` | `in.WorkstreamPageDeleteParams` | `WorkstreamPageDeleteResult` | -| `page.get` | `in.WorkstreamPageGetParams` | `WorkstreamPageGetResult` | -| `page.history` | `in.WorkstreamPageHistoryParams` | `WorkstreamPageHistoryResult` | -| `page.list` | `in.WorkstreamPageListParams` | `WorkstreamPageListResult` | -| `page.move` | `in.WorkstreamPageMoveParams` | `WorkstreamPageMoveResult` | -| `page.restore` | `in.WorkstreamPageRestoreParams` | `WorkstreamPageRestoreResult` | -| `page.save` | `in.WorkstreamPageSaveParams` | `WorkstreamPageSaveResult` | -| `page.update` | `in.WorkstreamPageUpdateParams` | `WorkstreamPageUpdateResult` | - ## ping methods | method | params | result | @@ -1135,16 +1112,6 @@ $0.10 goes out as `0.1`. | `quick.promote` | `in.IDParams` | `taskResult` | | `quick.undo` | `in.IDParams` | `Empty` | -## record methods - -| method | params | result | -|---|---|---| -| `record.drop` | `in.WorkstreamRecordDropParams` | `WorkstreamRecordDropResult` | -| `record.keep` | `in.WorkstreamRecordKeepParams` | `WorkstreamRecordKeepResult` | -| `record.list` | `in.WorkstreamRecordListParams` | `WorkstreamRecordListResult` | -| `record.propose` | `in.WorkstreamRecordProposeParams` | `WorkstreamRecordProposeResult` | -| `record.supersede` | `in.WorkstreamRecordSupersedeParams` | `WorkstreamRecordSupersedeResult` | - ## render methods | method | params | result | @@ -1301,20 +1268,6 @@ role pins and checked as the other pins are. |---|---|---| | `slash.catalog` | `{}` | `SlashCatalogResult` | -## source methods - -| method | params | result | -|---|---|---| -| `source.add` | `in.WorkstreamSourceAddParams` | `WorkstreamSourceAddResult` | -| `source.list` | `in.WorkstreamSourceListParams` | `WorkstreamSourceListResult` | -| `source.remove` | `in.WorkstreamSourceRemoveParams` | `WorkstreamSourceRemoveResult` | -| `source.update` | `in.WorkstreamSourceUpdateParams` | `WorkstreamSourceUpdateResult` | - -A workstream's sources: files, repos, issues, links and things kept from its work. Each is a -pointer in its own table, never a collection member; removing one never touches what it names. -The engine's workstream agent only proposes sources; the person keeps or drops them. Served with -the hello feature `workstream_sources`. - ## One engine on a home @@ -1331,14 +1284,6 @@ the hello feature `workstream_sources`. | `spend.report` | `in.SpendReportParams` | `SpendReportResult` | | `spend.today` | `{}` | `SpendResult` | -## suggestion methods - -| method | params | result | -|---|---|---| -| `suggestion.drop` | `in.WorkstreamSuggestionDropParams` | `WorkstreamSuggestionDropResult` | -| `suggestion.keep` | `in.WorkstreamSuggestionKeepParams` | `WorkstreamSuggestionKeepResult` | -| `suggestion.list` | `in.WorkstreamSuggestionListParams` | `WorkstreamSuggestionListResult` | - ## sync methods | method | params | result | @@ -1382,34 +1327,12 @@ the hello feature `workstream_sources`. | `tools.output` | `in.toolCallParams` | `LinesNextLineTotalResult` | | `tools.unfollow` | `in.toolCallParams` | `Empty` | -## wake methods - -| method | params | result | -|---|---|---| -| `wake.apply` | `in.WorkstreamWakeApplyParams` | `WorkstreamWakeApplyResult` | -| `wake.list` | `in.WorkstreamWakeListParams` | `WorkstreamWakeListResult` | -| `wake.not_now` | `in.WorkstreamWakeNotNowParams` | `WorkstreamWakeNotNowResult` | -| `wake.undo` | `in.WorkstreamWakeUndoParams` | `WorkstreamWakeUndoResult` | - ## work methods | method | params | result | |---|---|---| | `work.graph` | `in.GraphRequest` | `GraphResult` | -## workstream methods - -| method | params | result | -|---|---|---| -| `workstream.create` | `in.WorkstreamCreateParams` | `WorkstreamCreateResult` | -| `workstream.get` | `in.WorkstreamGetParams` | `WorkstreamGetResult` | -| `workstream.list` | `in.WorkstreamListParams` | `WorkstreamListResult` | -| `workstream.mark_done` | `in.WorkstreamMarkDoneParams` | `WorkstreamMarkDoneResult` | -| `workstream.plan_get` | `in.WorkstreamPlanGetParams` | `WorkstreamPlanGetResult` | -| `workstream.plan_set` | `in.WorkstreamPlanSetParams` | `WorkstreamPlanSetResult` | -| `workstream.reopen` | `in.WorkstreamReopenParams` | `WorkstreamReopenResult` | -| `workstream.update` | `in.WorkstreamUpdateParams` | `WorkstreamUpdateResult` | - ## Events | event | data | @@ -1466,7 +1389,6 @@ the hello feature `workstream_sources`. | `turn.thinking` | `TurnThinking` | | `turn.thinking_done` | `TurnThinkingDone` | | `turn.tool` | `TurnTool` | -| `workstream.changed` | `WorkstreamChanged` | ## Slash catalogue commands and forms @@ -1649,40 +1571,6 @@ the state file), so on upgrade every agent starts read, and a new agent starts read because it has said nothing. Engines that serve it announce `agents.mark_seen` in `hello.features`. -## Workstreams (v0 proposal) - -The Workstreams plan, WP-1, serves 25 methods backed by the collections SQLite -store and announces `workstreams` in `hello.features`. Older engines without -this feature must not be called; their methods may answer `unknown_method`. -Pages, version history, records, plans, settings and go-aheads are engine-owned -SQLite state. Markdown is a page export. Every committed change sends one -`workstream.changed` for its id and changed parts; unchanged writes emit nothing. -List/get may persist and announce the first observed job leaving Backlog. -Creation checks refs and ownership before opening one bare agent conversation, -with no first message or model turn. Repeating a nonempty client_id returns the -first workstream. Origin and agent conversations are collection members. -Mark done ends active go-aheads, and optional chat archiving excludes the agent's -record. Reopen keeps the workstream in Building. Recorded Factory facts and agent -spend feed the numbers; unavailable readings remain absent, while measured zero -is present. Queue reads have a three-second call deadline and a ten-second cache -of at most 32 factories. Distinct factories are read concurrently, up to eight -at a time; at most 20 missing jobs get individual reads per call. Further jobs -remain unknown. A Factory connection that cannot be read stays unknown. -Update, mark_done and reopen results use the same bounded, cached facts and agent -spend as get. They persist the first observed start and return the refreshed row, -combining the observation with the mutation's single `workstream.changed` event. -A fault while reading a queue releases waiting readers and caches an unavailable -result for the normal window before a retry. Repeating mark_done preserves the -original done_at and how_it_went. -Client IDs, page/milestone/wave/draft titles and source labels reject control -characters. Why, how_it_went, record text and draft text allow newline and tab -only; other control characters return `bad_params`. Names use collection rules. -Go-aheads are stored with scope, cap and end bounds; expiry is derived on read. -Factory enforcement and automatic wave starts remain separate capabilities. -A workstream is a collection, sharing its id, naming rules and membership methods. -Its jobs use `factory_job` refs with `factory`; a job in another workstream is refused -as `in_other_workstream`. Versioned page and plan writes refuse stale bases with `version_conflict`. - ## Errands (`agents.errand`) An errand is one short, detached, metered model call with the app's own prompt From 99b478f18336eceb95b9f59a3ab148f80acf2d41 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 17:56:12 -0500 Subject: [PATCH 4/5] docs: one change entry for the generic collections, client tools and kept conversations Co-Authored-By: Claude Opus 5.5 --- ...pp-generic-collections-and-client-tools.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md diff --git a/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md b/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md new file mode 100644 index 0000000000..f39c879cd1 --- /dev/null +++ b/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md @@ -0,0 +1,31 @@ +--- +kind: added +title: Collections hold Factory jobs and keep blank conversations, and apps attach client tools +pr: 2336 +surface: [app, chat] +invalidates: + - The collections database becomes schema 7 on its first successful write; an engine built before this change refuses it, leaving collections unavailable there and data intact. + - A never-spoken conversation was removed at launch unless a team referenced it; a conversation any collection references is now kept as well. +--- + +Collections hold Factory jobs as `factory_job` references with a required +`factory` field. The CLI accepts `--factory` for adding, finding and removing +jobs. App collection targets resolve their title, status and repository through +bounded, shared Factory reads; deleted or unavailable targets retain their +references. Several collections can hold the same job. + +`codeaf app` clients can attach named sets of tools to a conversation with +`agents.tools_set`. The model sees them after every built-in tool, for as long +as that client stays connected. Each call reaches the client as one +`agents.tool_call` event, and the client answers it with `agents.tool_result`. +A disconnect, the deadline (30 seconds by default, at most 120) or the end of the +turn answers the model with an error that says the outcome is unknown. Late or +repeated answers are ignored. A conversation holds at most 4 sets and 64 tools; +built-in tool names are refused, and child tasks never receive client tools. The +hello carries `agents.client_tools`. Conversations held by a remote engine are +refused for now. + +Launch cleanup, the blank-chat sweep and the background sweep now keep a +never-spoken conversation that any collection references. An unreadable +collections database keeps it too. Opening such a conversation by id after a +restart restores its saved details. From 039a90c76486bf2e86b34825a0a47ae43a1523f9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 18:08:43 -0500 Subject: [PATCH 5/5] docs: the generic-pieces entry is written fresh, so the gate sees it as added The first version reused the client-tools entry's wording, so git paired the two files as a rename and the change-entry gate found no added entry. Co-Authored-By: Claude Opus 5.5 --- ...pp-generic-collections-and-client-tools.md | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md b/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md index f39c879cd1..ea69b436b4 100644 --- a/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md +++ b/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md @@ -8,24 +8,22 @@ invalidates: - A never-spoken conversation was removed at launch unless a team referenced it; a conversation any collection references is now kept as well. --- -Collections hold Factory jobs as `factory_job` references with a required -`factory` field. The CLI accepts `--factory` for adding, finding and removing -jobs. App collection targets resolve their title, status and repository through -bounded, shared Factory reads; deleted or unavailable targets retain their -references. Several collections can hold the same job. +**Factory jobs in collections.** A membership can name a Factory job: kind +`factory_job` with its `factory`. `codeaf collections add|remove|find` take +`--factory`. In the app, `collections.open` shows each job's title, status and +repository from one bounded, briefly shared read per Factory; a job that is gone +or unreadable keeps its row and says so. One job may sit in several collections. -`codeaf app` clients can attach named sets of tools to a conversation with -`agents.tools_set`. The model sees them after every built-in tool, for as long -as that client stays connected. Each call reaches the client as one -`agents.tool_call` event, and the client answers it with `agents.tool_result`. -A disconnect, the deadline (30 seconds by default, at most 120) or the end of the -turn answers the model with an error that says the outcome is unknown. Late or -repeated answers are ignored. A conversation holds at most 4 sets and 64 tools; -built-in tool names are refused, and child tasks never receive client tools. The -hello carries `agents.client_tools`. Conversations held by a remote engine are -refused for now. +**Client tools.** An app attaches sets of tools to one conversation +(`agents.tools_set`, hello feature `agents.client_tools`). They follow the +built-in tools while that connection lasts. The engine sends every call as an +`agents.tool_call` event and waits for `agents.tool_result`; a lost connection, +the turn ending, or the deadline (default 30 s, ceiling 120 s) gives the model +an error saying the outcome is unknown, and a late or second answer is dropped. +Limits: 4 sets, 64 tools, no built-in names, never inherited by child tasks. +Remote-held conversations are refused for now. -Launch cleanup, the blank-chat sweep and the background sweep now keep a -never-spoken conversation that any collection references. An unreadable -collections database keeps it too. Opening such a conversation by id after a -restart restores its saved details. +**Kept conversations.** Every cleanup path (launch, blank-chat sweep, background +sweep) now spares a never-spoken conversation that some collection holds, and an +unreadable collections file counts as holding it. After a restart, opening it +by id brings back its saved details.