Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions cmd/codeaf/appworkstreams.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,11 +480,11 @@ func workstreamSource(p *appwire.WorkstreamSource) *workstream.Source {
return &workstream.Source{Kind: p.Kind, ID: p.ID, ChatID: workstreamValue(p.ChatID), Factory: workstreamValue(p.Factory), Label: workstreamValue(p.Label)}
}
func appWorkstreamPageNode(p workstream.PageNode) appwire.WorkstreamPageNode {
return appwire.WorkstreamPageNode{ID: p.ID, WorkstreamID: p.WorkstreamID, ParentID: workstreamOptional(p.ParentID), Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs, Position: p.Position, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, UpdatedFrom: appWorkstreamSource(p.UpdatedFrom)}
return appwire.WorkstreamPageNode{ID: p.ID, WorkstreamID: p.WorkstreamID, ParentID: workstreamOptional(p.ParentID), Title: p.Title, Owner: p.Owner, ReadByJobs: p.ReadByJobs, Position: p.Position, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, UpdatedFrom: appWorkstreamSource(p.UpdatedFrom), DeletedAt: p.DeletedAt}
}
func appWorkstreamPage(p workstream.Page) appwire.WorkstreamPage {
n := appWorkstreamPageNode(p.PageNode)
return appwire.WorkstreamPage{ID: n.ID, WorkstreamID: n.WorkstreamID, ParentID: n.ParentID, Title: n.Title, Owner: n.Owner, ReadByJobs: n.ReadByJobs, Position: n.Position, Version: n.Version, UpdatedAt: n.UpdatedAt, UpdatedBy: n.UpdatedBy, UpdatedFrom: n.UpdatedFrom, Doc: p.Doc, Markdown: p.Markdown}
return appwire.WorkstreamPage{ID: n.ID, WorkstreamID: n.WorkstreamID, ParentID: n.ParentID, Title: n.Title, Owner: n.Owner, ReadByJobs: n.ReadByJobs, Position: n.Position, Version: n.Version, UpdatedAt: n.UpdatedAt, UpdatedBy: n.UpdatedBy, UpdatedFrom: n.UpdatedFrom, DeletedAt: n.DeletedAt, Doc: p.Doc, Markdown: p.Markdown}
}
func (b *appBackend) WorkstreamPageList(p appwire.WorkstreamPageListParams) (appwire.WorkstreamPageListResult, error) {
return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageListResult, workstream.Change, error) {
Expand All @@ -493,12 +493,26 @@ func (b *appBackend) WorkstreamPageList(p appwire.WorkstreamPageListParams) (app
for _, page := range pages {
out.Pages = append(out.Pages, appWorkstreamPageNode(page))
}
if err == nil && workstreamValue(p.IncludeDeleted) {
var deleted []workstream.PageNode
deleted, err = s.DeletedPages(context.Background(), p.WorkstreamID)
out.Deleted = []appwire.WorkstreamPageNode{}
for _, page := range deleted {
out.Deleted = append(out.Deleted, appWorkstreamPageNode(page))
}
}
return out, workstream.Change{}, err
})
}
func (b *appBackend) WorkstreamPageGet(p appwire.WorkstreamPageGetParams) (appwire.WorkstreamPageGetResult, error) {
return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageGetResult, workstream.Change, error) {
v, err := s.Page(context.Background(), p.ID, workstreamValue(p.Markdown))
var v workstream.Page
var err error
if p.Version != nil {
v, err = s.PageAtVersion(context.Background(), p.ID, *p.Version, workstreamValue(p.Markdown))
} else {
v, err = s.Page(context.Background(), p.ID, workstreamValue(p.Markdown))
}
return appwire.WorkstreamPageGetResult{Page: appWorkstreamPage(v)}, workstream.Change{}, err
})
}
Expand All @@ -510,7 +524,11 @@ func (b *appBackend) WorkstreamPageSave(p appwire.WorkstreamPageSaveParams) (app
}
func (b *appBackend) WorkstreamPageCreate(p appwire.WorkstreamPageCreateParams) (appwire.WorkstreamPageCreateResult, error) {
return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageCreateResult, workstream.Change, error) {
v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: workstreamValue(p.ReadByJobs), Doc: p.Doc})
read := workstream.ReadByJobsDefault(p.Title)
if p.ReadByJobs != nil {
read = *p.ReadByJobs
}
v, c, err := s.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: p.WorkstreamID, ParentID: workstreamValue(p.ParentID), AfterID: workstreamValue(p.AfterID), Title: p.Title, Owner: workstreamValue(p.Owner), ReadByJobs: read, Doc: p.Doc})
return appwire.WorkstreamPageCreateResult{Page: appWorkstreamPage(v)}, c, err
})
}
Expand Down Expand Up @@ -548,7 +566,14 @@ func (b *appBackend) WorkstreamPageHistory(p appwire.WorkstreamPageHistoryParams
}
func (b *appBackend) WorkstreamPageRestore(p appwire.WorkstreamPageRestoreParams) (appwire.WorkstreamPageRestoreResult, error) {
return workstreamDo(b, p, func(s *workstream.Store) (appwire.WorkstreamPageRestoreResult, workstream.Change, error) {
v, c, err := s.RestorePage(context.Background(), p.ID, p.Version, p.BaseVersion, workstream.Attribution{})
var v workstream.Page
var c workstream.Change
var err error
if p.Version == nil {
v, c, err = s.UndeletePage(context.Background(), p.ID)
} else {
v, c, err = s.RestorePage(context.Background(), p.ID, *p.Version, *p.BaseVersion, workstream.Attribution{})
}
return appwire.WorkstreamPageRestoreResult{Page: appWorkstreamPage(v)}, c, err
})
}
Expand Down
67 changes: 59 additions & 8 deletions cmd/codeaf/appworkstreams_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,24 @@ func workstreamWireSeed(t *testing.T, name string, situation int) *workstreamWir
if name == "page.delete" {
fixture.page(t, "conformance-descendant", fixture.IDs["pg_32e1"])
}
case "page.history", "page.restore":
case "page.history":
fixture.saveByAgent(t)
// WP-F1 (g): prepare all three additive example_1 pairs with the exact
// optional keys illustrated by the wire contract.
case "page.list":
if situation == 1 {
fixture.deletedPage(t)
}
case "page.get":
if situation == 1 {
fixture.saveByAgent(t)
}
case "page.restore":
if situation == 1 {
fixture.deletedPage(t)
} else {
fixture.saveByAgent(t)
}
}
if name == "record.list" || name == "record.keep" || name == "record.drop" || name == "record.supersede" || name == "workstream.changed" {
fixture.record(t)
Expand Down Expand Up @@ -611,6 +627,18 @@ func (fixture *workstreamWireFixture) page(t *testing.T, alias, parent string) {
fixture.IDs[alias] = page.Page.ID
}

func (fixture *workstreamWireFixture) deletedPage(t *testing.T) {
t.Helper()
fixture.page(t, "pg_33f2", fixture.IDs["pg_31d0"])
id := fixture.IDs["pg_33f2"]
if _, err := fixture.Backend.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil {
t.Fatal(err)
}
if _, err := fixture.Backend.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); err != nil {
t.Fatal(err)
}
}

func (fixture *workstreamWireFixture) source() *appwire.WorkstreamSource {
return &appwire.WorkstreamSource{Kind: "message", ID: "msg_19a2", ChatID: workstreamPtr(fixture.IDs["conv_5b9e"]), Label: workstreamPtr("Billing scope")}
}
Expand Down Expand Up @@ -746,10 +774,11 @@ func (contract *workstreamWireContract) checkError(t *testing.T, path string, va
}

type workstreamWireErrorCase struct {
Name string
Method string
Code string
Example string
Name string
Method string
Code string
Example string
ParamsExample int
}

// TestAppWorkstreamWireConformance keeps the table independent of the fixtures:
Expand All @@ -767,6 +796,12 @@ func TestAppWorkstreamWireConformance(t *testing.T) {
errorCases := []workstreamWireErrorCase{
{Name: "bad_params", Method: "workstream.get", Code: "bad_params"},
{Name: "not_found", Method: "workstream.get", Code: "not_found", Example: "workstream.get"},
// WP-F1 (a), (b), (g): new version and restore error doors use real dispatch.
{Name: "get_unknown_version", Method: "page.get", Code: "not_found", ParamsExample: 1},
{Name: "restore_version_only", Method: "page.restore", Code: "bad_params"},
{Name: "restore_base_only", Method: "page.restore", Code: "bad_params"},
{Name: "restore_live_id_only", Method: "page.restore", Code: "bad_params", ParamsExample: 1},
{Name: "restore_deleted_version", Method: "page.restore", Code: "bad_params"},
{Name: "conflict", Method: "workstream.create", Code: "conflict"},
{Name: "collection_owner", Method: "collections.add", Code: "in_other_workstream", Example: "collections.add"},
{Name: "create_owner", Method: "workstream.create", Code: "in_other_workstream", Example: "collections.add"},
Expand Down Expand Up @@ -1155,14 +1190,29 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m
fixture := workstreamWireSeed(t, "workstream.get", 0)
backend := fixture.Backend
examples := files[errorCase.Method]
params := fixture.prepare(t, examples.Examples.Params[0])
params := fixture.prepare(t, examples.Examples.Params[errorCase.ParamsExample])
var engine appwire.Engine = &workstreamWireBackend{appBackend: backend}
release := func() {}
changedShape := false
switch errorCase.Name {
case "bad_params":
delete(params, "id")
changedShape = true
case "not_found":
params["id"] = "missing-workstream"
case "get_unknown_version":
params["version"] = json.Number("99")
case "restore_version_only":
delete(params, "base_version")
changedShape = true
case "restore_base_only":
delete(params, "version")
changedShape = true
case "restore_live_id_only":
params["id"] = fixture.IDs["pg_31d0"]
case "restore_deleted_version":
fixture.deletedPage(t)
params["id"] = fixture.IDs["pg_33f2"]
case "conflict":
params["name"] = fixture.Stream.Name
case "collection_owner":
Expand All @@ -1180,6 +1230,7 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m
params["id"] = fixture.IDs["pg_32e1"]
params["parent_id"] = fixture.IDs["conformance-descendant"]
case "too_large":
changedShape = true
params["doc"] = map[string]any{"type": "doc", "content": []any{
map[string]any{"type": "paragraph", "content": []any{
map[string]any{"type": "text", "text": strings.Repeat("x", (1<<20)+1)},
Expand All @@ -1192,9 +1243,9 @@ func workstreamWireError(t *testing.T, contract *workstreamWireContract, files m
default:
t.Fatalf("unimplemented error case %s", errorCase.Name)
}
if errorCase.Name != "bad_params" && errorCase.Name != "too_large" {
if !changedShape {
comparison := &workstreamWireComparison{}
workstreamWireCheck(t, comparison.shape(errorCase.Method+" error params ", params, workstreamWireDecode(t, examples.Examples.Params[0]), workstreamWirePreparedShape))
workstreamWireCheck(t, comparison.shape(errorCase.Method+" error params ", params, workstreamWireDecode(t, examples.Examples.Params[errorCase.ParamsExample]), workstreamWirePreparedShape))
}
connection := workstreamWireConnect(t, engine)
frame := connection.call(t, errorCase.Method, params)
Expand Down
192 changes: 192 additions & 0 deletions cmd/codeaf/appworkstreams_pages_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package main

import (
"context"
"encoding/json"
"reflect"
"testing"

"github.com/Agent-Field/codeaf/internal/appwire"
"github.com/Agent-Field/codeaf/internal/workstream"
)

// WP-F1 (c): creation alone chooses the title default; explicit booleans win,
// renames retain the existing choice, and the initial Overview remains off.
func TestAppWorkstreamPageReadByJobsDefaults(t *testing.T) {
b, _, _, _ := workstreamBackend(t)
w := workstreamCreateForTest(t, b, "Read defaults")
pages, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID})
if err != nil || len(pages.Pages) != 1 || pages.Pages[0].ReadByJobs {
t.Fatalf("initial Overview: %+v %v", pages, err)
}
for _, tc := range []struct {
title string
value *bool
want bool
}{
{"Spec", nil, true}, {" decisions ", nil, true}, {"DECISIONS", nil, true},
{"Spec notes", nil, false}, {"Overview", nil, false}, {"Notes", nil, false},
{"Spec", workstreamPtr(false), false}, {"Notes", workstreamPtr(true), true},
} {
t.Run(tc.title, func(t *testing.T) {
made, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: tc.title, ReadByJobs: tc.value})
if err != nil || made.Page.ReadByJobs != tc.want {
t.Fatalf("created: %+v %v, want read_by_jobs=%v", made, err, tc.want)
}
rename := "Spec"
if tc.want {
rename = "Notes"
}
updated, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: made.Page.ID, Title: &rename})
if err != nil || updated.Page.ReadByJobs != tc.want {
t.Fatalf("rename changed read_by_jobs: %+v %v", updated, err)
}
})
}
}

// WP-F1 (a): handlers expose historical content and attribution, optional
// markdown and current metadata, including deleted_at for a deleted page.
func TestAppWorkstreamPageGetVersion(t *testing.T) {
b, rec, _, _ := workstreamBackend(t)
w := workstreamCreateForTest(t, b, "Version handler")
store, err := b.workstreamStore(false)
if err != nil || store == nil {
t.Fatalf("store: %v", err)
}
doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Earlier"}]}]}`)
first, _, err := store.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Before", Doc: doc,
Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: "old-source"}}})
if err != nil {
t.Fatal(err)
}
if _, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: first.ID, BaseVersion: 1, Doc: json.RawMessage(`{"type":"doc","content":[]}`)}); err != nil {
t.Fatal(err)
}
if _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: first.ID, Title: workstreamPtr("After"), ReadByJobs: workstreamPtr(true)}); err != nil {
t.Fatal(err)
}
before := len(workstreamEvents(rec))
current, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID})
if err != nil {
t.Fatal(err)
}
selected, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(2)})
if err != nil || !reflect.DeepEqual(selected, current) {
t.Fatalf("current version: %+v, want %+v: %v", selected, current, err)
}
old, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(1), Markdown: workstreamPtr(true)})
if err != nil || old.Page.Title != "Before" || string(old.Page.Doc) != string(doc) || old.Page.Version != 1 || old.Page.UpdatedBy != "agent" || !old.Page.UpdatedAt.Equal(first.UpdatedAt) || old.Page.UpdatedFrom == nil || old.Page.UpdatedFrom.ID != "old-source" || !old.Page.ReadByJobs || old.Page.Markdown == nil || *old.Page.Markdown != "Earlier\n" {
t.Fatalf("historical page: %+v %v", old, err)
}
_, err = b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(99)})
workstreamCheckError(t, err, appwire.CodeNotFound)
for _, version := range []int{0, -1} {
_, err := b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: &version})
workstreamCheckError(t, err, appwire.CodeBadParams)
}
if len(workstreamEvents(rec)) != before {
t.Fatal("version reads emitted changes")
}
if _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: first.ID}); err != nil {
t.Fatal(err)
}
old, err = b.WorkstreamPageGet(appwire.WorkstreamPageGetParams{ID: first.ID, Version: workstreamPtr(1)})
if err != nil || old.Page.DeletedAt.IsZero() || old.Page.Markdown != nil || old.Page.Title != "Before" {
t.Fatalf("deleted historical page: %+v %v", old, err)
}
}

// WP-F1 (b): list presence, deleted write refusals, restore parameter dispatch
// and the committed pages event are visible through the backend handlers.
func TestAppWorkstreamDeletedPageHandlers(t *testing.T) {
b, rec, _, _ := workstreamBackend(t)
w := workstreamCreateForTest(t, b, "Deleted handler")
list := func(include *bool) map[string]json.RawMessage {
t.Helper()
result, err := b.WorkstreamPageList(appwire.WorkstreamPageListParams{WorkstreamID: w.ID, IncludeDeleted: include})
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatal(err)
}
return fields
}
for _, include := range []*bool{nil, workstreamPtr(false)} {
if _, present := list(include)["deleted"]; present {
t.Fatal("deleted present without include_deleted=true")
}
}
if raw := string(list(workstreamPtr(true))["deleted"]); raw != "[]" {
t.Fatalf("empty deleted list = %s", raw)
}
made, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, Title: "Spec"})
if err != nil {
t.Fatal(err)
}
id := made.Page.ID
_, err = b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id})
workstreamCheckError(t, err, appwire.CodeBadParams)
if _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); err != nil {
t.Fatal(err)
}
fields := list(workstreamPtr(true))
var nodes []appwire.WorkstreamPageNode
if err := json.Unmarshal(fields["deleted"], &nodes); err != nil || len(nodes) != 1 || nodes[0].ID != id || nodes[0].DeletedAt.IsZero() {
t.Fatalf("deleted list: %s %v", fields["deleted"], err)
}
for name, call := range map[string]func() error{
"save": func() error {
_, err := b.WorkstreamPageSave(appwire.WorkstreamPageSaveParams{ID: id, BaseVersion: 1, Doc: made.Page.Doc})
return err
},
"update": func() error { _, err := b.WorkstreamPageUpdate(appwire.WorkstreamPageUpdateParams{ID: id}); return err },
"move": func() error { _, err := b.WorkstreamPageMove(appwire.WorkstreamPageMoveParams{ID: id}); return err },
"delete": func() error { _, err := b.WorkstreamPageDelete(appwire.WorkstreamPageDeleteParams{ID: id}); return err },
"history": func() error {
_, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id})
return err
},
"create parent": func() error {
_, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, ParentID: &id, Title: "No"})
return err
},
"create after": func() error {
_, err := b.WorkstreamPageCreate(appwire.WorkstreamPageCreateParams{WorkstreamID: w.ID, AfterID: &id, Title: "No"})
return err
},
} {
t.Run(name, func(t *testing.T) { workstreamCheckError(t, call(), appwire.CodeNotFound) })
}
for _, p := range []appwire.WorkstreamPageRestoreParams{
{ID: id, Version: workstreamPtr(1)}, {ID: id, BaseVersion: workstreamPtr(1)},
{ID: id, Version: workstreamPtr(1), BaseVersion: workstreamPtr(1)},
} {
_, err := b.WorkstreamPageRestore(p)
workstreamCheckError(t, err, appwire.CodeBadParams)
}
_, err = b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: "missing"})
workstreamCheckError(t, err, appwire.CodeNotFound)
before := len(workstreamEvents(rec))
restored, err := b.WorkstreamPageRestore(appwire.WorkstreamPageRestoreParams{ID: id})
if err != nil || restored.Page.Version != 1 || !restored.Page.DeletedAt.IsZero() || restored.Page.UpdatedBy != "you" || restored.Page.UpdatedFrom != nil {
t.Fatalf("undelete: %+v %v", restored, err)
}
events := workstreamEvents(rec)
if len(events) != before+1 || events[before].ID != w.ID || !reflect.DeepEqual(events[before].Parts, []string{"workstream", "pages"}) {
t.Fatalf("undelete event: %+v", events[before:])
}
history, err := b.WorkstreamPageHistory(appwire.WorkstreamPageHistoryParams{ID: id})
if err != nil || len(history.Versions) != 1 {
t.Fatalf("undelete added history: %+v %v", history, err)
}
if raw := string(list(workstreamPtr(true))["deleted"]); raw != "[]" {
t.Fatalf("deleted list after restore = %s", raw)
}
}
Loading
Loading