For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a collaborator select N≥2 open Topics on a Source and have the Agent incorporate them in one rewrite, one proposal, one commit — by modelling a batch as a Topic with anchor.kind = "batch" and a parent_topic_id link from each child.
Architecture: Add topics.parent_topic_id (nullable self-FK) and a new "batch" anchor kind. Drop the composite FK topics.(incorporated_proposal_id, id) → incorporation_proposals(id, topic_id) and enforce the looser invariant ("a Topic's incorporated proposal points at this Topic OR its parent") via a SQLite trigger + the existing collab write funnel. Every other piece — wb-agent, wb-incorporate, proposal freshness, the Approve handler, the realtime event surface — extends to handle the batch case where N=1 is the degenerate of N≥2.
Tech Stack: Go (server + wb-agent CLI + tests), SQLite (modernc.org/sqlite), plain JS/CSS (chrome.js), Claude Code (the Agent runtime), chromedp (e2e tests).
Spec: docs/superpowers/specs/2026-05-20-batched-incorporation-design.html
internal/collab/migrations/006_batched_incorporation.sql — new column, dropped composite FK, triggers, index.internal/collab/batch.go — store helpers: CreateBatch, BatchParentForTopic, BatchChildrenOfParent, IsTopicBatched, DiscardBatch.internal/collab/batch_test.go — unit tests for the above.internal/server/handler_batches.go — POST /api/batches.internal/server/handler_batches_test.go — handler tests.internal/collab/anchor.go — add BatchAnchor{} type and "batch" kind.internal/collab/anchor_test.go — extend tests.internal/collab/mutators.go — CompleteIncorporation accepts N child topic IDs; ClaimIncorporation excludes batched children + batch parents.internal/collab/mutators_test.go — extend tests.internal/collab/freshness.go — openNonGlobalTopicIDsExcluding also excludes batched + batch parents.internal/collab/freshness_test.go — extend tests.internal/collab/incorporate.go — IncorporateInput passes child set; commit message gets one Topic: trailer per transitioned Topic.internal/collab/incorporate_test.go — extend tests.internal/collab/gitops.go — CommitInput.TopicIDs (slice) replaces TopicID (string) so trailers loop.internal/collab/gitops_test.go — extend tests.internal/collab/reader.go — TopicSummary carries ParentTopicID so the sidebar can render children under their parent.cmd/wb-agent/get_topic.go — when target Topic has anchor.kind = "batch", include children: [{topic, anchor, messages}].cmd/wb-agent/list_open_topics.go — replace --exclude-topic (singular) with --exclude-topics (CSV).cmd/wb-agent/main_test.go — extend tests.internal/server/handler_proposals.go — handleProposeRewrite 409s on a batched child; handleDiscardTopic clears children and writes audit reason when target is a batch parent; handleIncorporate transitions N+1 atomically + emits N+1 events.internal/server/handler_proposals_test.go — extend tests.internal/server/server.go — register POST /api/batches.internal/server/static/chrome.js — sidebar grouping with Batches section, multi-select mode, Resolve toolbar expander.internal/server/static/chrome.css — accent rail for children, multi-select header, batch pill.internal/server/static/chrome.css lives under internal/server/templates/ if the existing layout puts CSS there — check the actual layout when editing.internal/server/e2e_resolve_test.go — extend with a batch end-to-end test..claude/skills/wb-incorporate/SKILL.md — branch on anchor.kind = "batch"; --exclude-topics plural; explanation contract covers N rewrites._test sibling package where the existing file uses it).go test ./internal/collab/ -run TestBatchAnchor -v. Run the whole package with go test ./internal/collab/ -v. Run the full test suite with go test ./....s.send(func(db *sql.DB) error { ... }) for writes — never bypass it from inside the same process. Reads can use s.db (or s.RawDB() from outside the package) directly.github.com/getorcha/wiki-browser; the local working directory is /home/volrath/code/orcha/wiki-browser; the orcha repo root is one level above (cfg.Root).Co-Authored-By lines. Use a single-line git commit -m "..." per the user's convention.parent_topic_id, drop composite FK, add triggersFiles:
internal/collab/migrations/006_batched_incorporation.sqlinternal/collab/migrate_test.go (extend) and internal/collab/batch_test.go (new)The composite FK topics.(incorporated_proposal_id, id) → incorporation_proposals(id, topic_id) cannot be dropped via ALTER TABLE in SQLite. Use the twelve-step rebuild pattern from migration 003. Mirror the structure of migrations/003_agent_runtime.sql.
Append the test to internal/collab/migrate_test.go (study the existing tests in that file for the pattern — they use collab.Open(...) and then s.RawDB().QueryRow(...) to inspect schema).
func TestMigration006_addsParentTopicAndDropsCompositeFK(t *testing.T) {
dir := t.TempDir()
s, err := Open(Config{Path: filepath.Join(dir, "collab.db")})
if err != nil {
t.Fatal(err)
}
defer s.Close()
// parent_topic_id column exists, nullable, FK to topics(id).
rows, err := s.RawDB().Query(`PRAGMA table_info(topics)`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var found bool
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
t.Fatal(err)
}
if name == "parent_topic_id" {
found = true
if notnull != 0 {
t.Fatalf("parent_topic_id should be nullable, got notnull=%d", notnull)
}
}
}
if !found {
t.Fatal("parent_topic_id column missing")
}
// Composite FK on (incorporated_proposal_id, id) is GONE.
var sqlText string
if err := s.RawDB().QueryRow(
`SELECT sql FROM sqlite_master WHERE type='table' AND name='topics'`,
).Scan(&sqlText); err != nil {
t.Fatal(err)
}
if strings.Contains(sqlText, "REFERENCES incorporation_proposals(id, topic_id)") {
t.Fatalf("composite FK still present in topics schema:\n%s", sqlText)
}
// Triggers exist. The "no grandparent" invariant is split across two
// trigger names — one for UPDATE OF anchor, one for UPDATE OF parent_topic_id
// (and a third pair on INSERT, see migration SQL below).
wanted := []string{
"topics_incorporated_proposal_match",
"topics_parent_must_be_batch",
"topics_parent_must_be_batch_insert",
"topics_batch_no_grandparent_anchor",
"topics_batch_no_grandparent_parent",
"topics_batch_no_grandparent_insert",
}
for _, name := range wanted {
var n int
if err := s.RawDB().QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name=?`, name,
).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("trigger %s missing", name)
}
}
// Index exists.
var idxCount int
if err := s.RawDB().QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='topics_parent_topic_id'`,
).Scan(&idxCount); err != nil {
t.Fatal(err)
}
if idxCount != 1 {
t.Fatal("topics_parent_topic_id index missing")
}
}
go test ./internal/collab/ -run TestMigration006 -v
Expected: FAIL (parent_topic_id column missing).
006_batched_incorporation.sql-- migrate:no-tx
-- Add topics.parent_topic_id, drop the composite FK
-- topics.(incorporated_proposal_id, id) → incorporation_proposals(id, topic_id),
-- and install triggers that enforce the new integrity invariants.
--
-- Idempotency: CREATE TRIGGER / CREATE INDEX use IF NOT EXISTS. The 12-step
-- rebuild itself is NOT idempotent across re-runs — the SELECT below hardcodes
-- parent_topic_id = NULL because the source `topics` table on first run has
-- no such column. Re-running this migration after any batch has been created
-- would wipe every parent_topic_id back to NULL. The migration runner's
-- schema_migrations bookkeeping is the only mechanism preventing re-runs;
-- never replay this file manually once it has applied successfully.
PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = OFF;
BEGIN;
CREATE TABLE topics_new (
id TEXT PRIMARY KEY,
source_path TEXT NOT NULL,
anchor TEXT,
parent_topic_id TEXT REFERENCES topics(id) ON DELETE RESTRICT,
commit_sha TEXT,
incorporated_proposal_id TEXT,
incorporated_by TEXT,
incorporated_at INTEGER,
discarded_by TEXT,
discarded_at INTEGER,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
updated_at INTEGER NOT NULL,
CHECK ((commit_sha IS NULL) = (incorporated_at IS NULL)),
CHECK ((incorporated_proposal_id IS NULL) = (incorporated_at IS NULL)),
CHECK ((incorporated_by IS NULL) = (incorporated_at IS NULL)),
CHECK ((discarded_by IS NULL) = (discarded_at IS NULL)),
CHECK (incorporated_at IS NULL OR discarded_at IS NULL),
FOREIGN KEY (created_by) REFERENCES users(id),
FOREIGN KEY (incorporated_by) REFERENCES users(id),
FOREIGN KEY (discarded_by) REFERENCES users(id),
FOREIGN KEY (incorporated_proposal_id) REFERENCES incorporation_proposals(id)
);
INSERT INTO topics_new (
id, source_path, anchor, parent_topic_id, commit_sha,
incorporated_proposal_id, incorporated_by, incorporated_at,
discarded_by, discarded_at, created_at, created_by, updated_at
)
SELECT id, source_path, anchor, NULL, commit_sha,
incorporated_proposal_id, incorporated_by, incorporated_at,
discarded_by, discarded_at, created_at, created_by, updated_at
FROM topics;
DROP TABLE topics;
ALTER TABLE topics_new RENAME TO topics;
CREATE INDEX IF NOT EXISTS topics_by_source_path ON topics(source_path);
CREATE INDEX IF NOT EXISTS topics_parent_topic_id ON topics(parent_topic_id) WHERE parent_topic_id IS NOT NULL;
-- Replacement integrity trigger for the dropped composite FK. When a Topic
-- transitions to incorporated, the proposal it points at must be anchored on
-- the Topic itself OR on the Topic's parent (for a batched child).
CREATE TRIGGER IF NOT EXISTS topics_incorporated_proposal_match
AFTER UPDATE OF incorporated_proposal_id ON topics
WHEN NEW.incorporated_proposal_id IS NOT NULL
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM incorporation_proposals p
WHERE p.id = NEW.incorporated_proposal_id
AND (p.topic_id = NEW.id OR p.topic_id = NEW.parent_topic_id)
) THEN RAISE(ABORT, 'incorporated_proposal_id does not belong to topic or its parent') END;
END;
-- A child's parent_topic_id must reference a Topic with anchor.kind = "batch".
-- Fires on both UPDATE and INSERT so a row that arrives with parent_topic_id
-- already populated also gets the check.
CREATE TRIGGER IF NOT EXISTS topics_parent_must_be_batch
AFTER UPDATE OF parent_topic_id ON topics
WHEN NEW.parent_topic_id IS NOT NULL
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM topics p
WHERE p.id = NEW.parent_topic_id
AND json_extract(p.anchor, '$.kind') = 'batch'
) THEN RAISE(ABORT, 'parent_topic_id must reference a topic with anchor kind = batch') END;
END;
CREATE TRIGGER IF NOT EXISTS topics_parent_must_be_batch_insert
AFTER INSERT ON topics
WHEN NEW.parent_topic_id IS NOT NULL
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM topics p
WHERE p.id = NEW.parent_topic_id
AND json_extract(p.anchor, '$.kind') = 'batch'
) THEN RAISE(ABORT, 'parent_topic_id must reference a topic with anchor kind = batch') END;
END;
-- A batch parent itself cannot have a parent (no nested batches). Guard on
-- UPDATE OF anchor, UPDATE OF parent_topic_id, and INSERT — covers every
-- mutation that could establish the forbidden combination.
CREATE TRIGGER IF NOT EXISTS topics_batch_no_grandparent_anchor
AFTER UPDATE OF anchor ON topics
WHEN json_extract(NEW.anchor, '$.kind') = 'batch' AND NEW.parent_topic_id IS NOT NULL
BEGIN
SELECT RAISE(ABORT, 'a batch parent cannot itself be a child');
END;
CREATE TRIGGER IF NOT EXISTS topics_batch_no_grandparent_parent
AFTER UPDATE OF parent_topic_id ON topics
WHEN NEW.parent_topic_id IS NOT NULL AND json_extract(NEW.anchor, '$.kind') = 'batch'
BEGIN
SELECT RAISE(ABORT, 'a batch parent cannot itself be a child');
END;
CREATE TRIGGER IF NOT EXISTS topics_batch_no_grandparent_insert
AFTER INSERT ON topics
WHEN NEW.parent_topic_id IS NOT NULL AND json_extract(NEW.anchor, '$.kind') = 'batch'
BEGIN
SELECT RAISE(ABORT, 'a batch parent cannot itself be a child');
END;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;
go test ./internal/collab/ -run TestMigration006 -v
Expected: PASS.
The triggers are the integrity contract that replaces the dropped composite FK. Confirming they fire (not just exist) is more valuable than the existence check from Step 1. Append to internal/collab/migrate_test.go:
func TestTrigger_topicsIncorporatedProposalMatch_fires(t *testing.T) {
s, _ := newTestStore(t)
if err := s.UpsertUser(User{ID: "u", DisplayName: "U"}); err != nil {
t.Fatal(err)
}
// Two unrelated topics on different sources; the proposal belongs to t1,
// but we try to point t2.incorporated_proposal_id at it.
anchor, _ := MarshalAnchor(GlobalAnchor{})
t1 := uuid.NewString()
t2 := uuid.NewString()
for _, id := range []string{t1, t2} {
if err := s.InsertTopic(NewTopic{
ID: id, SourcePath: "doc.md", Anchor: anchor, CreatedBy: "u",
}); err != nil {
t.Fatal(err)
}
}
propID := uuid.NewString()
if _, err := s.InsertProposal(NewProposal{
ID: propID, TopicID: t1, RevisionNumber: 1,
ProposedSource: "x", BaseSourceSHA: strings.Repeat("0", 40),
}); err != nil {
t.Fatal(err)
}
// Direct UPDATE bypassing CompleteIncorporation — the trigger must refuse.
_, err := s.RawDB().Exec(
`UPDATE topics
SET incorporated_proposal_id = ?, commit_sha = 'x',
incorporated_by = 'u', incorporated_at = 1
WHERE id = ?`,
propID, t2,
)
if err == nil {
t.Fatal("expected trigger to refuse mismatched proposal/topic pairing")
}
if !strings.Contains(err.Error(), "does not belong to topic or its parent") {
t.Errorf("unexpected trigger message: %v", err)
}
}
func TestTrigger_topicsParentMustBeBatch_fires(t *testing.T) {
s, _ := newTestStore(t)
if err := s.UpsertUser(User{ID: "u", DisplayName: "U"}); err != nil {
t.Fatal(err)
}
// Two topics. Attempt to make t1 a child of t2 — but t2 has a global
// anchor, not a batch anchor. The trigger must refuse.
anchor, _ := MarshalAnchor(GlobalAnchor{})
t1, t2 := uuid.NewString(), uuid.NewString()
for _, id := range []string{t1, t2} {
if err := s.InsertTopic(NewTopic{
ID: id, SourcePath: "doc.md", Anchor: anchor, CreatedBy: "u",
}); err != nil {
t.Fatal(err)
}
}
_, err := s.RawDB().Exec(`UPDATE topics SET parent_topic_id = ? WHERE id = ?`, t2, t1)
if err == nil {
t.Fatal("expected trigger to refuse non-batch parent")
}
if !strings.Contains(err.Error(), "anchor kind = batch") {
t.Errorf("unexpected trigger message: %v", err)
}
}
func TestTrigger_topicsBatchNoGrandparent_fires(t *testing.T) {
s, _ := newTestStore(t)
if err := s.UpsertUser(User{ID: "u", DisplayName: "U"}); err != nil {
t.Fatal(err)
}
// A batch-anchored topic must not have a parent_topic_id. Seed a valid
// batch parent (parent_topic_id NULL), then try to attach it to another
// batch parent — the trigger must refuse.
batchAnchor, _ := MarshalAnchor(BatchAnchor{})
outer := uuid.NewString()
inner := uuid.NewString()
for _, id := range []string{outer, inner} {
if err := s.InsertTopic(NewTopic{
ID: id, SourcePath: "doc.md", Anchor: batchAnchor, CreatedBy: "u",
}); err != nil {
t.Fatal(err)
}
}
_, err := s.RawDB().Exec(`UPDATE topics SET parent_topic_id = ? WHERE id = ?`, outer, inner)
if err == nil {
t.Fatal("expected trigger to refuse a batch attached to another batch")
}
if !strings.Contains(err.Error(), "batch parent cannot itself be a child") {
t.Errorf("unexpected trigger message: %v", err)
}
}
If newTestStore doesn't exist as a shared helper, write a small one at the top of migrate_test.go that opens a fresh Store against a temp DB.
go test ./internal/collab/ -run 'TestTrigger_' -v
go test ./internal/collab/ -v
Expected: PASS. If any pre-existing test breaks because it asserted against the old schema's column order, fix the assertion to use column names rather than positional indexes.
git add internal/collab/migrations/006_batched_incorporation.sql internal/collab/migrate_test.go
git commit -m "collab: migration 006 — add parent_topic_id, drop composite FK, install batch triggers"
BatchAnchor to anchor.goFiles:
internal/collab/anchor.gointernal/collab/anchor_test.goA batch parent's anchor is {"kind": "batch"} with no payload — same shape as GlobalAnchor.
Append to internal/collab/anchor_test.go:
func TestBatchAnchor_marshalUnmarshalRoundtrip(t *testing.T) {
raw, err := MarshalAnchor(BatchAnchor{})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := string(raw); got != `{"kind":"batch"}` {
t.Fatalf("marshal: got %s", got)
}
got, err := UnmarshalAnchor(raw)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := got.(BatchAnchor); !ok {
t.Fatalf("expected BatchAnchor, got %T", got)
}
}
go test ./internal/collab/ -run TestBatchAnchor -v
Expected: FAIL (BatchAnchor undefined).
In internal/collab/anchor.go, add:
type BatchAnchor struct{}
func (BatchAnchor) AnchorKind() string { return "batch" }
In MarshalAnchor's switch, add:
case BatchAnchor:
return json.Marshal(struct {
Kind string `json:"kind"`
}{Kind: v.AnchorKind()})
In UnmarshalAnchor's switch, add:
case "batch":
var v struct {
Kind string `json:"kind"`
}
if err := decode(&v); err != nil {
return nil, fmt.Errorf("collab anchor: decode batch: %w", err)
}
return BatchAnchor{}, nil
In ValidateAnchor's switch, add:
case BatchAnchor:
return nil
go test ./internal/collab/ -run TestBatchAnchor -v
Expected: PASS.
go test ./internal/collab/ -run TestAnchor -v
Expected: all anchor tests pass.
git add internal/collab/anchor.go internal/collab/anchor_test.go
git commit -m "collab: add BatchAnchor for batch-parent Topics"
CreateBatch, BatchParentForTopic, BatchChildrenOfParent, IsTopicBatched, DiscardBatchFiles:
internal/collab/batch.gointernal/collab/batch_test.goThese helpers wrap the SQLite operations that POST /api/batches, handleDiscardTopic, and the realtime / read paths will need.
Create internal/collab/batch_test.go:
package collab
import (
"path/filepath"
"testing"
"github.com/google/uuid"
)
// helper: open a store backed by a temp DB and seed one user + two open
// Topics on the same source. Returns (store, user_id, child_a_id, child_b_id, source_path).
func newBatchTestEnv(t *testing.T) (*Store, string, string, string, string) {
t.Helper()
s, err := Open(Config{Path: filepath.Join(t.TempDir(), "collab.db")})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
if err := s.UpsertUser(User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}); err != nil {
t.Fatal(err)
}
src := "doc.md"
anchor, _ := MarshalAnchor(GlobalAnchor{})
a := uuid.NewString()
b := uuid.NewString()
if err := s.InsertTopicWithFirstMessage(NewTopicWithFirstMessage{
TopicID: a, SourcePath: src, Anchor: anchor, CreatedBy: "daniel@getorcha.com",
FirstMessageID: uuid.NewString(), FirstMessageBody: "child a",
}); err != nil {
t.Fatal(err)
}
if err := s.InsertTopicWithFirstMessage(NewTopicWithFirstMessage{
TopicID: b, SourcePath: src, Anchor: anchor, CreatedBy: "daniel@getorcha.com",
FirstMessageID: uuid.NewString(), FirstMessageBody: "child b",
}); err != nil {
t.Fatal(err)
}
return s, "daniel@getorcha.com", a, b, src
}
func TestCreateBatch_attachesChildrenToNewParent(t *testing.T) {
s, user, a, b, src := newBatchTestEnv(t)
parentID := uuid.NewString()
if err := s.CreateBatch(CreateBatchInput{
ParentTopicID: parentID,
SourcePath: src,
ChildTopicIDs: []string{a, b},
CreatedBy: user,
}); err != nil {
t.Fatalf("CreateBatch: %v", err)
}
// Parent exists with anchor.kind = batch and parent_topic_id NULL.
var anchor string
if err := s.RawDB().QueryRow(
`SELECT anchor FROM topics WHERE id = ?`, parentID,
).Scan(&anchor); err != nil {
t.Fatal(err)
}
if anchor != `{"kind":"batch"}` {
t.Fatalf("parent anchor: %s", anchor)
}
// Both children have parent_topic_id = parentID.
for _, child := range []string{a, b} {
var got string
if err := s.RawDB().QueryRow(
`SELECT parent_topic_id FROM topics WHERE id = ?`, child,
).Scan(&got); err != nil {
t.Fatal(err)
}
if got != parentID {
t.Fatalf("child %s parent_topic_id: got %s want %s", child, got, parentID)
}
}
}
func TestCreateBatch_rejectsAlreadyBatchedChild(t *testing.T) {
s, user, a, b, src := newBatchTestEnv(t)
firstParent := uuid.NewString()
if err := s.CreateBatch(CreateBatchInput{
ParentTopicID: firstParent, SourcePath: src, ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
secondParent := uuid.NewString()
err := s.CreateBatch(CreateBatchInput{
ParentTopicID: secondParent, SourcePath: src, ChildTopicIDs: []string{a}, CreatedBy: user,
})
if err == nil {
t.Fatal("expected error when re-batching an attached child")
}
}
func TestCreateBatch_rejectsCrossSource(t *testing.T) {
s, user, a, b, _ := newBatchTestEnv(t)
anchor, _ := MarshalAnchor(GlobalAnchor{})
other := uuid.NewString()
if err := s.InsertTopicWithFirstMessage(NewTopicWithFirstMessage{
TopicID: other, SourcePath: "other.md", Anchor: anchor, CreatedBy: user,
FirstMessageID: uuid.NewString(), FirstMessageBody: "x",
}); err != nil {
t.Fatal(err)
}
_ = b
parentID := uuid.NewString()
if err := s.CreateBatch(CreateBatchInput{
ParentTopicID: parentID, SourcePath: "doc.md", ChildTopicIDs: []string{a, other}, CreatedBy: user,
}); err == nil {
t.Fatal("expected error when children span sources")
}
}
func TestBatchParentForTopic_andBatchChildren(t *testing.T) {
s, user, a, b, src := newBatchTestEnv(t)
parentID := uuid.NewString()
if err := s.CreateBatch(CreateBatchInput{
ParentTopicID: parentID, SourcePath: src, ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
got, err := s.BatchParentForTopic(a)
if err != nil {
t.Fatal(err)
}
if got != parentID {
t.Fatalf("parent for child a: got %s want %s", got, parentID)
}
children, err := s.BatchChildrenOfParent(parentID)
if err != nil {
t.Fatal(err)
}
if len(children) != 2 {
t.Fatalf("children: got %d want 2", len(children))
}
}
func TestIsTopicBatched(t *testing.T) {
s, user, a, b, src := newBatchTestEnv(t)
got, err := s.IsTopicBatched(a)
if err != nil || got {
t.Fatalf("unbatched topic: got=%v err=%v", got, err)
}
parentID := uuid.NewString()
if err := s.CreateBatch(CreateBatchInput{
ParentTopicID: parentID, SourcePath: src, ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
got, err = s.IsTopicBatched(a)
if err != nil || !got {
t.Fatalf("batched child: got=%v err=%v", got, err)
}
}
func TestDiscardBatch_clearsChildrenAndWritesAuditMessage(t *testing.T) {
s, user, a, b, src := newBatchTestEnv(t)
parentID := uuid.NewString()
if err := s.CreateBatch(CreateBatchInput{
ParentTopicID: parentID, SourcePath: src, ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
if _, err := s.DiscardBatch(DiscardBatchInput{
ParentTopicID: parentID, ByUser: user, Reason: "reformulating",
}); err != nil {
t.Fatal(err)
}
// Parent is discarded.
var discardedAt sql.NullInt64
if err := s.RawDB().QueryRow(
`SELECT discarded_at FROM topics WHERE id = ?`, parentID,
).Scan(&discardedAt); err != nil {
t.Fatal(err)
}
if !discardedAt.Valid {
t.Fatal("parent not discarded")
}
// Children have parent_topic_id cleared and are still open.
for _, child := range []string{a, b} {
var p sql.NullString
var inc, disc sql.NullInt64
if err := s.RawDB().QueryRow(
`SELECT parent_topic_id, incorporated_at, discarded_at FROM topics WHERE id = ?`, child,
).Scan(&p, &inc, &disc); err != nil {
t.Fatal(err)
}
if p.Valid {
t.Fatalf("child %s still attached: parent=%s", child, p.String)
}
if inc.Valid || disc.Valid {
t.Fatalf("child %s should still be open", child)
}
}
// An audit message exists in the parent's thread containing both child IDs.
msgs, err := s.ListMessages(parentID)
if err != nil {
t.Fatal(err)
}
var found bool
for _, m := range msgs {
if m.Kind == "human" && strings.Contains(m.Body, a) && strings.Contains(m.Body, b) {
found = true
break
}
}
if !found {
t.Fatal("audit message missing child IDs")
}
}
Add the necessary imports (database/sql, strings, github.com/google/uuid).
go test ./internal/collab/ -run TestCreateBatch -v
Expected: FAIL (CreateBatch undefined).
internal/collab/batch.gopackage collab
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// ErrChildAlreadyBatched is returned when CreateBatch finds one of the
// requested children is already attached to an open parent.
var ErrChildAlreadyBatched = errors.New("child already batched")
// ErrCrossSourceBatch is returned when children span more than one source_path.
var ErrCrossSourceBatch = errors.New("batch children must share a source_path")
// ErrBatchMinimum is returned when fewer than 2 children are requested.
var ErrBatchMinimum = errors.New("batch requires at least 2 children")
// ErrNotBatchParent is returned when a caller asks for batch operations on a
// non-batch Topic.
var ErrNotBatchParent = errors.New("topic is not a batch parent")
// CreateBatchInput is the input to CreateBatch. The parent's id is provided by
// the caller so the HTTP handler can return it in the response without a
// follow-up read; SourcePath is the same as the children's. CreatedBy is the
// FK for both the parent topic row and the audit message it writes on abandon.
type CreateBatchInput struct {
ParentTopicID string
SourcePath string
ChildTopicIDs []string
CreatedBy string
}
// CreateBatch atomically:
// 1. Validates the input (≥2 children, all open, all on the same source,
// none already batched).
// 2. Inserts a batch parent Topic with anchor.kind = "batch".
// 3. Sets parent_topic_id on every child.
//
// All three steps happen inside a single funnel turn so concurrent batch
// creation can't double-attach a child.
func (s *Store) CreateBatch(in CreateBatchInput) error {
if in.ParentTopicID == "" || in.SourcePath == "" || in.CreatedBy == "" {
return fmt.Errorf("collab.CreateBatch: required fields missing")
}
if len(in.ChildTopicIDs) < 2 {
return fmt.Errorf("collab.CreateBatch: %w", ErrBatchMinimum)
}
if _, err := ValidateSourcePath(in.SourcePath); err != nil {
return fmt.Errorf("collab.CreateBatch: %w", err)
}
anchor, err := MarshalAnchor(BatchAnchor{})
if err != nil {
return fmt.Errorf("collab.CreateBatch: marshal anchor: %w", err)
}
return s.send(func(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
// Validate each child: open, on the requested source, not batched.
for _, child := range in.ChildTopicIDs {
var (
src string
parent sql.NullString
inc, disc sql.NullInt64
)
if err := tx.QueryRow(
`SELECT source_path, parent_topic_id, incorporated_at, discarded_at
FROM topics WHERE id = ?`, child,
).Scan(&src, &parent, &inc, &disc); err != nil {
_ = tx.Rollback()
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("collab.CreateBatch: child %s not found", child)
}
return err
}
if src != in.SourcePath {
_ = tx.Rollback()
return fmt.Errorf("collab.CreateBatch: %w (child %s on %s, want %s)",
ErrCrossSourceBatch, child, src, in.SourcePath)
}
if inc.Valid || disc.Valid {
_ = tx.Rollback()
return fmt.Errorf("collab.CreateBatch: child %s: %w", child, ErrTopicAlreadyTerminal)
}
if parent.Valid {
_ = tx.Rollback()
return fmt.Errorf("collab.CreateBatch: %w (child %s already attached to %s)",
ErrChildAlreadyBatched, child, parent.String)
}
}
// Insert the parent.
if _, err := tx.Exec(
`INSERT INTO topics(id, source_path, anchor, created_at, created_by, updated_at)
VALUES (?, ?, ?, unixepoch(), ?, unixepoch())`,
in.ParentTopicID, in.SourcePath, string(anchor), in.CreatedBy,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("insert parent: %w", err)
}
// Attach each child. The topics_parent_must_be_batch trigger runs on
// each UPDATE and refuses if the parent doesn't carry anchor.kind=batch.
for _, child := range in.ChildTopicIDs {
res, err := tx.Exec(
`UPDATE topics SET parent_topic_id = ?, updated_at = unixepoch()
WHERE id = ?
AND parent_topic_id IS NULL
AND incorporated_at IS NULL
AND discarded_at IS NULL`,
in.ParentTopicID, child,
)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("attach child %s: %w", child, err)
}
n, err := res.RowsAffected()
if err != nil {
_ = tx.Rollback()
return err
}
if n != 1 {
// Lost a race after the pre-check above: another batch claimed
// the child between SELECT and UPDATE.
_ = tx.Rollback()
return fmt.Errorf("collab.CreateBatch: %w (child %s claimed concurrently)",
ErrChildAlreadyBatched, child)
}
}
return tx.Commit()
})
}
// BatchParentForTopic returns the parent_topic_id for the given topic, or ""
// if the topic is not batched. Returns sql.ErrNoRows for unknown topic IDs.
func (s *Store) BatchParentForTopic(topicID string) (string, error) {
var parent sql.NullString
if err := s.db.QueryRow(
`SELECT parent_topic_id FROM topics WHERE id = ?`, topicID,
).Scan(&parent); err != nil {
return "", err
}
if !parent.Valid {
return "", nil
}
return parent.String, nil
}
// IsTopicBatched is BatchParentForTopic compressed to a bool.
func (s *Store) IsTopicBatched(topicID string) (bool, error) {
parent, err := s.BatchParentForTopic(topicID)
if err != nil {
return false, err
}
return parent != "", nil
}
// IsBatchParent returns true when the given topic has anchor.kind = "batch".
// Used by handler_proposals.go (routing discard / incorporate) and the agent
// service (post-job invariant generalisation).
func (s *Store) IsBatchParent(topicID string) (bool, error) {
var raw string
err := s.db.QueryRow(`SELECT anchor FROM topics WHERE id = ?`, topicID).Scan(&raw)
if err != nil {
return false, err
}
a, err := UnmarshalAnchor([]byte(raw))
if err != nil {
return false, err
}
_, ok := a.(BatchAnchor)
return ok, nil
}
// BatchChildrenOfParent returns the open child topic IDs attached to parentID,
// sorted ascending by id. Discarded/incorporated rows whose parent_topic_id
// is non-null (post-incorporation children) are also returned — callers that
// only want open children must filter.
func (s *Store) BatchChildrenOfParent(parentID string) ([]string, error) {
rows, err := s.db.Query(
`SELECT id FROM topics WHERE parent_topic_id = ? ORDER BY id`,
parentID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// DiscardBatchInput drives DiscardBatch.
type DiscardBatchInput struct {
ParentTopicID string
ByUser string
Reason string // optional; combined with the children-ID list into the audit message
}
// DiscardBatch flips the parent to discarded, clears parent_topic_id on every
// child, and writes a kind='human' audit message in the parent's thread that
// enumerates the attached children at abandon time. Returns the discarded_at
// timestamp.
func (s *Store) DiscardBatch(in DiscardBatchInput) (int64, error) {
if in.ParentTopicID == "" || in.ByUser == "" {
return 0, fmt.Errorf("collab.DiscardBatch: required fields missing")
}
now := time.Now().Unix()
err := s.send(func(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
// Confirm the target is an open batch parent.
var anchor string
var inc, disc sql.NullInt64
if err := tx.QueryRow(
`SELECT anchor, incorporated_at, discarded_at FROM topics WHERE id = ?`,
in.ParentTopicID,
).Scan(&anchor, &inc, &disc); err != nil {
_ = tx.Rollback()
return err
}
a, err := UnmarshalAnchor([]byte(anchor))
if err != nil {
_ = tx.Rollback()
return err
}
if _, ok := a.(BatchAnchor); !ok {
_ = tx.Rollback()
return fmt.Errorf("collab.DiscardBatch: %w", ErrNotBatchParent)
}
if inc.Valid || disc.Valid {
_ = tx.Rollback()
return fmt.Errorf("collab.DiscardBatch: %w", ErrTopicAlreadyTerminal)
}
// Collect children for the audit message + the detach.
rows, err := tx.Query(
`SELECT id FROM topics WHERE parent_topic_id = ? ORDER BY id`,
in.ParentTopicID,
)
if err != nil {
_ = tx.Rollback()
return err
}
var children []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
_ = tx.Rollback()
return err
}
children = append(children, id)
}
rows.Close()
// Build the audit message.
var sb strings.Builder
sb.WriteString("Batch abandoned. Children at abandon: ")
sb.WriteString(strings.Join(children, ", "))
if reason := strings.TrimSpace(in.Reason); reason != "" {
sb.WriteString(". Reason: ")
sb.WriteString(reason)
}
auditBody := sb.String()
// Allocate the next sequence in the parent's thread and insert the audit row.
var nextSeq int
if err := tx.QueryRow(
`SELECT COALESCE(MAX(sequence), 0) + 1 FROM topic_messages WHERE topic_id = ?`,
in.ParentTopicID,
).Scan(&nextSeq); err != nil {
_ = tx.Rollback()
return err
}
msgID := uuid.NewString()
by := in.ByUser
if _, err := tx.Exec(
`INSERT INTO topic_messages(
id, topic_id, kind, body, author_user_id,
proposal_id, sequence, created_at
) VALUES (?, ?, 'human', ?, ?, NULL, ?, ?)`,
msgID, in.ParentTopicID, auditBody, by, nextSeq, now,
); err != nil {
_ = tx.Rollback()
return err
}
// Clear parent_topic_id on every child.
if _, err := tx.Exec(
`UPDATE topics SET parent_topic_id = NULL, updated_at = ?
WHERE parent_topic_id = ?
AND incorporated_at IS NULL
AND discarded_at IS NULL`,
now, in.ParentTopicID,
); err != nil {
_ = tx.Rollback()
return err
}
// Discard the parent.
res, err := tx.Exec(
`UPDATE topics
SET discarded_at = ?, discarded_by = ?, updated_at = ?
WHERE id = ?
AND incorporated_at IS NULL
AND discarded_at IS NULL`,
now, in.ByUser, now, in.ParentTopicID,
)
if err != nil {
_ = tx.Rollback()
return err
}
n, err := res.RowsAffected()
if err != nil {
_ = tx.Rollback()
return err
}
if n != 1 {
_ = tx.Rollback()
return ErrTopicAlreadyTerminal
}
return tx.Commit()
})
return now, err
}
go test ./internal/collab/ -run 'TestCreateBatch|TestBatchParent|TestIsTopicBatched|TestDiscardBatch' -v
Expected: all pass.
go test ./internal/collab/ -v
Expected: all pass.
git add internal/collab/batch.go internal/collab/batch_test.go
git commit -m "collab: store helpers for batch creation, lookup, and abandon"
parent_topic_id to TopicSummary and surface it from list readsFiles:
internal/collab/reader.gointernal/collab/reader_test.goThe chrome sidebar needs to know which Topics are children of which batch parents. The read API returns this on the existing list endpoint.
TopicSummaryIn internal/collab/reader.go:
type TopicSummary struct {
ID string `json:"id"`
SourcePath string `json:"source_path"`
Anchor json.RawMessage `json:"anchor"`
ParentTopicID *string `json:"parent_topic_id,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
FirstMessagePreview string `json:"first_message_preview"`
MessageCount int `json:"message_count"`
}
scanTopicSummaries to include parent_topic_idIn ListOpenTopicsForSource and ListOpenTopicsForSourceExcluding, change the SELECT list to include t.parent_topic_id:
`SELECT t.id, t.source_path, t.anchor, t.parent_topic_id, t.created_by, t.created_at, t.updated_at,
COALESCE((SELECT m.body FROM topic_messages m WHERE m.topic_id = t.id ORDER BY m.sequence LIMIT 1), ''),
(SELECT COUNT(*) FROM topic_messages m WHERE m.topic_id = t.id)
FROM topics t
WHERE …`
And update scanTopicSummaries:
func scanTopicSummaries(rows *sql.Rows) ([]TopicSummary, error) {
out := []TopicSummary{}
for rows.Next() {
var raw string
var parent sql.NullString
var t TopicSummary
if err := rows.Scan(
&t.ID, &t.SourcePath, &raw, &parent, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt,
&t.FirstMessagePreview, &t.MessageCount,
); err != nil {
return nil, err
}
if _, err := UnmarshalAnchor(json.RawMessage(raw)); err != nil {
return nil, fmt.Errorf("scanTopicSummaries: topic %s: %w", t.ID, err)
}
t.Anchor = json.RawMessage(raw)
if parent.Valid {
v := parent.String
t.ParentTopicID = &v
}
out = append(out, t)
}
return out, rows.Err()
}
Append to internal/collab/reader_test.go:
func TestListOpenTopicsForSource_returnsParentTopicID(t *testing.T) {
s, user, a, b, src := newBatchTestEnv(t)
parentID := uuid.NewString()
if err := s.CreateBatch(CreateBatchInput{
ParentTopicID: parentID, SourcePath: src,
ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
rows, err := s.ListOpenTopicsForSource(src)
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 topics (parent + 2 children), got %d", len(rows))
}
byID := make(map[string]TopicSummary, len(rows))
for _, r := range rows {
byID[r.ID] = r
}
if byID[parentID].ParentTopicID != nil {
t.Errorf("parent should have nil ParentTopicID")
}
for _, child := range []string{a, b} {
got := byID[child].ParentTopicID
if got == nil || *got != parentID {
t.Errorf("child %s: ParentTopicID = %v want %s", child, got, parentID)
}
}
}
go test ./internal/collab/ -run TestListOpenTopicsForSource_returnsParentTopicID -v
go test ./internal/collab/ -v
Expected: PASS; no regressions.
git add internal/collab/reader.go internal/collab/reader_test.go
git commit -m "collab: expose parent_topic_id on TopicSummary"
ClaimIncorporation and freshness exclude batched + batch parentsFiles:
internal/collab/mutators.go (ClaimIncorporation query)internal/collab/freshness.go (openNonGlobalTopicIDsExcluding query)internal/collab/freshness_test.go (extend) and internal/collab/mutators_test.go (extend)Both functions currently scan "open non-global Topics" to enforce marker presence. They must additionally exclude (a) Topics already batched as children of an open parent, and (b) Topics that are themselves batch parents. The first set is "being incorporated together"; the second has no marker by definition.
Append to internal/collab/freshness_test.go:
func TestProposalFreshness_excludesBatchedChildrenAndBatchParents(t *testing.T) {
root := t.TempDir()
src := "doc.md"
if err := os.WriteFile(filepath.Join(root, src), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
repoSHA := mustGitInit(t, root) // helper used elsewhere in this package; see existing tests
_ = repoSHA
s, _ := newTestStore(t) // existing helper
if err := s.UpsertUser(User{ID: "u", DisplayName: "U"}); err != nil {
t.Fatal(err)
}
// Topic A: open, anchored, has a marker in the proposed source.
a := uuid.NewString()
preMarkerAnchor, _ := MarshalAnchor(PreMarkerAnchor{
SourceSHA: strings.Repeat("0", 40), Start: 0, End: 5, Quote: "hello",
})
if err := s.InsertTopicWithFirstMessage(NewTopicWithFirstMessage{
TopicID: a, SourcePath: src, Anchor: preMarkerAnchor, CreatedBy: "u",
FirstMessageID: uuid.NewString(), FirstMessageBody: "x",
}); err != nil {
t.Fatal(err)
}
// Topic P (the parent of a different batch) — has no marker in the proposed
// source. With the change in this task, it must NOT cause the freshness check
// to fail.
p := uuid.NewString()
batchAnchor, _ := MarshalAnchor(BatchAnchor{})
if err := s.InsertTopicWithFirstMessage(NewTopicWithFirstMessage{
TopicID: p, SourcePath: src, Anchor: batchAnchor, CreatedBy: "u",
FirstMessageID: uuid.NewString(), FirstMessageBody: "y",
}); err != nil {
t.Fatal(err)
}
// Topic C — a batched child of P, with no marker in the proposed source.
// Must also not cause freshness to fail.
c := uuid.NewString()
if err := s.InsertTopicWithFirstMessage(NewTopicWithFirstMessage{
TopicID: c, SourcePath: src, Anchor: preMarkerAnchor, CreatedBy: "u",
FirstMessageID: uuid.NewString(), FirstMessageBody: "z",
}); err != nil {
t.Fatal(err)
}
if _, err := s.RawDB().Exec(
`UPDATE topics SET parent_topic_id = ? WHERE id = ?`, p, c,
); err != nil {
t.Fatal(err)
}
// Proposal on Topic A whose proposed_source carries A's marker — no markers
// for P or C.
proposalID := uuid.NewString()
if _, err := s.InsertProposal(NewProposal{
ID: proposalID, TopicID: a, RevisionNumber: 1,
ProposedSource: `<span data-orcha-anchor="` + a + `">hello</span>`,
BaseSourceSHA: mustHashFile(t, root, src),
}); err != nil {
t.Fatal(err)
}
fr, err := ProposalFreshness(s, root, proposalID)
if err != nil {
t.Fatal(err)
}
if !fr.Fresh {
t.Fatalf("expected fresh; got stale_reasons=%v missing=%v", fr.StaleReasons, fr.MissingTopicIDs)
}
}
If mustGitInit / mustHashFile / newTestStore helpers don't already exist in the package, port the ones used by existing tests in freshness_test.go — do not reinvent.
go test ./internal/collab/ -run TestProposalFreshness_excludesBatchedChildrenAndBatchParents -v
Expected: FAIL — freshness sees C as an open non-global Topic missing its marker.
openNonGlobalTopicIDsExcluding in freshness.goChange the SQL filter to add two new exclusions:
`SELECT id FROM topics
WHERE source_path = ?
AND incorporated_at IS NULL
AND discarded_at IS NULL
AND id != ?
AND json_extract(anchor, '$.kind') != 'global'
AND json_extract(anchor, '$.kind') != 'batch'
AND parent_topic_id IS NULL
ORDER BY id`
ClaimIncorporation in mutators.gorows, err := tx.Query(
`SELECT id, json_extract(anchor, '$.kind')
FROM topics
WHERE source_path = ?
AND incorporated_at IS NULL
AND discarded_at IS NULL
AND id != ?
AND json_extract(anchor, '$.kind') != 'global'
AND json_extract(anchor, '$.kind') != 'batch'
AND parent_topic_id IS NULL
ORDER BY id`,
in.Attempt.SourcePath, in.Attempt.TopicID,
)
go test ./internal/collab/ -run TestProposalFreshness_excludesBatchedChildrenAndBatchParents -v
go test ./internal/collab/ -v
Expected: PASS.
git add internal/collab/freshness.go internal/collab/freshness_test.go internal/collab/mutators.go
git commit -m "collab: exclude batched children and batch parents from marker-set checks"
CompleteIncorporation to transition the parent + every child atomicallyFiles:
internal/collab/mutators.go (CompleteIncorporationInput, CompleteIncorporation)internal/collab/mutators_test.goWhen the proposal being incorporated is anchored on a batch parent, the same UPDATE that sets the parent's outcome columns must also flip every child's outcome columns under the same commit_sha / incorporated_proposal_id / etc.
ChildTopicIDs to CompleteIncorporationInputtype CompleteIncorporationInput struct {
TopicID string
ProposalID string
AttemptID string
CommitSHA string
IncorporatedBy string
IncorporatedAt int64
ReanchorTopicIDs []string
ChildTopicIDs []string // non-empty when TopicID is a batch parent
}
CompleteIncorporation to update the children inside the same transactionAfter the existing UPDATE topics SET commit_sha = … for the (parent or lone) TopicID and before the UPDATE incorporation_attempts SET committed_sha = …, add:
if len(in.ChildTopicIDs) > 0 {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(in.ChildTopicIDs)), ",")
args := []any{
in.CommitSHA, in.ProposalID, in.IncorporatedBy, in.IncorporatedAt,
}
for _, id := range in.ChildTopicIDs {
args = append(args, id)
}
args = append(args, in.TopicID)
query := `UPDATE topics
SET commit_sha = ?,
incorporated_proposal_id = ?,
incorporated_by = ?,
incorporated_at = ?,
updated_at = unixepoch()
WHERE id IN (` + placeholders + `)
AND parent_topic_id = ?
AND incorporated_at IS NULL
AND discarded_at IS NULL`
res, err := tx.Exec(query, args...)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("update children: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
_ = tx.Rollback()
return err
}
if int(n) != len(in.ChildTopicIDs) {
_ = tx.Rollback()
return fmt.Errorf("update children: affected %d rows, want %d", n, len(in.ChildTopicIDs))
}
}
In internal/collab/mutators_test.go, add:
func TestCompleteIncorporation_transitionsBatchAtomically(t *testing.T) {
s, user, a, b, src := newBatchTestEnv(t)
parentID := uuid.NewString()
if err := s.CreateBatch(CreateBatchInput{
ParentTopicID: parentID, SourcePath: src,
ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
proposalID := uuid.NewString()
if _, err := s.InsertProposal(NewProposal{
ID: proposalID, TopicID: parentID, RevisionNumber: 1,
ProposedSource: "rewritten", BaseSourceSHA: strings.Repeat("0", 40),
}); err != nil {
t.Fatal(err)
}
attemptID := uuid.NewString()
if _, err := s.InsertAttempt(NewAttempt{
ID: attemptID, ProposalID: proposalID, TopicID: parentID,
SourcePath: src, BaseSourceSHA: strings.Repeat("0", 40),
ApprovedBy: user,
}); err != nil {
t.Fatal(err)
}
if err := s.CompleteIncorporation(CompleteIncorporationInput{
TopicID: parentID, ProposalID: proposalID, AttemptID: attemptID,
CommitSHA: "deadbeef", IncorporatedBy: user, IncorporatedAt: 1700000000,
ChildTopicIDs: []string{a, b},
}); err != nil {
t.Fatal(err)
}
for _, id := range []string{parentID, a, b} {
var sha sql.NullString
if err := s.RawDB().QueryRow(
`SELECT commit_sha FROM topics WHERE id = ?`, id,
).Scan(&sha); err != nil {
t.Fatal(err)
}
if !sha.Valid || sha.String != "deadbeef" {
t.Errorf("topic %s commit_sha: %v", id, sha)
}
}
}
go test ./internal/collab/ -run TestCompleteIncorporation_transitionsBatchAtomically -v
go test ./internal/collab/ -v
Expected: PASS.
git add internal/collab/mutators.go internal/collab/mutators_test.go
git commit -m "collab: CompleteIncorporation transitions batch parent + every child atomically"
Incorporate + commit trailersFiles:
internal/collab/incorporate.go (IncorporateInput, Incorporate)internal/collab/gitops.go (CommitInput.TopicID → CommitInput.TopicIDs slice; trailer writer emits N lines; parser collects N IDs)internal/collab/recover.go (handle multi-topic commits by transitioning each Topic in the trailer list)internal/collab/incorporate_test.go, internal/collab/gitops_test.go, internal/collab/recover_test.goThe caller (the Approve handler in Task 12; the recovery code in this task) passes ChildTopicIDs into IncorporateInput. Incorporate does NOT independently look children up — the caller already knows them (the handler loads them to fan out realtime events; recovery loads them via the commit trailer list). Incorporate plumbs the child set through to CompleteIncorporation and to CommitSourceRewrite (which emits one Topic: trailer per transitioned Topic). Keep the existing Topic: trailer name (singular, no suffix) — that's the convention already on every commit in the repo, and the recovery parser at gitops.go:120 keys on it; switching to Topic-Id: would break recovery on every pre-existing commit. The parser is updated to collect a list rather than overwriting a single field.
CommitInput.TopicID string with CommitInput.TopicIDs []string in gitops.goIn internal/collab/gitops.go:
type CommitInput struct {
RepoRoot string
RelPath string
NewContent []byte
Subject string
Body string
ApprovedBy string
TopicIDs []string // ≥1; commit emits one Topic: trailer per element
ProposalRev int
AuthorName string
AuthorEmail string
}
Where the function emits the trailer block, replace the singular Topic: line with a loop over in.TopicIDs. The existing writer uses fmt.Fprintf(&msg, "Topic: %s\n", in.TopicID); change to:
for _, id := range in.TopicIDs {
fmt.Fprintf(&msg, "Topic: %s\n", id)
}
Keep Approved-by: and Proposal: lines unchanged. Validate len(in.TopicIDs) >= 1 in the existing if in.TopicID == "" … guard:
if in.RepoRoot == "" || in.RelPath == "" || len(in.TopicIDs) == 0 || in.Subject == "" {
return "", fmt.Errorf("collab: CommitInput is missing required fields")
}
incorporate.gosha, err := CommitSourceRewrite(CommitInput{
RepoRoot: in.RepoRoot,
RelPath: sourcePath,
NewContent: []byte(prop.ProposedSource),
Subject: in.Subject,
Body: in.Body,
ApprovedBy: fmt.Sprintf("%s <%s>", in.ApproverName, in.ApproverID),
TopicIDs: append([]string{prop.TopicID}, in.ChildTopicIDs...),
ProposalRev: prop.RevisionNumber,
AuthorName: in.AuthorName,
AuthorEmail: in.AuthorEmail,
})
Add ChildTopicIDs []string to IncorporateInput:
type IncorporateInput struct {
RepoRoot string
ProposalID string
ApproverID string
ApproverName string
Subject string
Body string
AuthorName string
AuthorEmail string
ReanchorTopicIDs []string
ChildTopicIDs []string // non-empty when ProposalID is anchored on a batch parent
}
And pass it into CompleteIncorporation:
if err := s.CompleteIncorporation(CompleteIncorporationInput{
TopicID: prop.TopicID,
ProposalID: prop.ID,
AttemptID: attemptID,
CommitSHA: sha,
IncorporatedBy: in.ApproverID,
IncorporatedAt: time.Now().Unix(),
ReanchorTopicIDs: reanchorTopicIDs,
ChildTopicIDs: in.ChildTopicIDs,
}); err != nil { … }
gitops.goListIncorporationCommits currently writes a single TopicID per commit (the parser overwrites c.TopicID if multiple Topic: lines appear). Change CommitTrailers and the parser to collect a list:
type CommitTrailers struct {
SHA string
TopicIDs []string
ProposalRev int
ApprovedBy string
AuthorTime int64
}
Inside the loop where each trailer line is read, replace the Topic: branch with:
case strings.HasPrefix(ln, "Topic: "):
c.TopicIDs = append(c.TopicIDs, strings.TrimPrefix(ln, "Topic: "))
And change the if c.TopicID != "" keep-commit guard at the end of the loop body to if len(c.TopicIDs) > 0.
recover.go to handle multi-topic commits as a single batchThis is the load-bearing change. A batched commit has multiple Topic: trailers; all of them are tied to ONE proposal (the one anchored on the batch parent). Naïvely iterating trailers and treating each as its own proposal would fail the proposal lookup for every child (children don't own proposal rows) and would never call CompleteIncorporation with ChildTopicIDs. Children would stay un-reconciled.
Replace recoverFromCommits's outer loop with batch-aware logic:
for _, c := range commits {
// For N=1 commits, c.TopicIDs has exactly one entry which IS the proposal's
// owner. For batches, exactly one trailer is the batch parent (its anchor.kind
// = "batch") and the rest are children. Identify the parent by querying anchor
// kind; fall back to "the only id" when N=1.
var parentID string
var childIDs []string
if len(c.TopicIDs) == 1 {
parentID = c.TopicIDs[0]
} else {
for _, id := range c.TopicIDs {
isBatch, err := s.IsBatchParent(id)
if err != nil || !isBatch {
continue
}
parentID = id
}
if parentID == "" {
// Malformed: multiple Topic: trailers but no batch parent in the DB.
// Possible after a manual DB edit; skip and surface in logs.
slog.Warn("recover: commit has multi-trailer but no batch parent in DB",
"sha", c.SHA, "topic_ids", c.TopicIDs)
continue
}
for _, id := range c.TopicIDs {
if id != parentID {
childIDs = append(childIDs, id)
}
}
}
// Skip if the parent's already reconciled (children will be reconciled in
// the same CompleteIncorporation call, so checking the parent is enough).
got, err := s.GetTopicCommitSHA(parentID)
if err != nil {
continue
}
if got.Valid {
continue
}
// Lookup the proposal by (parent_id, revision_number). For N=1 this is the
// lone Topic's proposal; for batches this is the parent's proposal.
var propID, baseSourceSHA string
err = s.db.QueryRow(
`SELECT id, base_source_sha FROM incorporation_proposals
WHERE topic_id = ? AND revision_number = ?`,
parentID, c.ProposalRev,
).Scan(&propID, &baseSourceSHA)
if err != nil {
continue
}
// (existing attempt-lookup-or-synthesize block, swapping c.TopicID for parentID)
var attemptID string
err = s.db.QueryRow(
`SELECT id FROM incorporation_attempts WHERE proposal_id = ?`, propID,
).Scan(&attemptID)
if err != nil {
attemptID, err = randomID()
if err != nil {
return fmt.Errorf("allocate synthetic attempt id: %w", err)
}
if _, err := s.InsertAttempt(NewAttempt{
ID: attemptID, ProposalID: propID, TopicID: parentID,
SourcePath: pathFromTopic(s, parentID),
BaseSourceSHA: baseSourceSHA,
ApprovedBy: extractUserID(c.ApprovedBy),
}); err != nil {
return fmt.Errorf("synthesize attempt: %w", err)
}
}
// One CompleteIncorporation call transitions the parent + every child.
if err := s.CompleteIncorporation(CompleteIncorporationInput{
TopicID: parentID,
ChildTopicIDs: childIDs,
ProposalID: propID,
AttemptID: attemptID,
CommitSHA: c.SHA,
IncorporatedBy: extractUserID(c.ApprovedBy),
IncorporatedAt: c.AuthorTime,
}); err != nil {
return fmt.Errorf("complete incorporation for %s: %w", parentID, err)
}
}
For recoverFromAttempts: the attempt row carries one topic_id (the parent for batches, the lone Topic for N=1). When replaying, also resolve children:
// In recoverFromAttempts, when calling CommitSourceRewrite + CompleteIncorporation:
childIDs, _ := s.BatchChildrenOfParent(a.TopicID)
isBatch, _ := s.IsBatchParent(a.TopicID)
trailerIDs := []string{a.TopicID}
if isBatch {
trailerIDs = append(trailerIDs, childIDs...)
}
sha, err := CommitSourceRewrite(CommitInput{
RepoRoot: repoRoot, RelPath: cleanPath,
NewContent: []byte(prop.ProposedSource),
Subject: fmt.Sprintf("Incorporate Topic %s (recovered)", a.TopicID),
ApprovedBy: a.ApprovedBy, TopicIDs: trailerIDs,
ProposalRev: prop.RevisionNumber,
AuthorName: "Orcha Agent", AuthorEmail: "agent@orcha.local",
})
// ...
if err := s.CompleteIncorporation(CompleteIncorporationInput{
TopicID: a.TopicID, ChildTopicIDs: childIDs, // children empty for N=1
ProposalID: a.ProposalID, AttemptID: a.ID,
CommitSHA: sha, IncorporatedBy: extractUserID(a.ApprovedBy),
IncorporatedAt: time.Now().Unix(),
}); err != nil { ... }
Apply the same pattern to every CommitSourceRewrite and CompleteIncorporation call inside the attempt-replay branches — there are two (Case 2a "file write never happened" and Case 2c "file write committed but topic update missed", roughly — match the actual branch layout in the existing recover.go).
gitops_test.go has fixtures that assert the trailer block — update them to use TopicIDs: []string{"…"} and assert one Topic: <id> line per ID. recover_test.go has tests that read c.TopicID — switch to c.TopicIDs[0] for the single-topic path; add a new test for the multi-topic recovery (commit with 3 Topic: trailers, all three topics get reconciled to the same commit).
Append to internal/collab/gitops_test.go:
func TestCommitSourceRewrite_emitsOneTopicTrailerPerTopic(t *testing.T) {
repo, file := setupCommitTest(t) // existing helper
sha, err := CommitSourceRewrite(CommitInput{
RepoRoot: repo, RelPath: file, NewContent: []byte("hi"),
Subject: "x", Body: "y", ApprovedBy: "Daniel <daniel>",
TopicIDs: []string{"t1", "t2", "t3"}, ProposalRev: 1,
AuthorName: "A", AuthorEmail: "a@b",
})
if err != nil {
t.Fatal(err)
}
out, err := exec.Command("git", "-C", repo, "log", "-1", "--format=%B", sha).Output()
if err != nil {
t.Fatal(err)
}
body := string(out)
for _, id := range []string{"t1", "t2", "t3"} {
if !strings.Contains(body, "Topic: "+id) {
t.Errorf("missing trailer for %s; commit body:\n%s", id, body)
}
}
}
go test ./internal/collab/ -run 'TestCommitSourceRewrite|TestIncorporate|TestRecover' -v
go test ./internal/collab/ -v
Expected: PASS.
git add internal/collab/incorporate.go internal/collab/gitops.go internal/collab/recover.go internal/collab/incorporate_test.go internal/collab/gitops_test.go internal/collab/recover_test.go
git commit -m "collab: emit + parse one Topic: trailer per transitioned Topic; recovery handles multi-topic commits"
wb-agent get-topic returns children for batch parentsFiles:
cmd/wb-agent/get_topic.gocmd/wb-agent/main_test.go (extend)When the job's Topic has anchor.kind = "batch", the response carries an additional children: [{id, anchor, created_by, created_at, messages}] array, one entry per child. Each child entry mirrors the parent's top-level shape (which uses id, not topic_id) so the skill can read both with the same destructuring.
Append to cmd/wb-agent/main_test.go:
func TestGetTopic_batchParent_returnsChildren(t *testing.T) {
tmp := t.TempDir()
cfgPath, root, store := setupAgentEnv(t, tmp) // existing helper used by main_test.go
_ = root
defer store.Close()
user := "daniel@getorcha.com"
src := "doc.md"
if err := os.WriteFile(filepath.Join(root, src), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
mustGitCommit(t, root, "init") // existing helper
a := uuid.NewString()
b := uuid.NewString()
anchor, _ := collab.MarshalAnchor(collab.GlobalAnchor{})
for _, id := range []string{a, b} {
if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: id, SourcePath: src, Anchor: anchor, CreatedBy: user,
FirstMessageID: uuid.NewString(), FirstMessageBody: "x",
}); err != nil {
t.Fatal(err)
}
}
parent := uuid.NewString()
if err := store.CreateBatch(collab.CreateBatchInput{
ParentTopicID: parent, SourcePath: src, ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
// Job pointing at the parent.
jobID := uuid.NewString()
if err := store.InsertJob(collab.NewJob{
ID: jobID, Kind: "incorporate", SourcePath: src, TopicID: &parent,
}); err != nil {
t.Fatal(err)
}
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
code := runGetTopic(
[]string{"--config=" + cfgPath, "--job-id=" + jobID},
stdout, stderr, openStoreReal,
)
if code != 0 {
t.Fatalf("exit %d; stderr=%s", code, stderr.String())
}
var payload struct {
ID string `json:"id"`
Anchor struct{ Kind string } `json:"anchor"`
Children []struct{ ID string `json:"id"` } `json:"children"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.Anchor.Kind != "batch" {
t.Errorf("anchor kind: %s", payload.Anchor.Kind)
}
if len(payload.Children) != 2 {
t.Fatalf("children: got %d want 2", len(payload.Children))
}
}
go test ./cmd/wb-agent/ -run TestGetTopic_batchParent -v
Expected: FAIL (children field missing).
cmd/wb-agent/get_topic.goAdd the new struct field at the top of the file:
type getTopicOutput struct {
ID string `json:"id"`
SourcePath string `json:"source_path"`
BaseSourceSHA string `json:"base_source_sha"`
Anchor json.RawMessage `json:"anchor"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
Messages []getTopicMessage `json:"messages"`
Children []getTopicChild `json:"children,omitempty"`
}
type getTopicChild struct {
ID string `json:"id"`
Anchor json.RawMessage `json:"anchor"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
Messages []getTopicMessage `json:"messages"`
}
After the existing block that fills out.Messages, add:
// Batch parents nest their children inline so the skill can read parent +
// children in one call.
parsed, err := collab.UnmarshalAnchor(out.Anchor)
if err != nil {
fmt.Fprintf(stderr, "decode anchor: %v\n", err)
return 1
}
if _, ok := parsed.(collab.BatchAnchor); ok {
childIDs, err := store.BatchChildrenOfParent(out.ID)
if err != nil {
fmt.Fprintf(stderr, "load children: %v\n", err)
return 1
}
out.Children = make([]getTopicChild, 0, len(childIDs))
for _, cid := range childIDs {
var c getTopicChild
var rawAnchor string
if err := store.RawDB().QueryRow(
`SELECT id, anchor, created_by, created_at FROM topics WHERE id = ?`, cid,
).Scan(&c.ID, &rawAnchor, &c.CreatedBy, &c.CreatedAt); err != nil {
fmt.Fprintf(stderr, "load child %s: %v\n", cid, err)
return 1
}
c.Anchor = json.RawMessage(rawAnchor)
childMsgs, err := store.ListMessages(cid)
if err != nil {
fmt.Fprintf(stderr, "list child messages: %v\n", err)
return 1
}
c.Messages = make([]getTopicMessage, 0, len(childMsgs))
for _, m := range childMsgs {
c.Messages = append(c.Messages, getTopicMessage{
ID: m.ID, TopicID: m.TopicID, Kind: m.Kind, Body: m.Body,
AuthorUserID: m.AuthorUserID, ProposalID: m.ProposalID,
Sequence: m.Sequence, CreatedAt: m.CreatedAt,
})
}
out.Children = append(out.Children, c)
}
}
go test ./cmd/wb-agent/ -run TestGetTopic_batchParent -v
go test ./cmd/wb-agent/ -v
Expected: PASS.
git add cmd/wb-agent/get_topic.go cmd/wb-agent/main_test.go
git commit -m "wb-agent: get-topic nests children for batch parents"
wb-agent list-open-topics accepts --exclude-topics (plural)Files:
cmd/wb-agent/list_open_topics.gocmd/wb-agent/main_test.gointernal/collab/reader.go (add ListOpenTopicsForSourceExcludingSet)The plural flag accepts a comma-separated list. The singular --exclude-topic is removed (the skill is the only caller and is updated in tandem).
Append to cmd/wb-agent/main_test.go:
func TestListOpenTopics_excludeTopicsPlural(t *testing.T) {
tmp := t.TempDir()
cfgPath, root, store := setupAgentEnv(t, tmp)
defer store.Close()
user := "daniel@getorcha.com"
src := "doc.md"
mustGitCommit(t, root, "init")
// MarkerAnchor — must be non-global / non-batch / non-batched so the query
// returns all three before the exclude set applies.
anchor, _ := collab.MarshalAnchor(collab.MarkerAnchor{})
a := uuid.NewString()
b := uuid.NewString()
c := uuid.NewString()
for _, id := range []string{a, b, c} {
if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: id, SourcePath: src, Anchor: anchor, CreatedBy: user,
FirstMessageID: uuid.NewString(), FirstMessageBody: "x",
}); err != nil {
t.Fatal(err)
}
}
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
abs := filepath.Join(root, src)
code := runListOpenTopics(
[]string{"--config=" + cfgPath, "--source-path=" + abs, "--exclude-topics=" + a + "," + b},
stdout, stderr, openStoreReal,
)
if code != 0 {
t.Fatalf("exit %d; stderr=%s", code, stderr.String())
}
var topics []collab.TopicSummary
if err := json.Unmarshal(stdout.Bytes(), &topics); err != nil {
t.Fatalf("decode: %v", err)
}
if len(topics) != 1 || topics[0].ID != c {
t.Fatalf("expected only %s, got %d topics: %+v", c, len(topics), topics)
}
}
go test ./cmd/wb-agent/ -run TestListOpenTopics_excludeTopicsPlural -v
Expected: FAIL — flag not recognised.
ListOpenTopicsForSourceExcludingSet in internal/collab/reader.go// ListOpenTopicsForSourceExcludingSet lists open non-global Topics on
// sourcePath whose id is not in the exclude set. Used by wb-agent's
// list-open-topics when a batch needs to exclude the parent + every child.
func (s *Store) ListOpenTopicsForSourceExcludingSet(sourcePath string, exclude []string) ([]TopicSummary, error) {
if _, err := ValidateSourcePath(sourcePath); err != nil {
return nil, fmt.Errorf("collab.ListOpenTopicsForSourceExcludingSet: %w", err)
}
args := []any{sourcePath}
placeholders := ""
if len(exclude) > 0 {
placeholders = " AND t.id NOT IN (" + strings.TrimSuffix(strings.Repeat("?,", len(exclude)), ",") + ")"
for _, id := range exclude {
args = append(args, id)
}
}
rows, err := s.db.Query(
`SELECT t.id, t.source_path, t.anchor, t.parent_topic_id, t.created_by, t.created_at, t.updated_at,
COALESCE((SELECT m.body FROM topic_messages m WHERE m.topic_id = t.id ORDER BY m.sequence LIMIT 1), ''),
(SELECT COUNT(*) FROM topic_messages m WHERE m.topic_id = t.id)
FROM topics t
WHERE t.source_path = ?
AND t.incorporated_at IS NULL
AND t.discarded_at IS NULL
AND json_extract(t.anchor, '$.kind') != 'global'
AND json_extract(t.anchor, '$.kind') != 'batch'
AND t.parent_topic_id IS NULL`+placeholders+`
ORDER BY t.updated_at DESC, t.created_at DESC, t.id`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTopicSummaries(rows)
}
strings import will be needed.
cmd/wb-agent/list_open_topics.go to use the plural flagfunc runListOpenTopics(args []string, stdout, stderr io.Writer, opener storeOpener) int {
fs := flag.NewFlagSet("list-open-topics", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "wiki-browser.yaml", "path to config file")
sourcePath := fs.String("source-path", "", "absolute path to the source file")
excludeTopics := fs.String("exclude-topics", "", "comma-separated topic ids to exclude")
if err := fs.Parse(args); err != nil {
return 2
}
if *sourcePath == "" {
fmt.Fprintln(stderr, "list-open-topics: --source-path is required")
return 2
}
store, cfg, err := opener(*configPath)
if err != nil {
fmt.Fprintf(stderr, "open store: %v\n", err)
return 1
}
defer store.Close()
abs := filepath.Clean(*sourcePath)
rel, err := filepath.Rel(cfg.Root, abs)
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
fmt.Fprintf(stderr, "list-open-topics: source path escapes repo root: %q\n", abs)
return 1
}
if _, err := collab.ValidateSourcePath(rel); err != nil {
fmt.Fprintf(stderr, "list-open-topics: invalid source path %q: %v\n", rel, err)
return 1
}
var ids []string
if s := strings.TrimSpace(*excludeTopics); s != "" {
for _, p := range strings.Split(s, ",") {
if t := strings.TrimSpace(p); t != "" {
ids = append(ids, t)
}
}
}
topics, err := store.ListOpenTopicsForSourceExcludingSet(rel, ids)
if err != nil {
fmt.Fprintf(stderr, "list-open-topics: %v\n", err)
return 1
}
if err := json.NewEncoder(stdout).Encode(topics); err != nil {
fmt.Fprintf(stderr, "encode: %v\n", err)
return 1
}
return 0
}
go test ./cmd/wb-agent/ -run TestListOpenTopics_excludeTopicsPlural -v
go test ./cmd/wb-agent/ -v
go test ./internal/collab/ -v
Expected: PASS.
git add cmd/wb-agent/list_open_topics.go cmd/wb-agent/main_test.go internal/collab/reader.go
git commit -m "wb-agent: list-open-topics accepts --exclude-topics (plural csv)"
Files:
internal/agent/service.go (checkIncorporateInvariants)internal/agent/service_test.goThe post-job invariant currently checks "the incorporated Topic's marker is absent from proposed_source." For batches, the parent has no marker, so that check passes vacuously — but every child must also have its marker absent. The "other open topics" list must also exclude every Topic in the batch's set.
Append to internal/agent/service_test.go:
func TestCheckIncorporateInvariants_batch_rejectsLeakedChildMarker(t *testing.T) {
env := startServiceWithFakeProposer(t) // existing helper
defer env.Cleanup()
user := env.UserID
src := "doc.md"
a := uuid.NewString()
b := uuid.NewString()
// Two child topics with marker anchors so the batch invariant has a
// concrete leak target.
markerAnchor, _ := collab.MarshalAnchor(collab.MarkerAnchor{})
for _, id := range []string{a, b} {
if err := env.Store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: id, SourcePath: src, Anchor: markerAnchor, CreatedBy: user,
FirstMessageID: uuid.NewString(), FirstMessageBody: "x",
}); err != nil {
t.Fatal(err)
}
}
parent := uuid.NewString()
if err := env.Store.CreateBatch(collab.CreateBatchInput{
ParentTopicID: parent, SourcePath: src,
ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
// Simulate the agent producing a proposal that LEAKS child a's marker.
leakedSource := `<span data-orcha-anchor="` + a + `">hi</span> rewritten`
job, prop := env.SeedJobAndAgentProposal(parent, src, leakedSource, "explained")
_ = prop
status, tail := env.Service.CheckIncorporateInvariantsForTest(agent.SubmitInput{
Kind: "incorporate", SourcePath: src, TopicID: parent,
}, job.ID)
if status != "failed" {
t.Fatalf("expected status=failed, got %q (tail=%s)", status, tail)
}
if !strings.Contains(tail, a) {
t.Errorf("expected tail to mention leaked child %s; got %q", a, tail)
}
}
func TestCheckIncorporateInvariants_batch_succeedsWhenAllChildMarkersDropped(t *testing.T) {
env := startServiceWithFakeProposer(t)
defer env.Cleanup()
user := env.UserID
src := "doc.md"
a := uuid.NewString()
b := uuid.NewString()
markerAnchor, _ := collab.MarshalAnchor(collab.MarkerAnchor{})
for _, id := range []string{a, b} {
if err := env.Store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: id, SourcePath: src, Anchor: markerAnchor, CreatedBy: user,
FirstMessageID: uuid.NewString(), FirstMessageBody: "x",
}); err != nil {
t.Fatal(err)
}
}
parent := uuid.NewString()
if err := env.Store.CreateBatch(collab.CreateBatchInput{
ParentTopicID: parent, SourcePath: src,
ChildTopicIDs: []string{a, b}, CreatedBy: user,
}); err != nil {
t.Fatal(err)
}
cleanSource := "rewritten with no markers"
job, _ := env.SeedJobAndAgentProposal(parent, src, cleanSource, "explained")
status, tail := env.Service.CheckIncorporateInvariantsForTest(agent.SubmitInput{
Kind: "incorporate", SourcePath: src, TopicID: parent,
}, job.ID)
if status != "" {
t.Fatalf("expected empty status (success), got %q (tail=%s)", status, tail)
}
}
If startServiceWithFakeProposer doesn't already expose Store, UserID, SeedJobAndAgentProposal, or a CheckIncorporateInvariantsForTest hook on Service, extend it. The simplest hook: export the existing internal function as func (s *Service) CheckIncorporateInvariantsForTest(in SubmitInput, jobID string) (string, string) { return s.checkIncorporateInvariants(in, jobID) } in service_test.go itself (same package).
go test ./internal/agent/ -run TestCheckIncorporateInvariants_batch -v
Expected: FAIL on the leak test (the current code only checks the proposal's TopicID, not children).
checkIncorporateInvariantsIn internal/agent/service.go, change the marker-checking section to handle the batch case:
// Determine the batch's set: just [prop.TopicID] for N=1, or
// [parent, child1, child2, …] when the proposal is anchored on a batch parent.
batchSet := []string{prop.TopicID}
isBatchParent, err := s.cfg.Store.IsBatchParent(prop.TopicID)
if err != nil {
return "failed", "load batch parent flag: " + err.Error()
}
if isBatchParent {
children, err := s.cfg.Store.BatchChildrenOfParent(prop.TopicID)
if err != nil {
return "failed", "load children: " + err.Error()
}
batchSet = append(batchSet, children...)
}
// Every other open Topic on the Source (excluding the batch's set) must
// have its marker present in proposed_source.
others, err := s.cfg.Store.ListOpenTopicsForSourceExcludingSet(in.SourcePath, batchSet)
if err != nil {
return "failed", "list other open topics: " + err.Error()
}
for _, t := range others {
marker := `data-orcha-anchor="` + t.ID + `"`
if !strings.Contains(prop.ProposedSource, marker) {
return "failed", "anchor invariant: topic " + t.ID + " not stamped in proposal"
}
}
// Every Topic in the batch's set must have its marker absent. For N=1 this
// is the single Topic; for N≥2 this checks all children (the parent never
// had a marker, so it satisfies vacuously, but listing it in the loop is
// harmless and keeps the code uniform).
for _, id := range batchSet {
leaked := `data-orcha-anchor="` + id + `"`
if strings.Contains(prop.ProposedSource, leaked) {
return "failed", "anchor invariant: marker leaked into proposal: " + id
}
}
go test ./internal/agent/ -run TestCheckIncorporateInvariants -v
go test ./internal/agent/ -v
Expected: PASS.
git add internal/agent/service.go internal/agent/service_test.go
git commit -m "agent: post-job invariant generalises to N markers for batches"
POST /api/batches handlerFiles:
internal/server/handler_batches.gointernal/server/handler_batches_test.gointernal/server/server.go to register the route.The handler validates the request, calls collab.CreateBatch, enqueues a wb-incorporate job for the parent, publishes topic.created for the parent (children's updates are surfaced by the chrome's refetch on the parent's event), and returns {parent_topic_id, job_id}.
Create internal/server/handler_batches_test.go with table-driven cases:
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/getorcha/wiki-browser/internal/collab"
)
func TestPostBatches_createsParentAndQueuesJob(t *testing.T) {
env := newServerTestEnv(t) // existing helper that returns Deps with collab + agent service stubs
defer env.Cleanup()
a := uuid.NewString()
b := uuid.NewString()
src := "doc.md"
env.SeedOpenTopic(a, src) // helper that inserts a topic with marker anchor
env.SeedOpenTopic(b, src)
body, _ := json.Marshal(map[string]any{
"child_topic_ids": []string{a, b},
})
r := httptest.NewRequest("POST", "/api/batches", bytes.NewReader(body))
r = r.WithContext(env.PrincipalCtx(r.Context()))
w := httptest.NewRecorder()
env.Mux().ServeHTTP(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
ParentTopicID string `json:"parent_topic_id"`
JobID string `json:"job_id"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.ParentTopicID == "" || resp.JobID == "" {
t.Fatalf("response missing ids: %+v", resp)
}
// Verify children attached.
got, _ := env.Collab().BatchParentForTopic(a)
if got != resp.ParentTopicID {
t.Errorf("child a parent: got %s want %s", got, resp.ParentTopicID)
}
}
func TestPostBatches_rejectsBelowMinimum(t *testing.T) {
env := newServerTestEnv(t)
defer env.Cleanup()
a := uuid.NewString()
env.SeedOpenTopic(a, "doc.md")
body, _ := json.Marshal(map[string]any{"child_topic_ids": []string{a}})
r := httptest.NewRequest("POST", "/api/batches", bytes.NewReader(body))
r = r.WithContext(env.PrincipalCtx(r.Context()))
w := httptest.NewRecorder()
env.Mux().ServeHTTP(w, r)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d: %s", w.Code, w.Body.String())
}
}
func TestPostBatches_rejectsCrossSource(t *testing.T) {
env := newServerTestEnv(t)
defer env.Cleanup()
a, b := uuid.NewString(), uuid.NewString()
env.SeedOpenTopic(a, "doc.md")
env.SeedOpenTopic(b, "other.md")
body, _ := json.Marshal(map[string]any{"child_topic_ids": []string{a, b}})
r := httptest.NewRequest("POST", "/api/batches", bytes.NewReader(body))
r = r.WithContext(env.PrincipalCtx(r.Context()))
w := httptest.NewRecorder()
env.Mux().ServeHTTP(w, r)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d: %s", w.Code, w.Body.String())
}
var resp struct{ Code string }
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Code != "cross_source" {
t.Errorf("expected code=cross_source, got %s", resp.Code)
}
}
func TestPostBatches_rejectsAlreadyBatched(t *testing.T) {
env := newServerTestEnv(t)
defer env.Cleanup()
a, b, c := uuid.NewString(), uuid.NewString(), uuid.NewString()
env.SeedOpenTopic(a, "doc.md")
env.SeedOpenTopic(b, "doc.md")
env.SeedOpenTopic(c, "doc.md")
// Create one batch with (a, b).
parent := uuid.NewString()
if err := env.Collab().CreateBatch(collab.CreateBatchInput{
ParentTopicID: parent, SourcePath: "doc.md",
ChildTopicIDs: []string{a, b}, CreatedBy: env.PrincipalUserID(),
}); err != nil {
t.Fatal(err)
}
// Try to batch (a, c) — should 409.
body, _ := json.Marshal(map[string]any{"child_topic_ids": []string{a, c}})
r := httptest.NewRequest("POST", "/api/batches", bytes.NewReader(body))
r = r.WithContext(env.PrincipalCtx(r.Context()))
w := httptest.NewRecorder()
env.Mux().ServeHTTP(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
}
If newServerTestEnv doesn't exist, model it on internal/server/helpers_test.go — that file already wires up a Mux with stubbed Collab and AgentService. Add SeedOpenTopic, PrincipalCtx, PrincipalUserID, Collab(), and Mux() helpers as needed.
go test ./internal/server/ -run TestPostBatches -v
Expected: FAIL — route not registered.
internal/server/handler_batches.gopackage server
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"github.com/getorcha/wiki-browser/internal/agent"
"github.com/getorcha/wiki-browser/internal/auth"
"github.com/getorcha/wiki-browser/internal/collab"
"github.com/getorcha/wiki-browser/internal/realtime"
)
type batchCreateRequest struct {
ChildTopicIDs []string `json:"child_topic_ids"`
}
func (d Deps) handleCreateBatch(w http.ResponseWriter, r *http.Request) {
if d.Collab == nil {
writeJSONError(w, http.StatusServiceUnavailable, "collab_unavailable")
return
}
if d.AgentService == nil {
writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
return
}
var req batchCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_json")
return
}
if len(req.ChildTopicIDs) < 2 {
writeJSONError(w, http.StatusUnprocessableEntity, "batch_minimum")
return
}
// Pre-check: all children must exist, be open, share source, not batched,
// AND not have any in-flight individual incorporate job. The last check
// is critical — if a child has a queued/running solo job, that job will
// run after the batch forms, producing a proposal targeting the now-
// batched child (which violates "no individual proposals on batched
// children"). CreateBatch repeats the existence/openness/source checks
// inside the transaction; this read surfaces the right 4xx code without
// parsing collab errors twice.
var sourcePath string
for _, id := range req.ChildTopicIDs {
state, err := d.Collab.GetTopicState(id)
if err != nil {
writeJSONError(w, http.StatusNotFound, "unknown_topic")
return
}
if !state.Open {
writeJSONError(w, http.StatusUnprocessableEntity, "child_terminal")
return
}
if sourcePath == "" {
sourcePath = state.SourcePath
} else if sourcePath != state.SourcePath {
writeJSONError(w, http.StatusUnprocessableEntity, "cross_source")
return
}
inflight, err := d.Collab.HasInflightIncorporateForTopic(id)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "lookup_failed")
return
}
if inflight {
writeJSON(w, http.StatusConflict, map[string]any{
"code": "child_has_inflight_job",
"topic_id": id,
})
return
}
}
principal, _ := auth.PrincipalFrom(r.Context())
parentID := uuid.NewString()
if err := d.Collab.CreateBatch(collab.CreateBatchInput{
ParentTopicID: parentID, SourcePath: sourcePath,
ChildTopicIDs: req.ChildTopicIDs, CreatedBy: principal.UserID,
}); err != nil {
switch {
case errors.Is(err, collab.ErrBatchMinimum):
writeJSONError(w, http.StatusUnprocessableEntity, "batch_minimum")
case errors.Is(err, collab.ErrCrossSourceBatch):
writeJSONError(w, http.StatusUnprocessableEntity, "cross_source")
case errors.Is(err, collab.ErrChildAlreadyBatched):
writeJSONError(w, http.StatusConflict, "child_already_batched")
case errors.Is(err, collab.ErrTopicAlreadyTerminal):
writeJSONError(w, http.StatusUnprocessableEntity, "child_terminal")
default:
writeJSONError(w, http.StatusInternalServerError, "create_batch_failed")
}
return
}
jobID, err := d.AgentService.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: sourcePath, TopicID: parentID,
})
if err != nil {
// The batch row IS already in the DB at this point — CreateBatch ran
// atomically. If Submit fails, the batch is "stuck": parent + attached
// children exist but no job is running. Return 202 with parent_topic_id
// and a null job_id so the chrome can render the parent's card with the
// existing #4 retry affordance (POST /api/topics/{parent_id}/proposals
// is idempotent and will enqueue a fresh job). Do NOT roll the batch
// back — partial rollback of CreateBatch (deleting the parent + audit
// message + clearing children) is exactly the kind of multi-store
// transaction this codebase consistently avoids.
var code string
switch {
case errors.Is(err, agent.ErrInflight):
code = "inflight"
default:
code = "submit_failed"
}
writeJSON(w, http.StatusAccepted, map[string]any{
"parent_topic_id": parentID,
"job_id": nil,
"code": code,
})
return
}
if d.Realtime != nil {
d.Realtime.Publish(sourcePath, realtime.Event{
Type: "topic.created",
Payload: topicCreatedPayload(parentID, sourcePath, "batch", "", principal.UserID, time.Now()),
})
}
writeJSON(w, http.StatusCreated, map[string]string{
"parent_topic_id": parentID,
"job_id": jobID,
})
}
internal/server/server.goInsert above the existing POST /api/topics registration:
mux.Handle("POST /api/batches",
d.withSession(auth.RequireCollaborator(auth.RequireCSRF(http.HandlerFunc(d.handleCreateBatch)))))
go test ./internal/server/ -run TestPostBatches -v
go test ./internal/server/ -v
Expected: PASS.
git add internal/server/handler_batches.go internal/server/handler_batches_test.go internal/server/server.go
git commit -m "server: POST /api/batches creates a batch parent and enqueues incorporate job"
DiscardBatchFiles:
Modify: internal/server/handler_proposals.go (handleProposeRewrite, handleDiscardTopic)
Modify: internal/server/handler_proposals_test.go
Step 1: Add failing tests
In internal/server/handler_proposals_test.go, add:
func TestProposeRewrite_409sOnBatchedChild(t *testing.T) {
env := newServerTestEnv(t)
defer env.Cleanup()
a, b := uuid.NewString(), uuid.NewString()
env.SeedOpenTopic(a, "doc.md")
env.SeedOpenTopic(b, "doc.md")
parent := uuid.NewString()
if err := env.Collab().CreateBatch(collab.CreateBatchInput{
ParentTopicID: parent, SourcePath: "doc.md",
ChildTopicIDs: []string{a, b}, CreatedBy: env.PrincipalUserID(),
}); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("POST", "/api/topics/"+a+"/proposals", nil)
r = r.WithContext(env.PrincipalCtx(r.Context()))
w := httptest.NewRecorder()
env.Mux().ServeHTTP(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
var resp struct{ Code string }
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Code != "topic_batched" {
t.Errorf("expected code=topic_batched, got %s", resp.Code)
}
}
func TestDiscardTopic_routesBatchParentToDiscardBatch(t *testing.T) {
env := newServerTestEnv(t)
defer env.Cleanup()
a, b := uuid.NewString(), uuid.NewString()
env.SeedOpenTopic(a, "doc.md")
env.SeedOpenTopic(b, "doc.md")
parent := uuid.NewString()
if err := env.Collab().CreateBatch(collab.CreateBatchInput{
ParentTopicID: parent, SourcePath: "doc.md",
ChildTopicIDs: []string{a, b}, CreatedBy: env.PrincipalUserID(),
}); err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"reason": "reformulating"})
r := httptest.NewRequest("POST", "/api/topics/"+parent+"/discard", bytes.NewReader(body))
r = r.WithContext(env.PrincipalCtx(r.Context()))
w := httptest.NewRecorder()
env.Mux().ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Children detached.
for _, child := range []string{a, b} {
parentNow, _ := env.Collab().BatchParentForTopic(child)
if parentNow != "" {
t.Errorf("child %s still attached to %s", child, parentNow)
}
}
}
go test ./internal/server/ -run 'TestProposeRewrite_409sOnBatchedChild|TestDiscardTopic_routesBatchParentToDiscardBatch' -v
Expected: FAIL.
handleProposeRewriteAfter loading state and the existing if !state.Open guard, add:
batched, err := d.Collab.IsTopicBatched(topicID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "lookup_failed")
return
}
if batched {
writeJSONError(w, http.StatusConflict, "topic_batched")
return
}
handleDiscardTopicBefore the existing d.Collab.DiscardTopic(...) call, check whether the target is a batch parent and route to DiscardBatch:
// If the target is a batch parent, abandon the batch (clears children's
// parent_topic_id + writes audit message) in a single transaction.
isBatchParent, err := d.Collab.IsBatchParent(id)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "lookup_failed")
return
}
if isBatchParent {
discardedAt, derr := d.Collab.DiscardBatch(collab.DiscardBatchInput{
ParentTopicID: id, ByUser: principal.UserID, Reason: strings.TrimSpace(req.Reason),
})
if derr != nil {
if errors.Is(derr, collab.ErrTopicAlreadyTerminal) {
writeJSONError(w, http.StatusUnprocessableEntity, "topic_terminal")
return
}
writeJSONError(w, http.StatusInternalServerError, "discard_failed")
return
}
if d.Realtime != nil {
d.Realtime.Publish(state.SourcePath, realtime.Event{
Type: "topic.discarded",
Payload: map[string]any{
"topic_id": id,
"discarded_by": principal.UserID,
"discarded_at": discardedAt,
},
})
}
writeJSON(w, http.StatusOK, map[string]int64{"discarded_at": discardedAt})
return
}
// Solo discard against a batched child is blocked — the user must abandon
// the batch first.
batched, err := d.Collab.IsTopicBatched(id)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "lookup_failed")
return
}
if batched {
writeJSONError(w, http.StatusConflict, "topic_batched")
return
}
go test ./internal/server/ -v
go test ./internal/collab/ -v
Expected: PASS.
git add internal/server/handler_proposals.go internal/server/handler_proposals_test.go internal/collab/batch.go
git commit -m "server: 409 on solo Propose/Discard for batched child; route batch-parent discard through DiscardBatch"
handleIncorporate transitions batch parent + children atomicallyFiles:
internal/server/handler_proposals.go (handleIncorporate)internal/server/handler_proposals_test.goWhen the proposal's target Topic is a batch parent, load the children, pass them through collab.IncorporateInput.ChildTopicIDs, and emit topic.incorporated for the parent and each child after the commit lands.
Append to internal/server/handler_proposals_test.go:
func TestIncorporate_batchEmitsTopicIncorporatedForParentAndChildren(t *testing.T) {
env := newServerTestEnv(t)
defer env.Cleanup()
a, b := uuid.NewString(), uuid.NewString()
env.SeedOpenTopic(a, "doc.md")
env.SeedOpenTopic(b, "doc.md")
parent := uuid.NewString()
if err := env.Collab().CreateBatch(collab.CreateBatchInput{
ParentTopicID: parent, SourcePath: "doc.md",
ChildTopicIDs: []string{a, b}, CreatedBy: env.PrincipalUserID(),
}); err != nil {
t.Fatal(err)
}
// Seed a fresh proposal on the parent. Use the env helper.
prop := env.SeedAgentProposal(parent, "doc.md", "rewritten")
subscriber := env.SubscribeRealtime("doc.md")
r := httptest.NewRequest("POST", "/api/proposals/"+prop.ID+"/incorporate", bytes.NewBufferString("{}"))
r = r.WithContext(env.PrincipalCtx(r.Context()))
w := httptest.NewRecorder()
env.Mux().ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
got := subscriber.WaitForEvents(t, "topic.incorporated", 3) // parent + a + b
if len(got) != 3 {
t.Fatalf("expected 3 topic.incorporated events; got %d: %+v", len(got), got)
}
ids := make(map[string]bool, 3)
for _, ev := range got {
ids[ev.Payload["topic_id"].(string)] = true
}
for _, id := range []string{parent, a, b} {
if !ids[id] {
t.Errorf("expected topic.incorporated for %s", id)
}
}
}
If the env helpers don't yet exist (SeedAgentProposal, SubscribeRealtime, WaitForEvents), extend helpers_test.go first — model on e2e_realtime_test.go.
Add a second test that exercises the stale-proposal 409 path for a batch:
func TestIncorporate_batch_409sWhenProposalStaleFromNewOpenTopic(t *testing.T) {
env := newServerTestEnv(t)
defer env.Cleanup()
a, b := uuid.NewString(), uuid.NewString()
env.SeedOpenTopic(a, "doc.md")
env.SeedOpenTopic(b, "doc.md")
parent := uuid.NewString()
if err := env.Collab().CreateBatch(collab.CreateBatchInput{
ParentTopicID: parent, SourcePath: "doc.md",
ChildTopicIDs: []string{a, b}, CreatedBy: env.PrincipalUserID(),
}); err != nil {
t.Fatal(err)
}
// Proposal whose proposed source lacks the marker for a brand-new
// unrelated open topic created after job start.
prop := env.SeedAgentProposal(parent, "doc.md", "rewritten with no markers")
unrelated := uuid.NewString()
env.SeedOpenTopic(unrelated, "doc.md")
r := httptest.NewRequest("POST", "/api/proposals/"+prop.ID+"/incorporate", bytes.NewBufferString("{}"))
r = r.WithContext(env.PrincipalCtx(r.Context()))
w := httptest.NewRecorder()
env.Mux().ServeHTTP(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 stale_proposal, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Code string `json:"code"`
StaleReasons []string `json:"stale_reasons"`
MissingTopicIDs []string `json:"missing_topic_ids"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Code != "stale_proposal" {
t.Errorf("code: got %s want stale_proposal", resp.Code)
}
found := false
for _, id := range resp.MissingTopicIDs {
if id == unrelated {
found = true
}
}
if !found {
t.Errorf("missing_topic_ids should contain the unrelated open topic; got %v", resp.MissingTopicIDs)
}
}
Expected: FAIL — children don't receive their event.
handleIncorporateAfter the existing prop, err := d.Collab.GetProposal(id) and state, err := d.Collab.GetTopicState(prop.TopicID) lookups, but before computing the reanchor set, add a children lookup:
isBatchParent, err := d.Collab.IsBatchParent(state.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "lookup_failed")
return
}
var childTopicIDs []string
if isBatchParent {
childTopicIDs, err = d.Collab.BatchChildrenOfParent(state.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "load_children_failed")
return
}
}
Pass ChildTopicIDs: childTopicIDs into collab.IncorporateInput.
Replace the single-event emit with a loop that fans out one topic.incorporated per transitioned Topic:
if d.Realtime != nil {
emit := func(topicID string) {
d.Realtime.Publish(state.SourcePath, realtime.Event{
Type: "topic.incorporated",
Payload: map[string]any{
"topic_id": topicID,
"source_path": state.SourcePath,
"commit_sha": commitSHA,
"incorporated_by": principal.UserID,
"incorporated_at": time.Now().Unix(),
},
})
}
emit(state.ID)
for _, id := range childTopicIDs {
emit(id)
}
}
When computing the reanchor set, the existing ListOpenTopicsForSourceExcluding(state.SourcePath, state.ID) returns Topics still open at approval time — including this batch's children and any other batch parents. The existing logic then filters that list by strings.Contains(prop.ProposedSource, marker). For batches this works correctly because (a) the parent has no marker, so its Contains-check returns false; (b) each child's marker was dropped from proposed_source by the Agent per the rewrite contract, so each child's Contains-check also returns false. The reanchor list therefore contains only Topics outside the batch — exactly the right set. No code change needed in the reanchor pass.
Default commit subject: when the proposal target is a batch parent, prefer the parent's agent-proposal body over the first human message. Add to the subject-default branch:
subject := strings.TrimSpace(req.Subject)
if subject == "" {
if isBatchParent {
agentMsg, err := d.Collab.GetAgentProposalMessage(prop.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "load_explanation_failed")
return
}
subject = DefaultBatchIncorporateSubject(len(childTopicIDs), agentMsg.Body)
} else {
first, err := d.Collab.FirstHumanMessage(state.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "load_first_message_failed")
return
}
subject = DefaultIncorporateSubject(first.Body)
}
}
DefaultBatchIncorporateSubject to internal/server/subject.goRead the existing DefaultIncorporateSubject first (subject.go), then add the batch variant alongside it:
// DefaultBatchIncorporateSubject builds the commit subject when the
// proposal being approved is anchored on a batch parent. n is the number of
// children; explanation is the parent's agent-proposal message body. The
// first 60 runes of the explanation (with Markdown-prefix stripping and
// whitespace collapsing) become the <summary>.
func DefaultBatchIncorporateSubject(n int, explanation string) string {
summary := normaliseSubjectSummary(explanation, 60)
if summary == "" {
return fmt.Sprintf("Incorporate %d Topics", n)
}
return fmt.Sprintf("Incorporate %d Topics: %s", n, summary)
}
If normaliseSubjectSummary doesn't already exist as a helper, extract the relevant fragment from DefaultIncorporateSubject into a shared helper. The whole point of the rune-truncation logic from #4 is captured there.
go test ./internal/server/ -run TestIncorporate_batchEmits -v
go test ./internal/server/ -v
Expected: PASS.
git add internal/server/handler_proposals.go internal/server/handler_proposals_test.go internal/server/subject.go internal/server/subject_test.go
git commit -m "server: handleIncorporate transitions batch parent + children and emits per-topic events"
wb-incorporate/SKILL.md for batchesFiles:
.claude/skills/wb-incorporate/SKILL.mdThe skill body branches on anchor.kind = "batch". For batches, the working set is the parent's thread + every child's thread + prior proposals; the rewrite drops every child's marker (the parent has none); the explanation covers all N rewrites. The plural --exclude-topics flag replaces --exclude-topic.
Open .claude/skills/wb-incorporate/SKILL.md and replace the contents of Steps 1–3 with:
## 1. Load the working set
Run:
<wb-agent path> get-topic --config=… --job-id=…
The response includes the Topic you're working on with its anchor and the
full message thread, the absolute path to the Source file, and the
`base_source_sha` of the Source at this moment.
If the response's `anchor.kind` is `"batch"`, this job is a **batch
incorporation**: the response additionally carries a `children` array, one
entry per Topic in the batch. Each child has its own anchor and its own
message thread. The parent's thread holds batch-level commentary (and
prior proposal explanations); the children's threads hold the individual
discussions.
For both N=1 and batch cases, prior proposals for this Topic (or its
batch) appear inside the parent's message thread as `kind:
"agent-proposal"` rows with their full `proposed_source` bodies inlined —
read them as your own earlier thinking.
Then list the other open discussions on the same Source so you can keep
them anchored. Pass every Topic id in the batch's set (the parent's id +
every child's id, or just the lone Topic for N=1) so they're all excluded:
<wb-agent path> list-open-topics --config=… --source-path=<abs> --exclude-topics=<csv>
For N=1, the CSV has one entry. For batches, it's the parent's id followed
by every child's id, comma-separated.
Read the current Source via the Read tool at the absolute `source_path`.
## 2. Produce a rewrite
Apply the discussions to the Source. For N=1 the discussion comes from
the lone Topic's thread; for a batch, you're applying every child's
discussion at once. The discussions are authoritative — don't invent
changes the humans didn't agree to. If a discussion is ambiguous or
unresolved, prefer the most recent human messages and explicit decision
markers ("yes", "approved", "let's go with X").
If batched children's discussions conflict with one another (rare, but
possible since the user batched them despite the conflict), pick the
interpretation that preserves both intents where possible, and flag the
conflict in the explanation in Step 4 so humans can review.
If your edit orphans nearby references — a caption naming a removed
element, a sentence pointing at a deleted section, a cross-reference whose
target moved — fix those too. Stay conservative: only what the edit
directly broke, not surrounding prose you'd merely improve.
## 3. Drop your markers; re-anchor every other open Topic
For the Topic you're incorporating (or every Topic in the batch's set for
N≥2), the rewritten Source MUST NOT contain any occurrence of
`data-orcha-anchor="<id>"`. The batch parent itself never had a marker —
no action needed for it.
For every Topic returned by `list-open-topics`, the rewritten Source MUST
contain at least one occurrence of `data-orcha-anchor="<that-topic-id>"`.
Placement rules (unchanged from N=1):
- If the Topic's intent maps to a region of the new Source, wrap it. Inline
anchors use `<span data-orcha-anchor="<id>">…</span>`; block-level
anchors use `<div data-orcha-anchor="<id>"></div>` immediately preceding
the block, separated by a blank line.
- If a Topic's intent spans multiple regions, use multiple markers.
- If a Topic's intent no longer maps cleanly to anything in the new
Source, append it to a section titled exactly:
## Other ideas (potentially to discard)
at the very bottom of the Source. Each parked Topic gets at least one
sub-bullet referencing the discussion, wrapped in a marker.
## 4. Write a short explanation
In 1–3 paragraphs, summarise what the rewrite does and why.
For N=1: explain what you understood the Topic to be asking for and how
your rewrite addresses it.
For a batch: cover every child's intent in the same prose — one paragraph
each is fine, or one TL;DR paragraph followed by per-child notes. The
first sentence should function as a TL;DR — the commit subject default
pulls from this prefix. If the batched discussions are conceptually
unrelated to one another, just say so ("these three independent changes
were batched for one review pass") — the batch doesn't need to invent
narrative coherence.
The Step 5 (Persist) section needs no change — insert-proposal is identical for batches; the binary derives the proposal's topic_id from the job.
glow .claude/skills/wb-incorporate/SKILL.md | head -120
(Or simply cat the file and inspect. No tests for SKILL.md beyond manual review — it's prompt text consumed by Claude Code.)
git add .claude/skills/wb-incorporate/SKILL.md
git commit -m "wb-incorporate: branch on anchor.kind=batch; --exclude-topics plural"
Files:
internal/server/static/chrome.js (renderTopicList, renderTopicCard)internal/server/static/chrome.css (accent rail, batch pill)Sidebar adds a new "Batches" group. A parent renders as a normal card with a "Batch · N" pill. Children render under the parent — visible only when the focused Topic is the parent or any child — with an accent rail and indentation.
renderTopicListReplace the existing function (chrome.js around line 1202) with:
function renderTopicList(topics) {
topicList.innerHTML = '';
const globals = [];
const anchored = [];
const parents = [];
const childrenOf = new Map();
for (const t of topics) {
const anchor = anchorObject(t.anchor);
if (anchor.kind === 'batch') {
parents.push(t);
} else if (t.parent_topic_id) {
const arr = childrenOf.get(t.parent_topic_id) || [];
arr.push(t);
childrenOf.set(t.parent_topic_id, arr);
} else if (anchor.kind === 'global') {
globals.push(t);
} else {
anchored.push(t);
}
}
if (globals.length) {
appendSubheader('Global');
for (const t of globals) topicList.appendChild(renderTopicCard(t));
}
if (anchored.length) {
appendSubheader('Anchored');
for (const t of anchored) topicList.appendChild(renderTopicCard(t));
}
if (parents.length) {
appendSubheader('Batches');
for (const parent of parents) {
const parentCard = renderTopicCard(parent);
topicList.appendChild(parentCard);
const children = childrenOf.get(parent.id) || [];
const focusInBatch = selectedTopicID === parent.id || children.some(c => c.id === selectedTopicID);
const wrap = document.createElement('div');
wrap.className = 'wb-batch-children';
wrap.dataset.parentId = parent.id;
wrap.hidden = !focusInBatch;
for (const child of children) wrap.appendChild(renderTopicCard(child));
topicList.appendChild(wrap);
}
}
}
renderTopicCard for the batch pillInside renderTopicCard, after the label span computation:
const anchor = anchorObject(t.anchor);
const isBatch = anchor.kind === 'batch';
const label = isBatch ? 'Batch' : (anchor.kind === 'global' ? 'Global' : 'Anchored');
// existing innerHTML assignment, with the addition of a pill for batches:
const pill = isBatch
? '<span class="wb-topic-card__pill">Batch · ' + countChildren(t.id) + '</span>'
: '';
btn.innerHTML =
'<span class="wb-topic-card__select" hidden></span>' +
'<div class="wb-topic-card__head">' +
'<span class="wb-topic-card__dot"></span>' +
'<span class="wb-topic-card__label">' + escapeHTML(label) + '</span>' +
pill +
'<div class="wb-topic-card__readers" aria-label="Readers"></div>' +
'</div>' +
quoteHTML +
'<p class="wb-topic-card__preview">' + escapeHTML(truncate(t.first_message_preview || '', 120)) + '</p>';
Add a countChildren helper that reads from the same childrenOf map that renderTopicList built — promote the map to a module-level Map and refresh it on each renderTopicList call, or pass it in as a parameter to renderTopicCard.
Find selectTopic and clearTopicSelection (chrome.js around lines 1250 and 1267). Add a call after each selectedTopicID mutation:
function refreshBatchChildVisibility() {
if (!topicList) return;
const wraps = topicList.querySelectorAll('.wb-batch-children');
wraps.forEach(wrap => {
const parentID = wrap.dataset.parentId;
const childIDs = Array.from(wrap.querySelectorAll('.wb-topic-card')).map(c => c.dataset.topicId);
const focused = selectedTopicID === parentID || childIDs.includes(selectedTopicID);
wrap.hidden = !focused;
});
}
Call it at the end of selectTopic and at the end of clearTopicSelection.
Inspect the existing CSS file to find where .wb-topic-card is styled. Append:
.wb-topic-card__pill {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--accent);
color: var(--bg);
padding: 1px 6px;
border-radius: 3px;
margin-left: auto;
}
.wb-batch-children {
margin-left: 14px;
padding-left: 8px;
border-left: 2px solid var(--accent);
}
.wb-batch-children > .wb-topic-card {
margin-top: 4px;
}
Per the project CLAUDE.md, templates and static assets are baked into the binary via embed.FS so a rebuild is mandatory.
playwright-cli list
playwright-cli close-all
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli open --browser=chromium http://localhost:8080/doc/<some/path>
playwright-cli resize 1440 900
playwright-cli screenshot --filename=/tmp/wb-batch-sidebar.png
Read /tmp/wb-batch-sidebar.png and inspect the sidebar. With no batch yet, the sidebar should look unchanged. Create a batch via curl + CSRF token from /auth/me, then reload and confirm the parent card has the "Batch · N" pill and that clicking it reveals the children with the rail.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "chrome: sidebar Batches group with parent pill, rail-indented children on focus"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssClicking the hidden wb-topic-card__select slot enters multi-select mode: every card's checkbox unhides, a sidebar header bar appears with "N selected · Batch & Rewrite". Submitting POSTs /api/batches. Cards from different sources or already-batched are disabled in the multi-select.
After renderTopicCard define the checkbox interaction:
let multiSelectMode = false;
const multiSelectIDs = new Set();
function enterMultiSelectMode() {
multiSelectMode = true;
topicList.classList.add('wb-topic-list--multiselect');
topicList.querySelectorAll('.wb-topic-card__select').forEach(s => s.hidden = false);
renderMultiSelectHeader();
}
function exitMultiSelectMode() {
multiSelectMode = false;
multiSelectIDs.clear();
topicList.classList.remove('wb-topic-list--multiselect');
topicList.querySelectorAll('.wb-topic-card__select').forEach(s => {
s.hidden = true;
s.classList.remove('wb-topic-card__select--checked');
});
renderMultiSelectHeader();
}
function toggleSelect(cardEl, topicID) {
if (multiSelectIDs.has(topicID)) {
multiSelectIDs.delete(topicID);
cardEl.querySelector('.wb-topic-card__select')?.classList.remove('wb-topic-card__select--checked');
} else {
multiSelectIDs.add(topicID);
cardEl.querySelector('.wb-topic-card__select')?.classList.add('wb-topic-card__select--checked');
}
renderMultiSelectHeader();
}
function renderMultiSelectHeader() {
let header = document.getElementById('wb-multiselect-header');
if (!multiSelectMode || multiSelectIDs.size === 0) {
if (header) header.remove();
return;
}
if (!header) {
header = document.createElement('div');
header.id = 'wb-multiselect-header';
header.className = 'wb-multiselect-header';
topicSidebar.insertBefore(header, topicList);
}
const n = multiSelectIDs.size;
header.innerHTML =
'<span class="wb-multiselect-header__count">' + n + ' selected</span>' +
'<button type="button" class="wb-multiselect-header__cancel">Cancel</button>' +
'<button type="button" class="wb-multiselect-header__submit"' + (n < 2 ? ' disabled' : '') + '>Batch & Rewrite</button>';
header.querySelector('.wb-multiselect-header__cancel').onclick = exitMultiSelectMode;
header.querySelector('.wb-multiselect-header__submit').onclick = submitBatch;
}
async function submitBatch() {
const ids = Array.from(multiSelectIDs);
const csrf = await currentCSRFToken(); // existing helper used by other POSTs
const resp = await fetch('/api/batches', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ child_topic_ids: ids }),
});
if (!resp.ok) {
pushToast({ title: 'Could not create batch', body: 'HTTP ' + resp.status, kind: 'error' });
return;
}
const data = await resp.json();
exitMultiSelectMode();
await loadTopics();
// Focus the new parent so its children appear under it.
selectTopic(data.parent_topic_id);
}
Hook the checkbox-click in the card's click handler. Find the existing btn.onclick = … (around the card creation in renderTopicCard) and adjust:
btn.addEventListener('click', (ev) => {
if (multiSelectMode) {
ev.stopPropagation();
toggleSelect(btn, t.id);
return;
}
// existing single-click selection behaviour
selectTopic(t.id);
});
Add an entry point to enter multi-select: a checkbox icon next to "Anchored" / "Batches" subheaders, OR a long-press handler on cards. For v1, use a small "Select" button on each subheader:
function appendSubheader(label) {
const h = document.createElement('h3');
h.className = 'wb-topic-list__subheader';
const lbl = document.createElement('span');
lbl.textContent = label;
h.appendChild(lbl);
if (label === 'Anchored' || label === 'Global') {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'wb-topic-list__subheader-select';
btn.textContent = 'Select';
btn.onclick = enterMultiSelectMode;
h.appendChild(btn);
}
topicList.appendChild(h);
}
In chrome.css:
.wb-multiselect-header {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
background: var(--accent-light);
border-bottom: 1px solid var(--accent);
font-size: 12px;
}
.wb-multiselect-header__count { font-weight: 600; flex: 1; }
.wb-multiselect-header__cancel,
.wb-multiselect-header__submit {
padding: 4px 10px;
border: 1px solid var(--rule);
background: var(--surface);
font-family: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.wb-multiselect-header__submit {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
.wb-multiselect-header__submit[disabled] { opacity: 0.5; cursor: not-allowed; }
.wb-topic-card__select {
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid var(--rule);
border-radius: 3px;
margin-right: 6px;
}
.wb-topic-card__select--checked {
background: var(--accent);
border-color: var(--accent);
}
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli screenshot --filename=/tmp/wb-multi-select.png
Read /tmp/wb-multi-select.png — confirm Select button appears on subheaders and that clicking it enters multi-select mode. Click checkboxes on two anchored cards, click Batch & Rewrite, confirm the parent appears in the sidebar with the Batch · 2 pill and a job spinner.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "chrome: multi-select mode + Batch & Rewrite header bar"
Files:
internal/server/static/chrome.jsThe Resolve toolbar's "1 Topic" label (reserved by #8) becomes the count of transitioned Topics when the resolved proposal is anchored on a batch parent. Clicking the label expands an inline panel listing each child with quick-jump links to its thread in the right sidebar.
Inside the existing Resolve-mount template build, change the count span to:
const childCount = isBatch ? (resolveBatchChildren.length + 1) : 1;
const countText = isBatch ? (childCount + ' Topics') : '1 Topic';
// ...
<button class="wb-resolve__count" type="button" aria-expanded="false">${escapeHTML(countText)}</button>
Plumb resolveBatchChildren through by fetching the topic list when Resolve opens for a batch parent. loadTopics() currently returns undefined (it side-effects through renderTopicList). Don't refactor it — just hit the API again, scoped to this Resolve session:
async function openResolveFor(proposalID, topicID) {
// existing fetches...
const sourcePath = currentSourcePath();
const resp = await fetch('/api/topics?source_path=' + encodeURIComponent(sourcePath), {
credentials: 'same-origin', cache: 'no-store',
});
const topics = resp.ok ? await resp.json() : [];
const parentTopic = topics.find(t => t.id === topicID);
const anchor = anchorObject(parentTopic ? parentTopic.anchor : '{"kind":"global"}');
if (anchor.kind === 'batch') {
resolveBatchChildren = topics.filter(t => t.parent_topic_id === topicID);
} else {
resolveBatchChildren = [];
}
// ...
}
const countBtn = resolveMount.querySelector('.wb-resolve__count');
if (countBtn) {
countBtn.addEventListener('click', () => {
let panel = resolveMount.querySelector('.wb-resolve__children');
if (panel) {
panel.remove();
countBtn.setAttribute('aria-expanded', 'false');
return;
}
panel = document.createElement('div');
panel.className = 'wb-resolve__children';
const items = [resolveParentSummary(), ...resolveBatchChildren.map(t => resolveChildSummary(t))];
panel.innerHTML = items.map(item =>
'<button type="button" class="wb-resolve__child" data-topic-id="' + escapeHTML(item.id) + '">' +
escapeHTML(item.label) +
'</button>'
).join('');
panel.querySelectorAll('.wb-resolve__child').forEach(btn => {
btn.onclick = () => selectTopic(btn.dataset.topicId);
});
resolveMount.querySelector('.wb-resolve__toolbar').appendChild(panel);
countBtn.setAttribute('aria-expanded', 'true');
});
}
Add resolveParentSummary / resolveChildSummary helpers that return {id, label} from the cached topic objects.
.wb-resolve__children {
position: absolute;
top: 100%;
left: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 4px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
z-index: 5;
}
.wb-resolve__child {
text-align: left;
padding: 4px 8px;
font-family: inherit;
font-size: 12px;
background: transparent;
border: none;
cursor: pointer;
}
.wb-resolve__child:hover { background: var(--accent-light); }
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
Create a batch, wait for its proposal, click Review changes on the parent's agent-proposal message, screenshot:
playwright-cli screenshot --filename=/tmp/wb-resolve-batch.png
Read the screenshot. Confirm the toolbar shows "3 Topics" (or whatever N+1 is), clicking it expands a panel with one entry per Topic, clicking an entry swaps the right sidebar to that Topic's thread.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "chrome: Resolve toolbar shows N Topics expander for batches"
Files:
internal/server/e2e_resolve_test.go (extend)A chromedp-driven test that exercises the entire batch flow end to end: seed two open Topics, click into multi-select, batch them, wait for the proposal, approve, and assert one commit + both children incorporated.
Append to internal/server/e2e_resolve_test.go:
func TestE2E_BatchedIncorporation(t *testing.T) {
if _, err := chromeBinary(); err != nil {
t.Skip("chrome/chromium not available; skipping headless test")
}
env := newE2EServer(t) // existing helper that builds Mux with a real collab DB and a fake agent service
defer env.Cleanup()
// Seed: a real git repo with one Source file; two open Topics on it.
env.WriteSource("doc.md", "Hello world.\n\nSecond paragraph.\n")
env.GitCommit("seed")
a := env.CreateTopic("doc.md", "first paragraph topic")
b := env.CreateTopic("doc.md", "second paragraph topic")
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(),
append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true))...)
defer allocCancel()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
if err := chromedp.Run(ctx, chromedp.Navigate(env.URL+"/doc/doc.md")); err != nil {
t.Fatal(err)
}
// Stub the agent so the wb-incorporate skill is replaced with a fake that
// drops both children's markers and adds an explanation. env.SetAgentRunner
// is an existing test seam.
env.SetAgentRunner(func(input agentFakeInput) error {
rewritten := "Hello world.\n\nSecond paragraph rewritten.\n"
// Drop child markers (there were none in the source to begin with —
// this branch just needs to produce a proposal that satisfies the
// post-job invariant: no markers for the batch's set).
_, err := env.InsertProposalAsAgent(input.JobID, rewritten,
"Combining first- and second-paragraph edits into one rewrite.")
return err
})
// Drive the UI: enter multi-select, check both cards, click Batch & Rewrite.
if err := chromedp.Run(ctx,
chromedp.WaitVisible(`.wb-topic-list__subheader`),
chromedp.Click(`.wb-topic-list__subheader-select`),
chromedp.Click(`.wb-topic-card[data-topic-id="`+a+`"] .wb-topic-card__select`),
chromedp.Click(`.wb-topic-card[data-topic-id="`+b+`"] .wb-topic-card__select`),
chromedp.Click(`.wb-multiselect-header__submit`),
); err != nil {
t.Fatalf("multi-select: %v", err)
}
// Wait for the proposal to land + agent-proposal message to appear.
if err := chromedp.Run(ctx,
chromedp.Poll(`document.querySelectorAll('.wb-topic-card .wb-topic-card__pill').length === 1`, nil),
chromedp.Poll(`document.querySelectorAll('.wb-agent-msg').length === 1`, nil),
); err != nil {
t.Fatalf("await proposal: %v", err)
}
// Approve.
if err := chromedp.Run(ctx,
chromedp.Click(`.wb-agent-msg__review`),
chromedp.WaitVisible(`.wb-resolve__count`),
); err != nil {
t.Fatalf("open resolve: %v", err)
}
var countText string
if err := chromedp.Run(ctx,
chromedp.Text(`.wb-resolve__count`, &countText),
); err != nil {
t.Fatal(err)
}
if !strings.Contains(countText, "3 Topics") {
t.Errorf("expected '3 Topics' count (parent + 2 children); got %q", countText)
}
if err := chromedp.Run(ctx,
chromedp.Click(`.wb-resolve__approve`),
chromedp.Poll(`document.querySelector('.wb-resolve__count') === null`, nil),
); err != nil {
t.Fatalf("approve: %v", err)
}
// Assertions on backend state.
for _, child := range []string{a, b} {
sha, _ := env.Collab().GetTopicCommitSHA(child)
if !sha.Valid {
t.Errorf("child %s did not get a commit_sha", child)
}
}
// Exactly one new commit on the repo.
out, err := exec.Command("git", "-C", env.RepoRoot, "log", "--oneline").Output()
if err != nil {
t.Fatal(err)
}
if lines := strings.Count(string(out), "\n"); lines != 2 {
t.Errorf("expected 2 commits (seed + incorporate), got %d:\n%s", lines, out)
}
}
Add helpers (newE2EServer, SetAgentRunner, InsertProposalAsAgent, WriteSource, GitCommit, CreateTopic, Collab, RepoRoot, URL, Cleanup) by extending the existing patterns in e2e_resolve_test.go and e2e_realtime_test.go. The fake agent runner returns by inserting the proposal directly through the store rather than spawning a real subprocess.
go test ./internal/server/ -run TestE2E_BatchedIncorporation -v
Expected: PASS.
go test ./...
Expected: PASS (no regressions).
git add internal/server/e2e_resolve_test.go
git commit -m "server: e2e test for batched incorporation flow"
go test ./...
Expected: PASS.
make build
Expected: builds cleanly.
Start the dev server with two collaborators in the allowlist (already configured per the dev.yaml change committed earlier). Sign in as Daniel, create two anchored Topics on the same Source, enter multi-select, batch them, wait for the proposal, click Review changes, expand the "3 Topics" panel, verify each Topic is reachable, approve, and confirm the file has been rewritten with both Topics' markers gone. Open git log -1 --format=%B and verify the commit body has one Topic: trailer per transitioned Topic (parent + each child).
git -C ../ log -1 --format=%B
Expected: trailers visible.
Skim wiki-browser/CLAUDE.md for any conventions this work establishes that future workers would benefit from. The notable candidates:
IsBatchParent + IsTopicBatched for branching server-side on batch state.topics_parent_must_be_batch trigger requires the parent to exist with anchor.kind = "batch" before a child's parent_topic_id is set — CreateBatch orders inserts accordingly.Only add to CLAUDE.md if the rule isn't obvious from the code itself.
If anything outside the existing commits changed (e.g., CLAUDE.md), commit it:
git add wiki-browser/CLAUDE.md
git commit -m "docs: note batched-incorporation conventions in CLAUDE.md"
collab.Incorporate call = one git commit).topic.incorporated events on a batch approve. Chrome dedupes by topic_id and by commit_sha (a single iframe reload covers all events). The test in Task 12 confirms the fan-out at the server boundary; the chrome-side dedupe is unchanged from #6.migrate:no-tx is required because the composite-FK drop needs PRAGMA foreign_keys = OFF. Migration 003 is the canonical reference.internal/collab/anchor.go); no SQL schema change to validate the new value beyond the JSON-checking triggers.