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: Ship the runtime that lets the Go server invoke Claude Code as the Agent — spawning headless claude -p subprocesses, exposing the work surface through a wb-agent CLI, tracking jobs in an agent_jobs table, and wiring the trigger HTTP API.
Architecture: A new internal/agent package owns the queue and the Runner interface; a new cmd/wb-agent binary opens the existing internal/collab code paths for the agent's writes; a new migration relaxes incorporation_proposals.proposed_by and creates agent_jobs. Two project-local skills (wb-incorporate, wb-perspective) scaffold the agent's task contract with <REWRITE CONTRACT OWNED BY #4/#5> placeholders. wiki-browser validates both binaries, the git-author config, and the queue cap at startup.
Tech Stack: Go 1.26, database/sql with modernc.org/sqlite, os/exec with Setpgid + cmd.Cancel + cmd.WaitDelay, embedded migrations, Claude Code 2.x CLI.
Reference spec: docs/superpowers/specs/2026-05-12-agent-runtime-design.html. Cross-cutting context: docs/superpowers/specs/2026-05-10-collaborative-annotations-decisions.md. #1 reference: docs/superpowers/specs/2026-05-11-document-model-design.html. #7 (auth) is assumed landed — the same auth.RequireCollaborator + auth.RequireCSRF middleware used by /api/topics wraps the new agent-jobs endpoints.
internal/collab/migrations/
└── 003_agent_runtime.sql # NEW: -- migrate:no-tx; relax proposed_by + create agent_jobs
internal/collab/
├── migrate.go # MODIFIED: recognize -- migrate:no-tx directive
├── migrate_test.go # MODIFIED: tests for directive + migration 003
├── mutators.go # MODIFIED: NewProposal.ProposedBy → *string
├── mutators_test.go # MODIFIED: null ProposedBy test
├── agent_jobs.go # NEW: InsertJob, StartJob, CompleteJob, ListJobs*, SweepIncompleteJobs
└── agent_jobs_test.go # NEW
internal/config/
├── config.go # MODIFIED: Agent struct + defaults + validation
└── config_test.go # MODIFIED
internal/agent/
├── runner.go # NEW: Job, RunResult, Runner interface
├── runner_test.go # NEW
├── fake_runner.go # NEW: deterministic Runner for tests
├── claude_cli_runner.go # NEW: real subprocess runner
├── claude_cli_runner_test.go # NEW: uses a fake-claude shell script
├── service.go # NEW: queue + lifecycle + agent_jobs writes
└── service_test.go # NEW
cmd/wb-agent/
├── main.go # NEW: subcommand dispatcher
├── get_topic.go # NEW
├── list_open_topics.go # NEW
├── insert_proposal.go # NEW
├── stubs.go # NEW: get-persona + put-perspective scaffolds
└── main_test.go # NEW
internal/server/
├── server.go # MODIFIED: Deps gains AgentService; new routes
├── agent_jobs.go # NEW: POST + GET handlers
└── agent_jobs_test.go # NEW
cmd/wiki-browser/
└── main.go # MODIFIED: validate agent binaries, start service, sweep at boot
.claude/skills/wb-incorporate/SKILL.md # NEW: scaffold with <REWRITE CONTRACT OWNED BY #4>
.claude/skills/wb-perspective/SKILL.md # NEW: scaffold with <REWRITE CONTRACT OWNED BY #5>
Makefile # MODIFIED: build wb-agent alongside wiki-browser
wiki-browser.example.yaml # MODIFIED: agent: block with all keys
Tasks 1-4 set the foundation (migration runner enhancement, the migration itself, Go mutator changes). Tasks 5-9 build the agent runtime in isolation, testable with FakeRunner. Tasks 10-14 build wb-agent. Tasks 15-17 wire it into the HTTP server and the wiki-browser binary. Task 18 commits the skill scaffolds. Task 19 is a full end-to-end smoke test.
-- migrate:no-tx directiveFiles:
internal/collab/migrate.gointernal/collab/migrate_test.goThe existing runner wraps every migration in a transaction. SQLite's PRAGMA foreign_keys is a no-op inside a transaction, so the table rebuild in Task 2 needs a way to opt out. A one-line directive recognized as the first non-blank line of the SQL file makes the runner skip its own BEGIN/COMMIT; the file then owns its own transaction and FK toggling. The schema_migrations insert still happens in a short tx afterward.
Add to internal/collab/migrate_test.go:
func TestMigrate_NoTxDirective(t *testing.T) {
db := openMemDB(t)
defer db.Close()
mfs := fstest.MapFS{
"001_setup.sql": &fstest.MapFile{Data: []byte(
`CREATE TABLE t (x INTEGER);`,
)},
"002_notx.sql": &fstest.MapFile{Data: []byte(
"-- migrate:no-tx\n" +
"BEGIN;\n" +
"INSERT INTO t(x) VALUES (1);\n" +
"COMMIT;\n" +
"INSERT INTO t(x) VALUES (2);\n", // outside the file's own tx; runner must not wrap
)},
}
if err := collab.Migrate(db, mfs); err != nil {
t.Fatalf("Migrate: %v", err)
}
var got int
if err := db.QueryRow(`SELECT COUNT(*) FROM t`).Scan(&got); err != nil {
t.Fatalf("scan: %v", err)
}
if got != 2 {
t.Fatalf("want 2 rows, got %d", got)
}
var rec string
if err := db.QueryRow(
`SELECT filename FROM schema_migrations WHERE filename = '002_notx.sql'`,
).Scan(&rec); err != nil {
t.Fatalf("schema_migrations missing 002_notx.sql: %v", err)
}
}
Add helper if not present:
import (
"database/sql"
"testing"
"testing/fstest"
_ "modernc.org/sqlite"
)
func openMemDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
return db
}
go test ./internal/collab/ -run TestMigrate_NoTxDirective -v
Expected: FAIL — current applyOne will wrap the BEGIN/COMMIT-containing file in another transaction, causing a cannot start a transaction within a transaction SQL error or similar.
internal/collab/migrate.goReplace the body of applyOne and add the directive detector. Replace the existing applyOne function with:
const noTxDirective = "-- migrate:no-tx"
// firstNonBlankLine returns the first non-empty trimmed line of body.
func firstNonBlankLine(body string) string {
for _, line := range strings.Split(body, "\n") {
t := strings.TrimSpace(line)
if t != "" {
return t
}
}
return ""
}
func applyOne(db *sql.DB, name, body string) error {
if firstNonBlankLine(body) == noTxDirective {
// Migration owns its own tx + any PRAGMA toggling. The runner records
// the schema_migrations row afterward in a separate short transaction.
// If the migration body commits but bookkeeping fails (full disk, conn
// eviction), the database can temporarily contain a forward schema with
// no recorded migration. Recovery is "fix the underlying issue, then
// rerun migrations", so -- migrate:no-tx migrations must be internally
// idempotent (use CREATE TABLE IF NOT EXISTS / guard with schema
// introspection).
if _, err := db.Exec(body); err != nil {
return fmt.Errorf("apply %s: %w", name, err)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("record %s: begin: %w", name, err)
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations(filename) VALUES (?)`, name,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("record %s: commit: %w", name, err)
}
return nil
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin %s: %w", name, err)
}
if _, err := tx.Exec(body); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply %s: %w", name, err)
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations(filename) VALUES (?)`, name,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
return nil
}
go test ./internal/collab/ -run TestMigrate -v
Expected: PASS (both existing and new tests).
git add internal/collab/migrate.go internal/collab/migrate_test.go
git commit -m "wiki-browser: collab — migrate runner recognizes -- migrate:no-tx directive"
proposed_by, create agent_jobsFiles:
internal/collab/migrations/003_agent_runtime.sqlinternal/collab/migrate_test.goThe migration runs the SQLite twelve-step table rebuild on incorporation_proposals (to drop NOT NULL on proposed_by) and creates agent_jobs. It must toggle foreign_keys off, which is only possible outside a transaction — so it uses the -- migrate:no-tx directive from Task 1.
Add to internal/collab/migrate_test.go:
func TestMigrate_003_AgentRuntime(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "collab.db")
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := collab.Migrate(db, collab.MigrationsFS); err != nil {
t.Fatalf("Migrate: %v", err)
}
// 1. proposed_by is now nullable.
if _, err := db.Exec(
`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
); err != nil {
t.Fatalf("seed user: %v", err)
}
if _, err := db.Exec(
`INSERT INTO topics(id, source_path, anchor, created_at, created_by, updated_at)
VALUES ('t1','docs/foo.md','{"kind":"global"}', unixepoch(), 'u1', unixepoch())`,
); err != nil {
t.Fatalf("seed topic: %v", err)
}
if _, err := db.Exec(
`INSERT INTO incorporation_proposals(
id, topic_id, revision_number, proposed_source, base_source_sha,
proposed_by, created_at
) VALUES ('p1','t1', 1, 'body', 'deadbeef', NULL, unixepoch())`,
); err != nil {
t.Fatalf("insert with null proposed_by: %v", err)
}
// 2. Existing non-null rows survive (insert before checking by adding one).
if _, err := db.Exec(
`INSERT INTO incorporation_proposals(
id, topic_id, revision_number, proposed_source, base_source_sha,
proposed_by, created_at
) VALUES ('p2','t1', 2, 'body2', 'cafef00d', 'u1', unixepoch())`,
); err != nil {
t.Fatalf("insert with non-null proposed_by: %v", err)
}
// 3. agent_jobs CHECK rejects mismatched discriminators.
if _, err := db.Exec(
`INSERT INTO agent_jobs(id, kind, source_path, topic_id, persona_name,
status, created_at)
VALUES ('j1','incorporate','docs/foo.md','t1','PERSONA','queued', unixepoch())`,
); err == nil {
t.Fatalf("expected CHECK violation for incorporate+persona_name set")
}
if _, err := db.Exec(
`INSERT INTO agent_jobs(id, kind, source_path, topic_id, persona_name,
status, created_at)
VALUES ('j2','incorporate','docs/foo.md','t1', NULL,'queued', unixepoch())`,
); err != nil {
t.Fatalf("valid incorporate row rejected: %v", err)
}
if _, err := db.Exec(
`INSERT INTO agent_jobs(id, kind, source_path, topic_id, persona_name,
status, created_at)
VALUES ('j3','perspective','docs/bar.md', NULL, 'CFO','queued', unixepoch())`,
); err != nil {
t.Fatalf("valid perspective row rejected: %v", err)
}
if _, err := db.Exec(
`INSERT INTO agent_jobs(id, kind, source_path, topic_id, persona_name,
status, created_at)
VALUES ('j4','incorporate','docs/foo.md','t1', NULL,'queued', unixepoch())`,
); err == nil {
t.Fatalf("expected partial unique violation for second queued job on same source")
}
// 4. foreign_key_check passes after the rebuild.
rows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("foreign_key_check: %v", err)
}
if rows.Next() {
t.Fatalf("foreign_key_check returned violations after migration")
}
rows.Close()
// 5. Composite FK from incorporation_attempts to (id, topic_id) survived
// the rebuild. The 12-step rebuild has to rewrite FKs in *dependent*
// tables' schema; if the rename didn't propagate, this FK becomes a
// no-op and bogus inserts would silently succeed. Verify both
// directions: a valid attempt referencing p2 inserts cleanly, and a
// bogus (proposal_id, topic_id) is rejected.
if _, err := db.Exec(
`INSERT INTO incorporation_attempts(
id, proposal_id, topic_id, source_path, base_source_sha,
approved_by, approved_at, created_at
) VALUES ('a1','p2','t1','docs/foo.md','cafef00d',
'u1', unixepoch(), unixepoch())`,
); err != nil {
t.Fatalf("valid incorporation_attempts insert: %v", err)
}
if _, err := db.Exec(
`INSERT INTO incorporation_attempts(
id, proposal_id, topic_id, source_path, base_source_sha,
approved_by, approved_at, created_at
) VALUES ('a2','does-not-exist','t1','docs/foo.md','cafef00d',
'u1', unixepoch(), unixepoch())`,
); err == nil {
t.Fatalf("expected composite FK violation for bogus proposal_id; the rebuild's ALTER TABLE ... RENAME must rewrite the FK in incorporation_attempts")
}
rows2, err := db.Query(`PRAGMA foreign_key_check(incorporation_attempts)`)
if err != nil {
t.Fatalf("foreign_key_check incorporation_attempts: %v", err)
}
if rows2.Next() {
t.Fatalf("incorporation_attempts foreign_key_check returned violations")
}
rows2.Close()
// 6. Re-applying the no-tx migration after a bookkeeping failure is
// idempotent. Simulate "body committed, schema_migrations insert failed"
// by deleting only the migration record, then run Migrate again against
// populated tables.
if _, err := db.Exec(
`DELETE FROM schema_migrations WHERE filename = '003_agent_runtime.sql'`,
); err != nil {
t.Fatalf("delete schema_migrations record: %v", err)
}
if err := collab.Migrate(db, collab.MigrationsFS); err != nil {
t.Fatalf("re-Migrate after bookkeeping loss: %v", err)
}
var count int
if err := db.QueryRow(
`SELECT COUNT(*) FROM incorporation_proposals WHERE topic_id = 't1'`,
).Scan(&count); err != nil {
t.Fatalf("count proposals after reapply: %v", err)
}
if count != 2 {
t.Fatalf("proposal count after reapply = %d, want 2", count)
}
}
go test ./internal/collab/ -run TestMigrate_003_AgentRuntime -v
Expected: FAIL — migration file does not exist yet.
Create internal/collab/migrations/003_agent_runtime.sql:
-- migrate:no-tx
-- Relax incorporation_proposals.proposed_by from NOT NULL to nullable, and
-- create agent_jobs.
--
-- The proposed_by relaxation requires SQLite's twelve-step table rebuild,
-- which in turn requires PRAGMA foreign_keys = OFF. That pragma is a no-op
-- inside a transaction, so this migration carries the -- migrate:no-tx
-- directive above and manages its own BEGIN/COMMIT.
--
-- This migration must be idempotent. The no-tx runner records
-- schema_migrations in a separate short transaction after the body executes,
-- so a successful body + failed bookkeeping leaves the body in a state where
-- it will be re-applied on next boot. Idempotency requirements:
-- * CREATE TABLE / CREATE INDEX use IF NOT EXISTS for the new objects.
-- * The 12-step rebuild is safe to re-run: after a successful first run,
-- incorporation_proposals already has the relaxed schema; redoing the
-- rebuild copies it through _new and lands at the same shape.
-- * `legacy_alter_table` is forced OFF so the rename rewrites foreign-key
-- references in dependent tables' schema (e.g. incorporation_attempts).
PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = OFF;
BEGIN;
CREATE TABLE incorporation_proposals_new (
id TEXT PRIMARY KEY,
topic_id TEXT NOT NULL,
revision_number INTEGER NOT NULL,
proposed_source TEXT NOT NULL,
base_source_sha TEXT NOT NULL,
proposed_by TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (topic_id) REFERENCES topics(id),
FOREIGN KEY (proposed_by) REFERENCES users(id)
);
INSERT INTO incorporation_proposals_new
SELECT id, topic_id, revision_number, proposed_source, base_source_sha,
proposed_by, created_at
FROM incorporation_proposals;
DROP TABLE incorporation_proposals;
ALTER TABLE incorporation_proposals_new RENAME TO incorporation_proposals;
CREATE UNIQUE INDEX IF NOT EXISTS incorporation_proposals_topic_rev
ON incorporation_proposals(topic_id, revision_number);
CREATE UNIQUE INDEX IF NOT EXISTS incorporation_proposals_id_topic
ON incorporation_proposals(id, topic_id);
-- agent_jobs — single source of truth for what the runtime is doing.
CREATE TABLE IF NOT EXISTS agent_jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
source_path TEXT NOT NULL,
topic_id TEXT,
persona_name TEXT,
status TEXT NOT NULL,
started_at INTEGER,
completed_at INTEGER,
exit_code INTEGER,
error_tail TEXT,
created_at INTEGER NOT NULL,
CHECK (status IN ('queued','running','succeeded','failed','timed_out')),
CHECK (
(kind = 'incorporate' AND topic_id IS NOT NULL AND persona_name IS NULL) OR
(kind = 'perspective' AND persona_name IS NOT NULL AND topic_id IS NULL)
),
CHECK ((status IN ('queued','running')) = (completed_at IS NULL)),
FOREIGN KEY (topic_id) REFERENCES topics(id)
);
CREATE INDEX IF NOT EXISTS agent_jobs_status ON agent_jobs(status);
CREATE INDEX IF NOT EXISTS agent_jobs_source_path ON agent_jobs(source_path, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS agent_jobs_one_inflight_source
ON agent_jobs(source_path)
WHERE status IN ('queued','running');
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;
go test ./internal/collab/ -run TestMigrate_003_AgentRuntime -v
Expected: PASS.
go test ./internal/collab/...
Expected: PASS.
git add internal/collab/migrations/003_agent_runtime.sql internal/collab/migrate_test.go
git commit -m "wiki-browser: collab — migration 003 relax proposed_by, create agent_jobs"
NewProposal.ProposedBy *stringFiles:
internal/collab/mutators.gointernal/collab/mutators_test.goNewProposal.ProposedBy becomes *string so callers can pass nil to record an Agent-authored proposal. The required-fields check loses that key. Human-driven proposals (when they exist post-#4) pass a non-nil pointer.
Add to internal/collab/mutators_test.go:
func TestInsertProposal_NullProposedBy(t *testing.T) {
s := openTestStore(t)
defer s.Close()
seedUser(t, s, "u1", "U1")
seedTopic(t, s, "t1", "docs/foo.md", `{"kind":"global"}`, "u1")
id, err := s.InsertProposal(collab.NewProposal{
ID: "p1", TopicID: "t1", RevisionNumber: 1,
ProposedSource: "new body",
BaseSourceSHA: "deadbeef",
ProposedBy: nil, // Agent proposal
})
if err != nil {
t.Fatalf("InsertProposal: %v", err)
}
if id != "p1" {
t.Fatalf("id = %s, want p1", id)
}
var got sql.NullString
if err := s.RawDBForTest().QueryRow(
`SELECT proposed_by FROM incorporation_proposals WHERE id = 'p1'`,
).Scan(&got); err != nil {
t.Fatalf("scan: %v", err)
}
if got.Valid {
t.Fatalf("proposed_by valid = true, want null")
}
}
Assume openTestStore, seedUser, seedTopic exist as test helpers — if they don't, add the obvious helpers using the existing public API.
go test ./internal/collab/ -run TestInsertProposal_NullProposedBy -v
Expected: compile error — ProposedBy is string, not *string.
In internal/collab/mutators.go, change NewProposal:
type NewProposal struct {
ID string
TopicID string
RevisionNumber int
ProposedSource string
BaseSourceSHA string
ProposedBy *string // nil = Agent-authored
}
Update InsertProposal:
func (s *Store) InsertProposal(p NewProposal) (string, error) {
if p.ID == "" || p.TopicID == "" || p.RevisionNumber < 1 ||
p.BaseSourceSHA == "" {
return "", fmt.Errorf("collab.InsertProposal: required fields missing")
}
err := s.send(func(db *sql.DB) error {
_, err := db.Exec(
`INSERT INTO incorporation_proposals(
id, topic_id, revision_number, proposed_source,
base_source_sha, proposed_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
p.ID, p.TopicID, p.RevisionNumber, p.ProposedSource,
p.BaseSourceSHA, p.ProposedBy,
)
return err
})
if err != nil {
return "", err
}
return p.ID, nil
}
(*string flows through database/sql as either the underlying value or NULL.)
InsertProposalSearch for callers:
git grep -n "InsertProposal\|NewProposal{" -- '*.go'
Update each ProposedBy: "..." to ProposedBy: ptr("...") using a small local helper, or use &someVar. If any test sets a literal string, change to a pointer.
Add this helper to internal/collab/mutators_test.go if multiple call sites need it:
func strPtr(s string) *string { return &s }
go test ./internal/collab/...
Expected: PASS.
git add internal/collab/mutators.go internal/collab/mutators_test.go
git commit -m "wiki-browser: collab — NewProposal.ProposedBy is *string (nil = Agent)"
agent_jobs mutators and readersFiles:
internal/collab/agent_jobs.gointernal/collab/agent_jobs_test.goAdds three lifecycle mutators (InsertJob, StartJob, CompleteJob), a sweep used at startup (SweepIncompleteJobs), and the reads the HTTP layer will need (ListJobsForSource, GetJob, HasInflightForSource). The migration's partial unique index is the DB-level invariant for one queued/running job per source_path; the service's in-memory map is only an optimization.
Create internal/collab/agent_jobs_test.go:
package collab_test
import (
"database/sql"
"testing"
"github.com/getorcha/wiki-browser/internal/collab"
)
func intPtr(i int) *int { return &i }
func TestAgentJobs_HappyPath(t *testing.T) {
s := openTestStore(t)
defer s.Close()
seedUser(t, s, "u1", "U1")
seedTopic(t, s, "t1", "docs/foo.md", `{"kind":"global"}`, "u1")
id := "j1"
if err := s.InsertJob(collab.NewJob{
ID: id, Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
}); err != nil {
t.Fatalf("InsertJob: %v", err)
}
if inflight, err := s.HasInflightForSource("docs/foo.md"); err != nil {
t.Fatalf("HasInflightForSource: %v", err)
} else if !inflight {
t.Fatalf("HasInflightForSource = false; want true")
}
if err := s.StartJob(id); err != nil {
t.Fatalf("StartJob: %v", err)
}
if err := s.CompleteJob(collab.CompleteJobInput{
ID: id, Status: "succeeded", ExitCode: intPtr(0), ErrorTail: "",
}); err != nil {
t.Fatalf("CompleteJob: %v", err)
}
if inflight, err := s.HasInflightForSource("docs/foo.md"); err != nil {
t.Fatalf("HasInflightForSource after complete: %v", err)
} else if inflight {
t.Fatalf("HasInflightForSource still true after complete")
}
jobs, err := s.ListJobsForSource("docs/foo.md", 10)
if err != nil {
t.Fatalf("ListJobsForSource: %v", err)
}
if len(jobs) != 1 {
t.Fatalf("len(jobs) = %d, want 1", len(jobs))
}
if jobs[0].Status != "succeeded" || jobs[0].ExitCode == nil || *jobs[0].ExitCode != 0 {
t.Fatalf("job: %+v", jobs[0])
}
}
func TestAgentJobs_PerspectiveKind(t *testing.T) {
s := openTestStore(t)
defer s.Close()
if err := s.InsertJob(collab.NewJob{
ID: "j2", Kind: "perspective", SourcePath: "docs/foo.md",
PersonaName: strPtr("CFO"),
}); err != nil {
t.Fatalf("InsertJob: %v", err)
}
}
func TestAgentJobs_InvalidKindOrMissingDiscriminator(t *testing.T) {
s := openTestStore(t)
defer s.Close()
if err := s.InsertJob(collab.NewJob{
ID: "j3", Kind: "incorporate", SourcePath: "docs/foo.md",
}); err == nil {
t.Fatalf("expected error: incorporate without topic_id")
}
if err := s.InsertJob(collab.NewJob{
ID: "j4", Kind: "perspective", SourcePath: "docs/foo.md",
}); err == nil {
t.Fatalf("expected error: perspective without persona_name")
}
if err := s.InsertJob(collab.NewJob{
ID: "j5", Kind: "other", SourcePath: "docs/foo.md",
}); err == nil {
t.Fatalf("expected error: unsupported kind")
}
}
func TestAgentJobs_RejectsSecondInflightForSameSource(t *testing.T) {
s := openTestStore(t)
defer s.Close()
seedUser(t, s, "u1", "U1")
seedTopic(t, s, "t1", "docs/foo.md", `{"kind":"global"}`, "u1")
if err := s.InsertJob(collab.NewJob{
ID: "j1", Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
}); err != nil {
t.Fatalf("InsertJob first: %v", err)
}
if err := s.InsertJob(collab.NewJob{
ID: "j2", Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
}); err == nil {
t.Fatalf("expected partial unique violation for second queued job on same source")
}
if err := s.CompleteJob(collab.CompleteJobInput{
ID: "j1", Status: "failed", ErrorTail: "done",
}); err != nil {
t.Fatalf("CompleteJob first: %v", err)
}
if err := s.InsertJob(collab.NewJob{
ID: "j3", Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
}); err != nil {
t.Fatalf("InsertJob after terminal state: %v", err)
}
}
func TestAgentJobs_SweepIncomplete(t *testing.T) {
s := openTestStore(t)
defer s.Close()
seedUser(t, s, "u1", "U1")
seedTopic(t, s, "t1", "docs/foo.md", `{"kind":"global"}`, "u1")
seedTopic(t, s, "t2", "docs/bar.md", `{"kind":"global"}`, "u1")
if err := s.InsertJob(collab.NewJob{
ID: "queued-job", Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
}); err != nil {
t.Fatalf("InsertJob queued: %v", err)
}
if err := s.InsertJob(collab.NewJob{
ID: "running-job", Kind: "incorporate", SourcePath: "docs/bar.md", TopicID: strPtr("t2"),
}); err != nil {
t.Fatalf("InsertJob running: %v", err)
}
if err := s.StartJob("running-job"); err != nil {
t.Fatalf("StartJob: %v", err)
}
n, err := s.SweepIncompleteJobs()
if err != nil {
t.Fatalf("SweepIncompleteJobs: %v", err)
}
if n != 2 {
t.Fatalf("swept %d, want 2", n)
}
// Both should now be 'failed' with the expected error_tail.
rows, err := s.RawDBForTest().Query(
`SELECT id, status, error_tail FROM agent_jobs ORDER BY id`,
)
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
type row struct {
id, status string
errTail sql.NullString
}
var got []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.status, &r.errTail); err != nil {
t.Fatalf("scan: %v", err)
}
got = append(got, r)
}
for _, r := range got {
if r.status != "failed" {
t.Fatalf("%s status = %s, want failed", r.id, r.status)
}
if !r.errTail.Valid || r.errTail.String != "server restarted while job in flight" {
t.Fatalf("%s error_tail = %#v", r.id, r.errTail)
}
}
}
go test ./internal/collab/ -run TestAgentJobs -v
Expected: compile error — NewJob, InsertJob, etc. do not exist.
RawDB() on StoreProduction code (the new wb-agent CLI and internal/agent.Service) needs to issue read-only queries the typed mutators don't expose. The existing RawDBForTest() exists but its name advertises "test-only." Add an aliased method without the ForTest suffix and keep the existing method for the test code that already uses it. In internal/collab/collab.go:
// RawDB returns the underlying *sql.DB. Callers may use it for read-only
// queries not covered by the typed APIs. Writes should always go through
// the typed mutators so they flow through the write funnel.
func (s *Store) RawDB() *sql.DB { return s.db }
(No test required — it's a one-line accessor.)
internal/collab/agent_jobs.gopackage collab
import (
"database/sql"
"fmt"
)
// NewJob is the input for InsertJob.
type NewJob struct {
ID string
Kind string // "incorporate" | "perspective"
SourcePath string
TopicID *string // non-nil iff Kind == "incorporate"
PersonaName *string // non-nil iff Kind == "perspective"
}
// AgentJob is the read shape returned by ListJobsForSource / GetJob.
type AgentJob struct {
ID string `json:"id"`
Kind string `json:"kind"`
SourcePath string `json:"source_path"`
TopicID *string `json:"topic_id,omitempty"`
PersonaName *string `json:"persona_name,omitempty"`
Status string `json:"status"`
StartedAt *int64 `json:"started_at,omitempty"`
CompletedAt *int64 `json:"completed_at,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
ErrorTail *string `json:"error_tail,omitempty"`
CreatedAt int64 `json:"created_at"`
}
// CompleteJobInput is the terminal-state write.
type CompleteJobInput struct {
ID string
Status string // "succeeded" | "failed" | "timed_out"
ExitCode *int // nil when no process exit code exists (spawn error/timeout)
ErrorTail string // ""=null in the column
}
func validateNewJob(j NewJob) error {
if j.ID == "" || j.SourcePath == "" {
return fmt.Errorf("collab.InsertJob: id/source_path required")
}
if _, err := ValidateSourcePath(j.SourcePath); err != nil {
return fmt.Errorf("collab.InsertJob: %w", err)
}
switch j.Kind {
case "incorporate":
if j.TopicID == nil || *j.TopicID == "" || j.PersonaName != nil {
return fmt.Errorf("collab.InsertJob: incorporate requires topic_id and no persona_name")
}
case "perspective":
if j.PersonaName == nil || *j.PersonaName == "" || j.TopicID != nil {
return fmt.Errorf("collab.InsertJob: perspective requires persona_name and no topic_id")
}
default:
return fmt.Errorf("collab.InsertJob: unsupported kind %q", j.Kind)
}
return nil
}
func (s *Store) InsertJob(j NewJob) error {
if err := validateNewJob(j); err != nil {
return err
}
return s.send(func(db *sql.DB) error {
_, err := db.Exec(
`INSERT INTO agent_jobs(
id, kind, source_path, topic_id, persona_name,
status, created_at
) VALUES (?, ?, ?, ?, ?, 'queued', unixepoch())`,
j.ID, j.Kind, j.SourcePath, j.TopicID, j.PersonaName,
)
return err
})
}
func (s *Store) StartJob(id string) error {
if id == "" {
return fmt.Errorf("collab.StartJob: id required")
}
return s.send(func(db *sql.DB) error {
res, err := db.Exec(
`UPDATE agent_jobs
SET status = 'running', started_at = unixepoch()
WHERE id = ? AND status = 'queued'`,
id,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return fmt.Errorf("collab.StartJob: %s not in queued state", id)
}
return nil
})
}
func (s *Store) CompleteJob(in CompleteJobInput) error {
if in.ID == "" {
return fmt.Errorf("collab.CompleteJob: id required")
}
switch in.Status {
case "succeeded", "failed", "timed_out":
default:
return fmt.Errorf("collab.CompleteJob: bad status %q", in.Status)
}
var errTail any
if in.ErrorTail != "" {
errTail = in.ErrorTail
}
return s.send(func(db *sql.DB) error {
res, err := db.Exec(
`UPDATE agent_jobs
SET status = ?, exit_code = ?, error_tail = ?,
completed_at = unixepoch()
WHERE id = ?
AND status IN ('queued','running')`,
in.Status, in.ExitCode, errTail, in.ID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return fmt.Errorf("collab.CompleteJob: %s not terminable", in.ID)
}
return nil
})
}
// SweepIncompleteJobs marks every queued or running job as failed. Returns
// the number of rows updated. Invoked at startup, before collab.Recover.
func (s *Store) SweepIncompleteJobs() (int, error) {
var n int64
err := s.send(func(db *sql.DB) error {
res, err := db.Exec(
`UPDATE agent_jobs
SET status = 'failed',
completed_at = unixepoch(),
error_tail = 'server restarted while job in flight'
WHERE status IN ('queued','running')`,
)
if err != nil {
return err
}
n, err = res.RowsAffected()
return err
})
return int(n), err
}
func (s *Store) HasInflightForSource(sourcePath string) (bool, error) {
if _, err := ValidateSourcePath(sourcePath); err != nil {
return false, err
}
var n int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM agent_jobs
WHERE source_path = ? AND status IN ('queued','running')`,
sourcePath,
).Scan(&n)
return n > 0, err
}
func (s *Store) ListJobsForSource(sourcePath string, limit int) ([]AgentJob, error) {
if _, err := ValidateSourcePath(sourcePath); err != nil {
return nil, err
}
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(
`SELECT id, kind, source_path, topic_id, persona_name, status,
started_at, completed_at, exit_code, error_tail, created_at
FROM agent_jobs
WHERE source_path = ?
ORDER BY created_at DESC
LIMIT ?`,
sourcePath, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AgentJob{}
for rows.Next() {
var j AgentJob
var topic, persona, errTail sql.NullString
var started, completed sql.NullInt64
var exitCode sql.NullInt64
if err := rows.Scan(
&j.ID, &j.Kind, &j.SourcePath, &topic, &persona, &j.Status,
&started, &completed, &exitCode, &errTail, &j.CreatedAt,
); err != nil {
return nil, err
}
if topic.Valid {
s := topic.String
j.TopicID = &s
}
if persona.Valid {
s := persona.String
j.PersonaName = &s
}
if started.Valid {
n := started.Int64
j.StartedAt = &n
}
if completed.Valid {
n := completed.Int64
j.CompletedAt = &n
}
if exitCode.Valid {
n := int(exitCode.Int64)
j.ExitCode = &n
}
if errTail.Valid {
s := errTail.String
j.ErrorTail = &s
}
out = append(out, j)
}
return out, rows.Err()
}
func (s *Store) GetJob(id string) (AgentJob, error) {
jobs, err := s.getJobByID(id)
if err != nil {
return AgentJob{}, err
}
return jobs, nil
}
// HasProposalForTopicSince reports whether any incorporation_proposals row
// exists for the given topic with created_at >= since. Used by agent.Service
// for the post-exit invariant check on incorporate jobs.
func (s *Store) HasProposalForTopicSince(topicID string, since int64) (bool, error) {
if topicID == "" {
return false, fmt.Errorf("collab.HasProposalForTopicSince: topic id required")
}
var n int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM incorporation_proposals
WHERE topic_id = ? AND created_at >= ?`,
topicID, since,
).Scan(&n)
return n > 0, err
}
func (s *Store) getJobByID(id string) (AgentJob, error) {
var j AgentJob
var topic, persona, errTail sql.NullString
var started, completed sql.NullInt64
var exitCode sql.NullInt64
err := s.db.QueryRow(
`SELECT id, kind, source_path, topic_id, persona_name, status,
started_at, completed_at, exit_code, error_tail, created_at
FROM agent_jobs WHERE id = ?`,
id,
).Scan(
&j.ID, &j.Kind, &j.SourcePath, &topic, &persona, &j.Status,
&started, &completed, &exitCode, &errTail, &j.CreatedAt,
)
if err != nil {
return AgentJob{}, err
}
if topic.Valid {
s := topic.String
j.TopicID = &s
}
if persona.Valid {
s := persona.String
j.PersonaName = &s
}
if started.Valid {
n := started.Int64
j.StartedAt = &n
}
if completed.Valid {
n := completed.Int64
j.CompletedAt = &n
}
if exitCode.Valid {
n := int(exitCode.Int64)
j.ExitCode = &n
}
if errTail.Valid {
s := errTail.String
j.ErrorTail = &s
}
return j, nil
}
go test ./internal/collab/ -run TestAgentJobs -v
Expected: PASS.
go test ./internal/collab/...
Expected: PASS.
git add internal/collab/collab.go internal/collab/agent_jobs.go internal/collab/agent_jobs_test.go
git commit -m "wiki-browser: collab — agent_jobs mutators, readers, RawDB, startup sweep"
Agent blockFiles:
internal/config/config.gointernal/config/config_test.gointernal/config/testdata/minimal.yamlinternal/config/testdata/valid.yamlAdds the new agent: block with defaults and startup validation. The validation checks that both binaries are resolvable; this turns deploy-time misconfiguration into a clear startup error.
Ordering note:
wb-agentis not built until Task 17 (make build). Until that lands, the integration path (runningwiki-browseragainst a config that uses the defaultwb_agent_bin) is broken —make runbetween Tasks 5 and 17 will fail-fast on missing-binary validation. Within this task and the next several, tests stubwb_agent_binto a sibling fake executable (viawriteExecutable) or stub theexecutablePathseam, so config tests remain green without the real binary.
Add to internal/config/config_test.go:
func TestConfig_AgentDefaults(t *testing.T) {
root := t.TempDir()
secret := writeTempFile(t, "client-secret\n")
wbAgentPath := writeExecutable(t, "fake-wb-agent")
claudePath := writeExecutable(t, "fake-claude")
yamlPath := writeTempFile(t, fmt.Sprintf(`
root: %q
auth:
public_base_url: "https://wiki.example.com"
google_client_id: "123.apps.googleusercontent.com"
google_client_secret_file: %q
allowed_emails: ["daniel@getorcha.com"]
agent:
author_name: "Orcha Agent"
author_email: "agent@orcha.local"
claude_bin: %q
wb_agent_bin: %q
`, root, secret, claudePath, wbAgentPath))
cfg, err := config.Load(yamlPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Agent.MaxConcurrentJobs != 1 {
t.Fatalf("MaxConcurrentJobs default = %d, want 1", cfg.Agent.MaxConcurrentJobs)
}
if cfg.Agent.IncorporateTimeout != 5*time.Minute {
t.Fatalf("IncorporateTimeout default = %v, want 5m", cfg.Agent.IncorporateTimeout)
}
if cfg.Agent.PerspectiveTimeout != 3*time.Minute {
t.Fatalf("PerspectiveTimeout default = %v, want 3m", cfg.Agent.PerspectiveTimeout)
}
if cfg.Agent.WBAgentBin != wbAgentPath {
t.Fatalf("WBAgentBin = %q, want %q", cfg.Agent.WBAgentBin, wbAgentPath)
}
}
func TestConfig_AgentMissingBinariesFail(t *testing.T) {
root := t.TempDir()
secret := writeTempFile(t, "client-secret\n")
yamlPath := writeTempFile(t, fmt.Sprintf(`
root: %q
auth:
public_base_url: "https://wiki.example.com"
google_client_id: "123.apps.googleusercontent.com"
google_client_secret_file: %q
allowed_emails: ["daniel@getorcha.com"]
agent:
author_name: "Orcha Agent"
author_email: "agent@orcha.local"
wb_agent_bin: "/does/not/exist/wb-agent"
`, root, secret))
if _, err := config.Load(yamlPath); err == nil {
t.Fatalf("expected error for missing wb_agent_bin")
}
}
func TestConfig_AgentRequiresAuthorFields(t *testing.T) {
root := t.TempDir()
secret := writeTempFile(t, "client-secret\n")
yamlPath := writeTempFile(t, fmt.Sprintf(`
root: %q
auth:
public_base_url: "https://wiki.example.com"
google_client_id: "123.apps.googleusercontent.com"
google_client_secret_file: %q
allowed_emails: ["daniel@getorcha.com"]
agent: {}
`, root, secret))
if _, err := config.Load(yamlPath); err == nil {
t.Fatalf("expected error for missing author_name/author_email")
}
}
// TestConfig_AgentDefaultWBAgentBin exercises the actual default branch for
// WBAgentBin — sibling-of-wiki-browser-binary resolution. Stubs the
// executablePath seam to point at a fake "wiki-browser" inside t.TempDir(),
// places a sibling "wb-agent" next to it, and asserts the default lands there.
// Without this test, the default branch is never exercised — the other tests
// pass wb_agent_bin explicitly because os.Executable() in a test binary
// resolves to /tmp/.../config.test, which has no sibling wb-agent.
func TestConfig_AgentDefaultWBAgentBin(t *testing.T) {
dir := t.TempDir()
wikiBrowserPath := filepath.Join(dir, "wiki-browser")
if err := os.WriteFile(wikiBrowserPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake wiki-browser: %v", err)
}
wbAgentPath := filepath.Join(dir, "wb-agent")
if err := os.WriteFile(wbAgentPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write sibling wb-agent: %v", err)
}
prev := config.SetExecutablePathForTest(func() (string, error) { return wikiBrowserPath, nil })
defer config.SetExecutablePathForTest(prev)
root := t.TempDir()
secret := writeTempFile(t, "client-secret\n")
claudePath := writeExecutable(t, "fake-claude")
yamlPath := writeTempFile(t, fmt.Sprintf(`
root: %q
auth:
public_base_url: "https://wiki.example.com"
google_client_id: "123.apps.googleusercontent.com"
google_client_secret_file: %q
allowed_emails: ["daniel@getorcha.com"]
agent:
author_name: "Orcha Agent"
author_email: "agent@orcha.local"
claude_bin: %q
`, root, secret, claudePath))
cfg, err := config.Load(yamlPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Agent.WBAgentBin != wbAgentPath {
t.Fatalf("default WBAgentBin = %q, want sibling %q", cfg.Agent.WBAgentBin, wbAgentPath)
}
}
Expose the seam from the config package — append to internal/config/config.go:
// SetExecutablePathForTest swaps the os.Executable resolver used by
// applyDefaults to compute the WBAgentBin default. Returns the previous value
// so tests can restore it via defer. Test-only; production code uses the
// package default initialized to os.Executable.
func SetExecutablePathForTest(fn func() (string, error)) func() (string, error) {
prev := executablePath
executablePath = fn
return prev
}
Test helpers — add to internal/config/config_test.go if missing:
func writeTempFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "config-test-*")
if err != nil { t.Fatalf("temp file: %v", err) }
if _, err := f.WriteString(content); err != nil { t.Fatalf("write: %v", err) }
_ = f.Close()
return f.Name()
}
func writeExecutable(t *testing.T, name string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write exec: %v", err)
}
return path
}
Update the existing config fixtures and tests so the new required agent:
block does not break unrelated config coverage:
internal/config/testdata/minimal.yaml and
internal/config/testdata/valid.yaml:agent:
author_name: "Orcha Agent"
author_email: "agent@orcha.local"
claude_bin: "/bin/sh"
wb_agent_bin: "/bin/sh"
time to internal/config/config_test.go imports.want value in TestLoad_valid with the expected Agent
defaults:Agent: config.Agent{
AuthorName: "Orcha Agent",
AuthorEmail: "agent@orcha.local",
ClaudeBin: "/bin/sh",
WBAgentBin: "/bin/sh",
MaxConcurrentJobs: 1,
IncorporateTimeout: 5 * time.Minute,
PerspectiveTimeout: 3 * time.Minute,
},
In tests that synthesize a config expected to load successfully
(TestLoad_devModeRelaxesOAuthFields, TestLoad_publicBaseURLNormalization,
and any other new success case), include an agent: block using
writeExecutable(t, ...) paths or /bin/sh.
Tests that intentionally fail before agent validation, such as missing root
or missing auth, may stay focused on their existing assertion. If validation
order changes during implementation, add a valid agent: block there too so
the test continues to assert the intended failure.
Step 2: Run the test to see it fail to compile
go test ./internal/config/ -run TestConfig_Agent -v
Expected: compile error — cfg.Agent does not exist.
internal/config/config.goAdd the Agent struct and field on Config:
type Config struct {
Listen string `yaml:"listen"`
Title string `yaml:"title"`
Root string `yaml:"root"`
Extensions []string `yaml:"extensions"`
IndexDB string `yaml:"index_db"`
CollabDB string `yaml:"collab_db"`
Exclude []string `yaml:"exclude"`
Auth Auth `yaml:"auth"`
Agent Agent `yaml:"agent"`
}
// Agent configures the Claude Code subprocess runtime. AuthorName/AuthorEmail
// are required (they appear on every Source-rewrite commit as the git author);
// the rest defaults to reasonable values for a single-Pi deployment.
type Agent struct {
AuthorName string `yaml:"author_name"`
AuthorEmail string `yaml:"author_email"`
ClaudeBin string `yaml:"claude_bin"`
WBAgentBin string `yaml:"wb_agent_bin"`
MaxConcurrentJobs int `yaml:"max_concurrent_jobs"`
IncorporateTimeout time.Duration `yaml:"incorporate_timeout"`
PerspectiveTimeout time.Duration `yaml:"perspective_timeout"`
LogDir string `yaml:"log_dir"`
}
Add time import. Extend applyDefaults:
// executablePath is a package-level seam over os.Executable so tests can stub
// the resolver used to compute the default WBAgentBin location. Tests that want
// to exercise the real default (sibling-of-wiki-browser-binary) replace this
// with a function that returns a path under their t.TempDir().
var executablePath = os.Executable
func (c *Config) applyDefaults() {
// ... existing defaults ...
if c.Agent.ClaudeBin == "" {
c.Agent.ClaudeBin = "claude"
}
if c.Agent.WBAgentBin == "" {
self, err := executablePath()
if err == nil {
c.Agent.WBAgentBin = filepath.Join(filepath.Dir(self), "wb-agent")
}
}
if c.Agent.MaxConcurrentJobs == 0 {
c.Agent.MaxConcurrentJobs = 1
}
if c.Agent.IncorporateTimeout == 0 {
c.Agent.IncorporateTimeout = 5 * time.Minute
}
if c.Agent.PerspectiveTimeout == 0 {
c.Agent.PerspectiveTimeout = 3 * time.Minute
}
}
Without the
executablePathseam,os.Executable()in ago testprocess returns the test binary path (e.g./tmp/go-build…/config.test), so the default points at a non-existent/tmp/.../wb-agentand validation always falls over. Tests would then sidestep by passingwb_agent_binexplicitly — at which point the default branch is never exercised. The seam lets one test exercise the real default; other tests can keep passing the path explicitly.
Extend validate — add after the existing auth validation:
if c.Agent.AuthorName == "" {
return fmt.Errorf("agent.author_name is required")
}
if c.Agent.AuthorEmail == "" {
return fmt.Errorf("agent.author_email is required")
}
if c.Agent.MaxConcurrentJobs < 1 {
return fmt.Errorf("agent.max_concurrent_jobs must be >= 1")
}
// Verify claude_bin resolves via PATH (or is an absolute path that exists).
if _, err := exec.LookPath(c.Agent.ClaudeBin); err != nil {
return fmt.Errorf("agent.claude_bin %q: %w", c.Agent.ClaudeBin, err)
}
// Verify wb_agent_bin exists at the specified path.
if c.Agent.WBAgentBin == "" {
return fmt.Errorf("agent.wb_agent_bin: cannot determine default; specify explicitly")
}
if _, err := os.Stat(c.Agent.WBAgentBin); err != nil {
return fmt.Errorf("agent.wb_agent_bin %q: %w", c.Agent.WBAgentBin, err)
}
if c.Agent.LogDir != "" {
if err := os.MkdirAll(c.Agent.LogDir, 0o755); err != nil {
return fmt.Errorf("agent.log_dir %q: %w", c.Agent.LogDir, err)
}
}
Add os/exec to imports.
go test ./internal/config/...
Expected: PASS.
git add internal/config/config.go internal/config/config_test.go
git commit -m "wiki-browser: config — Agent block with defaults + binary validation"
internal/agent — Job, RunResult, Runner interface, FakeRunnerFiles:
internal/agent/runner.gointernal/agent/runner_test.gointernal/agent/fake_runner.goThe substitutable boundary that lets the rest of the system test without spawning processes. Implementation comes in Task 7.
Create internal/agent/runner_test.go:
package agent_test
import (
"context"
"errors"
"testing"
"github.com/getorcha/wiki-browser/internal/agent"
)
func TestFakeRunner_CallsBackWithJob(t *testing.T) {
var seen agent.Job
r := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
seen = j
return agent.RunResult{ExitCode: 0}
})
job := agent.Job{
ID: "j1", Kind: "incorporate", SourcePath: "docs/foo.md",
TopicID: "t1", BaseSHA: "sha", RepoRoot: "/r", WBAgentPath: "/w",
}
res := r.Run(context.Background(), job)
if res.ExitCode != 0 {
t.Fatalf("exit = %d, want 0", res.ExitCode)
}
if seen.ID != "j1" {
t.Fatalf("seen.ID = %q, want j1", seen.ID)
}
}
func TestFakeRunner_PropagatesError(t *testing.T) {
want := errors.New("boom")
r := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
return agent.RunResult{Err: want}
})
res := r.Run(context.Background(), agent.Job{ID: "j2"})
if !errors.Is(res.Err, want) {
t.Fatalf("Err = %v, want %v", res.Err, want)
}
}
go test ./internal/agent/ -v
Expected: compile error — package does not exist.
internal/agent/runner.go// Package agent owns the Claude Code subprocess runtime: the queue, the
// Runner interface, and the agent_jobs lifecycle. The HTTP handlers and
// the main binary depend on this package; tests substitute FakeRunner so
// they never spawn a real claude.
package agent
import "context"
// Job is everything the runner needs to invoke an agent task. Note that
// timeout is NOT a field here — Runner.Run receives a context whose deadline
// is set by the caller (Service.run derives it from the per-kind config).
// This keeps the timeout discipline in one place and lets future Runners
// (e.g. v2 channels) honor it via the standard context contract.
type Job struct {
ID string
Kind string // "incorporate" | "perspective"
SourcePath string
TopicID string // populated when Kind == "incorporate"
BaseSHA string // populated when Kind == "incorporate"
PersonaName string // populated when Kind == "perspective"
SourceSHA string // populated when Kind == "perspective"
PersonaSHA string // populated when Kind == "perspective"
RepoRoot string // cfg.Root
WBAgentPath string // absolute path to the wb-agent binary
WikiRoot string // cwd for the subprocess (wiki-browser/)
LogPath string // when non-empty, runner streams full stderr to this file
}
// RunResult is what the runner returns. The service maps it onto an
// agent_jobs row in CompleteJob.
type RunResult struct {
ExitCode int // 0 on success; ignored when Err != nil
ErrorTail string // last 4 KiB of stderr
Err error // non-nil for spawn errors / timeouts
TimedOut bool // true when Err is a deadline-exceeded
}
// Runner is the substitution point. ClaudeCLIRunner is the production
// implementation; FakeRunner is for tests.
type Runner interface {
Run(ctx context.Context, j Job) RunResult
}
internal/agent/fake_runner.gopackage agent
import "context"
// FakeRunner runs a user-supplied function inline. Tests use it to simulate
// agent work (e.g. inserting an incorporation_proposals row directly via the
// collab store) without spawning a subprocess.
type FakeRunner struct {
fn func(context.Context, Job) RunResult
}
func NewFakeRunner(fn func(context.Context, Job) RunResult) *FakeRunner {
return &FakeRunner{fn: fn}
}
func (f *FakeRunner) Run(ctx context.Context, j Job) RunResult {
return f.fn(ctx, j)
}
go test ./internal/agent/ -v
Expected: PASS.
git add internal/agent/runner.go internal/agent/runner_test.go internal/agent/fake_runner.go
git commit -m "wiki-browser: agent — Runner interface and FakeRunner"
internal/agent — ClaudeCLIRunnerFiles:
internal/agent/claude_cli_runner.gointernal/agent/claude_cli_runner_test.goThe production runner. Spawns claude -p, sets Setpgid, installs a cmd.Cancel that signals the process group, and captures the last 4 KiB of stderr. Tests use a shell-script substitute so they exercise real subprocess semantics — including the process-group kill path — without depending on Claude Code being installed in CI.
Create internal/agent/claude_cli_runner_test.go:
package agent_test
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/getorcha/wiki-browser/internal/agent"
)
func writeFakeClaude(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fake-claude")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatalf("write fake-claude: %v", err)
}
return path
}
func writeFakeWBAgent(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "wb-agent")
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake wb-agent: %v", err)
}
return path
}
// runCtx returns a context with `d` deadline tied to the test. ClaudeCLIRunner
// no longer carries an internal context.WithTimeout — the caller (Service.run
// in production, the test here) sets the deadline via the context itself.
func runCtx(t *testing.T, d time.Duration) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), d)
t.Cleanup(cancel)
return ctx
}
func TestClaudeCLIRunner_Success(t *testing.T) {
claudeBin := writeFakeClaude(t, `exit 0`)
r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{ClaudeBin: claudeBin})
res := r.Run(runCtx(t, 5*time.Second), agent.Job{
ID: "j1", Kind: "incorporate", SourcePath: "docs/foo.md",
WikiRoot: t.TempDir(),
WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
})
if res.Err != nil {
t.Fatalf("Err = %v", res.Err)
}
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
}
}
func TestClaudeCLIRunner_NonZeroExit(t *testing.T) {
claudeBin := writeFakeClaude(t, `echo "boom" >&2; exit 7`)
r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{ClaudeBin: claudeBin})
res := r.Run(runCtx(t, 5*time.Second), agent.Job{
ID: "j2", Kind: "incorporate", SourcePath: "docs/foo.md",
WikiRoot: t.TempDir(),
WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
})
if res.Err != nil {
t.Fatalf("Err = %v", res.Err)
}
if res.ExitCode != 7 {
t.Fatalf("ExitCode = %d, want 7", res.ExitCode)
}
if !strings.Contains(res.ErrorTail, "boom") {
t.Fatalf("ErrorTail = %q, want to contain boom", res.ErrorTail)
}
}
func TestClaudeCLIRunner_Timeout(t *testing.T) {
// Spawn a child sleep so the timeout test exercises process-group kill.
claudeBin := writeFakeClaude(t, `
sh -c 'sleep 30' &
CHILD=$!
sleep 30
kill $CHILD 2>/dev/null
`)
r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{ClaudeBin: claudeBin})
start := time.Now()
res := r.Run(runCtx(t, 500*time.Millisecond), agent.Job{
ID: "j3", Kind: "incorporate", SourcePath: "docs/foo.md",
WikiRoot: t.TempDir(),
WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
})
elapsed := time.Since(start)
if !res.TimedOut {
t.Fatalf("TimedOut = false; want true (Err = %v)", res.Err)
}
// Generous upper bound: timeout (500ms) + WaitDelay (5s) + slack.
if elapsed > 8*time.Second {
t.Fatalf("Run took %v, expected <8s (WaitDelay should have triggered)", elapsed)
}
}
func TestClaudeCLIRunner_ClaudeMissing(t *testing.T) {
r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{
ClaudeBin: "/does/not/exist/claude",
})
res := r.Run(runCtx(t, 5*time.Second), agent.Job{
ID: "j4", Kind: "incorporate", SourcePath: "docs/foo.md",
WikiRoot: t.TempDir(),
WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
})
if res.Err == nil {
t.Fatalf("Err = nil; want non-nil (binary missing)")
}
if !errors.Is(res.Err, syscall.ENOENT) && !strings.Contains(res.Err.Error(), "no such file") {
t.Fatalf("Err = %v, want ENOENT-like", res.Err)
}
}
// TestClaudeCLIRunner_LogPathCapturesFullStderr verifies that when LogPath is
// set, the full stderr stream is captured to the file (not just the 4 KiB
// ErrorTail). Generates >stderrTailBytes of output so the tail truncates,
// then asserts the file holds the full content.
func TestClaudeCLIRunner_LogPathCapturesFullStderr(t *testing.T) {
// Emit ~8 KiB of stderr so the tail (4 KiB) is a strict subset.
claudeBin := writeFakeClaude(t, `
for i in $(seq 1 200); do
printf 'line-%04d %s\n' "$i" "padding-padding-padding-padding-padding" 1>&2
done
exit 0
`)
r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{ClaudeBin: claudeBin})
logPath := filepath.Join(t.TempDir(), "job.log")
res := r.Run(runCtx(t, 5*time.Second), agent.Job{
ID: "j5", Kind: "incorporate", SourcePath: "docs/foo.md",
WikiRoot: t.TempDir(),
WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
LogPath: logPath,
})
if res.Err != nil {
t.Fatalf("Err = %v", res.Err)
}
body, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log: %v", err)
}
if !strings.Contains(string(body), "line-0001 ") {
t.Fatalf("log file is missing early lines (truncation upstream of file)")
}
if !strings.Contains(string(body), "line-0200 ") {
t.Fatalf("log file is missing final lines")
}
if !strings.Contains(res.ErrorTail, "line-0200 ") {
t.Fatalf("ErrorTail should still contain the final lines")
}
if strings.Contains(res.ErrorTail, "line-0001 ") {
t.Fatalf("ErrorTail unexpectedly contained the first line (tail should have truncated)")
}
}
go test ./internal/agent/ -run TestClaudeCLI -v
Expected: compile error — ClaudeCLIRunner does not exist.
internal/agent/claude_cli_runner.gopackage agent
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
const stderrTailBytes = 4 << 10 // 4 KiB
type ClaudeCLIRunnerOptions struct {
ClaudeBin string // resolved path or PATH-resolvable name
}
type ClaudeCLIRunner struct {
bin string
}
func NewClaudeCLIRunner(opts ClaudeCLIRunnerOptions) *ClaudeCLIRunner {
return &ClaudeCLIRunner{bin: opts.ClaudeBin}
}
func (r *ClaudeCLIRunner) Run(ctx context.Context, j Job) RunResult {
prompt := buildPrompt(j)
// The caller owns the deadline. Service.run wraps the per-kind timeout
// into ctx via context.WithTimeout before calling Run, so we use ctx
// directly. Future Runners (v2 channels) honor the timeout the same way
// without needing a Job-level field.
cmd := exec.CommandContext(ctx, r.bin,
"-p", prompt,
"--dangerously-skip-permissions",
)
cmd.Dir = j.WikiRoot
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
// Signal the whole process group. The leading negative sign means
// "process group id" per kill(2). If the group is already dead
// (race between deadline firing and signal delivery), syscall.Kill
// returns ESRCH; return os.ErrProcessDone so Go treats the cancel
// as a no-op rather than surfacing ESRCH as the cmd.Run error
// (which would mis-classify a timeout as a spawn failure).
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil {
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
return nil
}
cmd.WaitDelay = 5 * time.Second
// Open the log file (if any) before invoking cmd.Run so a panic in the
// writer pipeline still triggers the deferred Close. stderrTail is the
// in-memory 4 KiB tail; stderrSink writes to both when LogPath is set.
var stderrTail bytes.Buffer
var stderrSink io.Writer = &stderrTail
if j.LogPath != "" {
f, err := os.OpenFile(j.LogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return RunResult{Err: fmt.Errorf("open log file %s: %w", j.LogPath, err)}
}
defer f.Close()
stderrSink = io.MultiWriter(f, &stderrTail)
}
cmd.Stderr = stderrSink
// Stdout is captured but not used; default human-readable output.
cmd.Stdout = nil
err := cmd.Run()
tail := tailBytes(stderrTail.Bytes(), stderrTailBytes)
res := RunResult{ErrorTail: tail}
// Timeout check FIRST: when the context deadline fires, cmd.Cancel
// SIGTERMs the group and cmd.Wait may return an ExitError reflecting
// the kill, or — on the ESRCH race — the cancel error itself. In every
// timeout case ctx.Err() is DeadlineExceeded, so we can disambiguate
// before falling through to exit-vs-spawn classification.
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
res.TimedOut = true
res.Err = fmt.Errorf("context deadline exceeded")
return res
}
if err == nil {
res.ExitCode = 0
return res
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
return res
}
// Spawn errors (binary missing, permission denied, etc.).
res.Err = err
return res
}
func tailBytes(b []byte, n int) string {
if len(b) <= n {
return strings.TrimSpace(string(b))
}
return strings.TrimSpace(string(b[len(b)-n:]))
}
// buildPrompt formats the prompt body the agent sees. The skill's first
// paragraph teaches it how to read this block.
func buildPrompt(j Job) string {
var b strings.Builder
switch j.Kind {
case "incorporate":
fmt.Fprintf(&b, "Use the wb-incorporate skill.\n\n")
fmt.Fprintf(&b, "Job ID: %s\n", j.ID)
fmt.Fprintf(&b, "Topic ID: %s\n", j.TopicID)
fmt.Fprintf(&b, "Source path: %s\n", j.SourcePath)
fmt.Fprintf(&b, "Base source SHA: %s\n", j.BaseSHA)
fmt.Fprintf(&b, "Repo root: %s\n", j.RepoRoot)
fmt.Fprintf(&b, "wb-agent path: %s\n", j.WBAgentPath)
case "perspective":
fmt.Fprintf(&b, "Use the wb-perspective skill.\n\n")
fmt.Fprintf(&b, "Job ID: %s\n", j.ID)
fmt.Fprintf(&b, "Source path: %s\n", j.SourcePath)
fmt.Fprintf(&b, "Persona name: %s\n", j.PersonaName)
fmt.Fprintf(&b, "Source SHA: %s\n", j.SourceSHA)
fmt.Fprintf(&b, "Persona SHA: %s\n", j.PersonaSHA)
fmt.Fprintf(&b, "Repo root: %s\n", j.RepoRoot)
fmt.Fprintf(&b, "wb-agent path: %s\n", j.WBAgentPath)
}
fmt.Fprintf(&b, "\nWhen done, exit 0. On any unrecoverable error, exit non-zero — the wiki-browser server will surface stderr to the operator.\n")
return b.String()
}
go test ./internal/agent/ -run TestClaudeCLI -v -timeout=60s
Expected: PASS for all four cases. The timeout case takes ~5.5s.
git add internal/agent/claude_cli_runner.go internal/agent/claude_cli_runner_test.go
git commit -m "wiki-browser: agent — ClaudeCLIRunner with process-group teardown"
internal/agent — Service (queue + lifecycle)Files:
internal/agent/service.gointernal/agent/service_test.goThe piece that callers actually invoke. Owns the in-memory queue (per-Source serialization + global cap), writes agent_jobs rows on each lifecycle transition, validates inputs, and rejects duplicates with a typed error. Uses FakeRunner for tests.
Create internal/agent/service_test.go:
package agent_test
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"github.com/getorcha/wiki-browser/internal/agent"
"github.com/getorcha/wiki-browser/internal/collab"
)
// openTestStoreAgent returns a fresh collab.Store with a seeded user + topic.
// Test helper kept local to avoid pulling collab's internal test helpers.
func openTestStoreAgent(t *testing.T) (*collab.Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "collab.db")
s, err := collab.Open(collab.Config{Path: path})
if err != nil {
t.Fatalf("collab.Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
if _, err := s.RawDBForTest().Exec(
`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
); err != nil {
t.Fatalf("seed user: %v", err)
}
topicID := "t1"
if _, err := s.RawDBForTest().Exec(
`INSERT INTO topics(id, source_path, anchor, created_at, created_by, updated_at)
VALUES (?, 'docs/foo.md', '{"kind":"global"}', unixepoch(), 'u1', unixepoch())`,
topicID,
); err != nil {
t.Fatalf("seed topic: %v", err)
}
return s, topicID
}
func TestService_Submit_Succeeds(t *testing.T) {
store, topicID := openTestStoreAgent(t)
done := make(chan struct{})
runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
// Simulate the agent inserting a proposal row.
if _, err := store.InsertProposal(collab.NewProposal{
ID: "p1", TopicID: topicID, RevisionNumber: 1,
ProposedSource: "new body", BaseSourceSHA: "sha", ProposedBy: nil,
}); err != nil {
t.Errorf("InsertProposal: %v", err)
}
close(done)
return agent.RunResult{ExitCode: 0}
})
svc := agent.NewService(agent.ServiceConfig{
Store: store, Runner: runner, MaxConcurrentJobs: 1,
IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
RepoRoot: t.TempDir(), WikiRoot: t.TempDir(), WBAgentPath: "/wb-agent",
})
t.Cleanup(svc.Stop)
jobID, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
})
if err != nil {
t.Fatalf("Submit: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatalf("runner never invoked")
}
// Allow CompleteJob to land.
require_succeeded := func() bool {
job, err := store.GetJob(jobID)
if err != nil { t.Fatalf("GetJob: %v", err) }
return job.Status == "succeeded"
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) && !require_succeeded() {
time.Sleep(20 * time.Millisecond)
}
if !require_succeeded() {
t.Fatalf("status not succeeded")
}
}
func TestService_Submit_409OnConcurrentSameSource(t *testing.T) {
store, topicID := openTestStoreAgent(t)
gate := make(chan struct{})
runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
<-gate
return agent.RunResult{ExitCode: 0}
})
svc := agent.NewService(agent.ServiceConfig{
Store: store, Runner: runner, MaxConcurrentJobs: 5,
IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
})
t.Cleanup(func() { close(gate); svc.Stop() })
if _, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
}); err != nil {
t.Fatalf("first Submit: %v", err)
}
// Allow the first job to enter 'running'.
time.Sleep(50 * time.Millisecond)
_, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
})
if !errors.Is(err, agent.ErrInflight) {
t.Fatalf("second Submit err = %v, want ErrInflight", err)
}
}
func TestService_RespectsGlobalCap(t *testing.T) {
store, topicID := openTestStoreAgent(t)
// Seed a second topic on a different source so per-source serialization
// is not what gates us.
if _, err := store.RawDBForTest().Exec(
`INSERT INTO topics(id, source_path, anchor, created_at, created_by, updated_at)
VALUES ('t2','docs/bar.md','{"kind":"global"}', unixepoch(), 'u1', unixepoch())`,
); err != nil {
t.Fatalf("seed t2: %v", err)
}
var mu sync.Mutex
var concurrent, peak int
gate := make(chan struct{})
runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
mu.Lock(); concurrent++; if concurrent > peak { peak = concurrent }; mu.Unlock()
<-gate
mu.Lock(); concurrent--; mu.Unlock()
return agent.RunResult{ExitCode: 0}
})
svc := agent.NewService(agent.ServiceConfig{
Store: store, Runner: runner, MaxConcurrentJobs: 1,
IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
})
t.Cleanup(func() { close(gate); svc.Stop() })
if _, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
}); err != nil {
t.Fatalf("Submit 1: %v", err)
}
if _, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/bar.md", TopicID: "t2", BaseSHA: "sha",
}); err != nil {
t.Fatalf("Submit 2: %v", err)
}
time.Sleep(150 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if peak > 1 {
t.Fatalf("peak concurrency = %d, want <= 1", peak)
}
}
func TestService_FailureRecordsErrorTail(t *testing.T) {
store, topicID := openTestStoreAgent(t)
runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
return agent.RunResult{ExitCode: 9, ErrorTail: "boom"}
})
svc := agent.NewService(agent.ServiceConfig{
Store: store, Runner: runner, MaxConcurrentJobs: 1,
IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
})
t.Cleanup(svc.Stop)
jobID, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
})
if err != nil {
t.Fatalf("Submit: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
j, err := store.GetJob(jobID)
if err == nil && j.Status == "failed" && j.ErrorTail != nil && *j.ErrorTail == "boom" {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("job did not reach failed state with expected error_tail")
}
func TestService_TimeoutLeavesExitCodeNull(t *testing.T) {
store, topicID := openTestStoreAgent(t)
runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
return agent.RunResult{TimedOut: true, ErrorTail: "deadline"}
})
svc := agent.NewService(agent.ServiceConfig{
Store: store, Runner: runner, MaxConcurrentJobs: 1,
IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
})
t.Cleanup(svc.Stop)
jobID, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
})
if err != nil {
t.Fatalf("Submit: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
j, err := store.GetJob(jobID)
if err == nil && j.Status == "timed_out" {
if j.ExitCode != nil {
t.Fatalf("ExitCode = %v, want nil for timeout", *j.ExitCode)
}
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("job did not reach timed_out state")
}
func TestService_PostExitNoProposalIsFailure(t *testing.T) {
store, topicID := openTestStoreAgent(t)
runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
// exit 0 but write no proposal
return agent.RunResult{ExitCode: 0}
})
svc := agent.NewService(agent.ServiceConfig{
Store: store, Runner: runner, MaxConcurrentJobs: 1,
IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
})
t.Cleanup(svc.Stop)
jobID, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
})
if err != nil {
t.Fatalf("Submit: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
j, err := store.GetJob(jobID)
if err == nil && j.Status == "failed" {
if j.ErrorTail == nil || *j.ErrorTail == "" {
t.Fatalf("error_tail empty; expected post-exit-no-proposal message")
}
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("job did not reach failed state")
}
func TestService_PreStartProposalDoesNotSatisfyPostExitCheck(t *testing.T) {
store, topicID := openTestStoreAgent(t)
gate := make(chan struct{})
var closeGate sync.Once
var calls int
runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
calls++
if calls == 1 {
<-gate // hold the global slot so the incorporate job stays queued
}
return agent.RunResult{ExitCode: 0}
})
svc := agent.NewService(agent.ServiceConfig{
Store: store, Runner: runner, MaxConcurrentJobs: 1,
IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
})
t.Cleanup(func() { closeGate.Do(func() { close(gate) }); svc.Stop() })
if _, err := svc.Submit(agent.SubmitInput{
Kind: "perspective", SourcePath: "docs/block.md",
PersonaName: "CFO", SourceSHA: "source-sha", PersonaSHA: "persona-sha",
}); err != nil {
t.Fatalf("Submit blocker: %v", err)
}
jobID, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
})
if err != nil {
t.Fatalf("Submit incorporate: %v", err)
}
// This row exists before StartJob runs for jobID. The post-exit check
// must use agent_jobs.started_at and reject crediting this old proposal.
if _, err := store.RawDBForTest().Exec(
`INSERT INTO incorporation_proposals(
id, topic_id, revision_number, proposed_source, base_source_sha,
proposed_by, created_at
) VALUES ('prestart', ?, 1, 'body', 'sha', NULL, 1)`,
topicID,
); err != nil {
t.Fatalf("insert pre-start proposal: %v", err)
}
closeGate.Do(func() { close(gate) })
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
j, err := store.GetJob(jobID)
if err == nil && j.Status == "failed" {
if j.ErrorTail == nil || *j.ErrorTail == "" {
t.Fatalf("error_tail empty; expected no post-start proposal message")
}
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("job credited a pre-start proposal or did not finish")
}
go test ./internal/agent/ -run TestService -v
Expected: compile error — agent.Service does not exist.
internal/agent/service.gopackage agent
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
"github.com/getorcha/wiki-browser/internal/collab"
)
var (
ErrInflight = errors.New("agent: another job is already in flight for this source")
ErrMissingSourcePath = errors.New("agent: source_path is required")
ErrUnsupportedKind = errors.New("agent: unsupported kind")
ErrMissingIncorporateFields = errors.New("agent: incorporate requires topic_id and base_sha")
ErrMissingPerspectiveFields = errors.New("agent: perspective requires persona_name, source_sha, persona_sha")
)
type ServiceConfig struct {
Store *collab.Store
Runner Runner
MaxConcurrentJobs int
IncorporateTimeout time.Duration
PerspectiveTimeout time.Duration
RepoRoot string // cfg.Root
WikiRoot string // wiki-browser project dir
WBAgentPath string // resolved wb-agent binary
LogDir string // optional; per-job stderr files go here when set
// AuthorName/AuthorEmail are the git identity used on Source-rewrite
// commits. They live on the Service (not as loose config) so the future
// #4 approval handler reads them through agent.Service rather than
// re-reading config, which keeps #4's seam stable across config shape
// changes. Required per the decisions log — the validate step in
// internal/config enforces this.
AuthorName string
AuthorEmail string
}
type SubmitInput struct {
Kind string // "incorporate" | "perspective"
SourcePath string
TopicID string // required when Kind == "incorporate"
BaseSHA string // required when Kind == "incorporate"
PersonaName string // required when Kind == "perspective"
SourceSHA string // required when Kind == "perspective"
PersonaSHA string // required when Kind == "perspective"
}
type Service struct {
cfg ServiceConfig
mu sync.Mutex
inflight map[string]struct{} // source_path → presence
globalSem chan struct{}
wg sync.WaitGroup
stopCtx context.Context
stopCancel context.CancelFunc
}
func NewService(cfg ServiceConfig) *Service {
if cfg.MaxConcurrentJobs < 1 {
cfg.MaxConcurrentJobs = 1
}
stopCtx, stopCancel := context.WithCancel(context.Background())
return &Service{
cfg: cfg,
inflight: map[string]struct{}{},
globalSem: make(chan struct{}, cfg.MaxConcurrentJobs),
stopCtx: stopCtx,
stopCancel: stopCancel,
}
}
// Stop cancels all in-flight jobs and waits for them. Idempotent.
func (s *Service) Stop() {
s.stopCancel()
s.wg.Wait()
}
// AuthorIdentity returns the git author identity to stamp on Source-rewrite
// commits. Consumed by #4's proposal-approval handler when it constructs an
// IncorporateInput; centralized here so a future config-shape change is
// invisible to callers.
func (s *Service) AuthorIdentity() (name, email string) {
return s.cfg.AuthorName, s.cfg.AuthorEmail
}
func (s *Service) Submit(in SubmitInput) (string, error) {
if err := s.validate(in); err != nil {
return "", err
}
// Two-layer in-flight gate. The in-memory map covers the common case
// fast (no DB hit inside one process). The partial unique index on
// agent_jobs(source_path) WHERE status IN ('queued','running') is the
// hard invariant across processes and bypassing code paths; InsertJob
// errors are mapped back to ErrInflight below when that index fires.
s.mu.Lock()
if _, ok := s.inflight[in.SourcePath]; ok {
s.mu.Unlock()
return "", ErrInflight
}
jobID := uuid.NewString()
s.inflight[in.SourcePath] = struct{}{}
s.mu.Unlock()
if inflight, err := s.cfg.Store.HasInflightForSource(in.SourcePath); err != nil {
s.mu.Lock()
delete(s.inflight, in.SourcePath)
s.mu.Unlock()
return "", fmt.Errorf("agent.Service: HasInflightForSource: %w", err)
} else if inflight {
s.mu.Lock()
delete(s.inflight, in.SourcePath)
s.mu.Unlock()
return "", ErrInflight
}
job := collab.NewJob{
ID: jobID, Kind: in.Kind, SourcePath: in.SourcePath,
}
switch in.Kind {
case "incorporate":
t := in.TopicID
job.TopicID = &t
case "perspective":
p := in.PersonaName
job.PersonaName = &p
}
if err := s.cfg.Store.InsertJob(job); err != nil {
s.mu.Lock(); delete(s.inflight, in.SourcePath); s.mu.Unlock()
if inflight, checkErr := s.cfg.Store.HasInflightForSource(in.SourcePath); checkErr == nil && inflight {
return "", ErrInflight
}
return "", fmt.Errorf("agent.Service: InsertJob: %w", err)
}
s.wg.Add(1)
go s.run(jobID, in)
return jobID, nil
}
func (s *Service) validate(in SubmitInput) error {
if in.SourcePath == "" {
return ErrMissingSourcePath
}
switch in.Kind {
case "incorporate":
if in.TopicID == "" || in.BaseSHA == "" {
return ErrMissingIncorporateFields
}
case "perspective":
if in.PersonaName == "" || in.SourceSHA == "" || in.PersonaSHA == "" {
return ErrMissingPerspectiveFields
}
default:
return fmt.Errorf("%w: %q", ErrUnsupportedKind, in.Kind)
}
return nil
}
func (s *Service) run(jobID string, in SubmitInput) {
defer s.wg.Done()
defer func() {
s.mu.Lock()
delete(s.inflight, in.SourcePath)
s.mu.Unlock()
}()
// Global concurrency gate. Sleep until a slot is free or the service stops.
select {
case s.globalSem <- struct{}{}:
case <-s.stopCtx.Done():
_ = s.cfg.Store.CompleteJob(collab.CompleteJobInput{
ID: jobID, Status: "failed", ErrorTail: "service shutting down",
})
return
}
defer func() { <-s.globalSem }()
if err := s.cfg.Store.StartJob(jobID); err != nil {
slog.Warn("agent: StartJob failed", "job_id", jobID, "err", err)
// Try to mark failed so the row reaches a terminal state.
_ = s.cfg.Store.CompleteJob(collab.CompleteJobInput{
ID: jobID, Status: "failed", ErrorTail: "StartJob: " + err.Error(),
})
return
}
timeout := s.cfg.IncorporateTimeout
if in.Kind == "perspective" {
timeout = s.cfg.PerspectiveTimeout
}
job := Job{
ID: jobID, Kind: in.Kind, SourcePath: in.SourcePath,
TopicID: in.TopicID, BaseSHA: in.BaseSHA,
PersonaName: in.PersonaName, SourceSHA: in.SourceSHA, PersonaSHA: in.PersonaSHA,
RepoRoot: s.cfg.RepoRoot, WikiRoot: s.cfg.WikiRoot, WBAgentPath: s.cfg.WBAgentPath,
}
if s.cfg.LogDir != "" {
job.LogPath = filepath.Join(s.cfg.LogDir, jobID+".log")
}
// Per-kind timeout is enforced via the context passed to Runner.Run.
// Service owns this here rather than the Runner so future Runner
// implementations (v2 channels, mock servers) honor the timeout
// uniformly via the standard context contract.
jobCtx, jobCancel := context.WithTimeout(s.stopCtx, timeout)
defer jobCancel()
start := time.Now()
res := s.cfg.Runner.Run(jobCtx, job)
dur := time.Since(start)
status, errTail := s.classify(res, in, jobID)
// Only an actual process exit produces a meaningful exit code. Spawn
// errors and timeouts leave it nil so the log line mirrors the DB row
// (CompleteJobInput.ExitCode is *int).
var exitCode *int
if res.Err == nil && !res.TimedOut {
exitCode = &res.ExitCode
}
slog.Info("agent: job complete",
"job_id", jobID, "kind", in.Kind, "source_path", in.SourcePath,
"duration_ms", dur.Milliseconds(), "status", status,
"exit_code", exitCode,
)
if err := s.cfg.Store.CompleteJob(collab.CompleteJobInput{
ID: jobID, Status: status, ExitCode: exitCode, ErrorTail: errTail,
}); err != nil {
slog.Warn("agent: CompleteJob failed", "job_id", jobID, "err", err)
}
}
// classify maps a RunResult onto (status, error_tail). It also runs the
// post-exit invariant check for incorporate jobs ("did a proposal row
// appear?"). Per #3's contract, deeper checks live in #4/#5.
func (s *Service) classify(res RunResult, in SubmitInput, jobID string) (string, string) {
if res.TimedOut {
return "timed_out", firstNonEmpty(res.ErrorTail, "timed out")
}
if res.Err != nil {
return "failed", "agent unreachable: " + res.Err.Error()
}
if res.ExitCode != 0 {
return "failed", firstNonEmpty(res.ErrorTail, fmt.Sprintf("exit %d", res.ExitCode))
}
if in.Kind == "incorporate" {
if err := s.assertProposalCreated(in.TopicID, jobID); err != nil {
return "failed", err.Error()
}
}
return "succeeded", ""
}
// assertProposalCreated checks that the agent produced at least one
// incorporation_proposals row for the given topic since the job started.
// Uses the agent_jobs row's started_at (not created_at) as the lower bound,
// per the spec: a job may sit in `queued` for arbitrary time before running,
// during which a prior proposal for the same topic could land via another
// path (e.g. a direct wb-agent invocation). Using created_at would
// erroneously credit those to this job. StartedAt is non-null at this point
// because StartJob always runs before Runner.Run.
func (s *Service) assertProposalCreated(topicID, jobID string) error {
jobRow, err := s.cfg.Store.GetJob(jobID)
if err != nil {
return fmt.Errorf("agent exited 0; post-exit GetJob failed: %w", err)
}
if jobRow.StartedAt == nil {
// Invariant violation: classify() runs after Runner.Run, which runs
// after StartJob; reaching here means the agent_jobs lifecycle is
// broken. Treat as failure rather than silently skipping the check.
return errors.New("agent exited 0; post-exit check found no started_at on the job row")
}
has, err := s.cfg.Store.HasProposalForTopicSince(topicID, *jobRow.StartedAt)
if err != nil {
return fmt.Errorf("agent exited 0; post-exit query failed: %w", err)
}
if !has {
return errors.New("agent exited 0 but produced no proposal")
}
return nil
}
func firstNonEmpty(parts ...string) string {
for _, p := range parts {
if p != "" {
return p
}
}
return ""
}
go test ./internal/agent/ -run TestService -v -timeout=30s
Expected: PASS for all service tests.
git add internal/agent/service.go internal/agent/service_test.go
git commit -m "wiki-browser: agent — Service with per-source queue, global cap, lifecycle"
cmd/wb-agent — subcommand dispatcherFiles:
cmd/wb-agent/main.gocmd/wb-agent/main_test.goThe CLI scaffold. Subcommand handlers come in Tasks 10-13.
Create cmd/wb-agent/main_test.go:
package main
import (
"bytes"
"strings"
"testing"
)
func TestDispatch_UnknownSubcommand(t *testing.T) {
var stderr bytes.Buffer
code := dispatch([]string{"wb-agent", "nope"}, &bytes.Buffer{}, &stderr, nil)
if code == 0 {
t.Fatalf("exit code = 0; want non-zero for unknown subcommand")
}
if !strings.Contains(stderr.String(), "unknown subcommand") {
t.Fatalf("stderr = %q; want to mention unknown subcommand", stderr.String())
}
}
func TestDispatch_NoArgsShowsHelp(t *testing.T) {
var stderr bytes.Buffer
code := dispatch([]string{"wb-agent"}, &bytes.Buffer{}, &stderr, nil)
if code == 0 {
t.Fatalf("exit code = 0; want non-zero with no subcommand")
}
if !strings.Contains(stderr.String(), "Usage") {
t.Fatalf("stderr = %q; want Usage", stderr.String())
}
}
go test ./cmd/wb-agent/ -v
Expected: compile error — package doesn't exist.
cmd/wb-agent/main.go// wb-agent — the CLI surface the Agent uses for collab DB access.
//
// The Agent invokes this via the Bash tool from inside a wb-incorporate or
// wb-perspective skill. The CLI opens the same collab DB the running
// wiki-browser server uses, via the existing internal/collab code paths
// (validation, sequence allocation, etc.). SQLite WAL + busy_timeout handle
// cross-process write contention.
package main
import (
"fmt"
"io"
"os"
"github.com/getorcha/wiki-browser/internal/collab"
)
func main() {
os.Exit(dispatch(os.Args, os.Stdout, os.Stderr, openStore))
}
// storeOpener returns an opened *collab.Store. The CALLER is responsible for
// calling Store.Close() — wb-agent is a short-lived process, but a missed
// Close skips the WAL checkpoint and can corrupt the DB under tight loops.
// Every subcommand handler must `defer store.Close()` immediately after a
// successful open.
type storeOpener func(configPath string) (*collab.Store, error)
func openStore(configPath string) (*collab.Store, error) {
// Real implementation comes in Task 10 once the first subcommand needs it.
return nil, fmt.Errorf("openStore: not implemented")
}
func dispatch(args []string, stdout, stderr io.Writer, opener storeOpener) int {
if len(args) < 2 {
fmt.Fprintln(stderr, usage())
return 2
}
switch args[1] {
case "get-topic", "list-open-topics", "insert-proposal", "get-persona", "put-perspective":
fmt.Fprintln(stderr, "subcommand not yet implemented")
return 2
default:
fmt.Fprintf(stderr, "unknown subcommand: %s\n%s\n", args[1], usage())
return 2
}
}
func usage() string {
return `Usage: wb-agent <subcommand> [flags]
Subcommands:
get-topic --id=<id>
list-open-topics --source-path=<path>
insert-proposal --topic-id=<id> --base-sha=<sha> (proposed source on stdin)
get-persona --source-path=<path> --name=<name> (scaffold; #5)
put-perspective --source-path=<path> --persona=<n>
--source-sha=<sha> --persona-sha=<sha> (scaffold; #5)
`
}
go test ./cmd/wb-agent/ -v
Expected: PASS.
go build -o /tmp/wb-agent ./cmd/wb-agent && /tmp/wb-agent
Expected: Prints usage on stderr; exits 2.
git add cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — subcommand dispatcher skeleton"
wb-agent get-topicFiles:
cmd/wb-agent/get_topic.gocmd/wb-agent/main.gocmd/wb-agent/main_test.goLoads a Topic by ID with its anchor and full message thread; emits JSON. This is also the first subcommand that needs an opened collab store, so we implement openStore here.
Append to cmd/wb-agent/main_test.go:
import (
"encoding/json"
"path/filepath"
"github.com/getorcha/wiki-browser/internal/collab"
)
// newTestStore returns one store for test setup/assertions plus an opener that
// opens a fresh Store handle on the same DB path for the command under test.
// Command handlers own and close the handle returned by opener; closing it must
// not close the setup/assertion handle.
func newTestStore(t *testing.T) (*collab.Store, storeOpener) {
t.Helper()
path := filepath.Join(t.TempDir(), "collab.db")
s, err := collab.Open(collab.Config{Path: path})
if err != nil { t.Fatalf("Open: %v", err) }
t.Cleanup(func() { _ = s.Close() })
opener := func(string) (*collab.Store, error) {
return collab.Open(collab.Config{Path: path})
}
return s, opener
}
func TestGetTopic_EmitsJSON(t *testing.T) {
store, opener := newTestStore(t)
if _, err := store.RawDBForTest().Exec(
`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
); err != nil { t.Fatalf("seed user: %v", err) }
if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: "t1", SourcePath: "docs/foo.md",
Anchor: []byte(`{"kind":"global"}`),
CreatedBy: "u1",
FirstMessageID: "m1",
FirstMessageBody: "hello",
}); err != nil { t.Fatalf("seed topic: %v", err) }
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{"wb-agent", "get-topic", "--id=t1"},
&stdout, &stderr, opener,
)
if code != 0 {
t.Fatalf("exit = %d; stderr = %s", code, stderr.String())
}
var out struct {
ID string `json:"id"`
SourcePath string `json:"source_path"`
Anchor json.RawMessage `json:"anchor"`
Messages []struct {
ID, Body string
} `json:"messages"`
}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, stdout.String())
}
if out.ID != "t1" {
t.Fatalf("ID = %q, want t1", out.ID)
}
if len(out.Messages) != 1 || out.Messages[0].Body != "hello" {
t.Fatalf("messages = %+v", out.Messages)
}
}
go test ./cmd/wb-agent/ -v
Expected: compile or test failure — handler doesn't exist.
cmd/wb-agent/get_topic.gopackage main
import (
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"github.com/getorcha/wiki-browser/internal/collab"
)
type getTopicOutput struct {
ID string `json:"id"`
SourcePath string `json:"source_path"`
Anchor json.RawMessage `json:"anchor"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
Messages []collab.TopicMessage `json:"messages"`
}
func runGetTopic(args []string, stdout, stderr io.Writer, opener storeOpener) int {
fs := flag.NewFlagSet("get-topic", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "wiki-browser.yaml", "path to config file")
id := fs.String("id", "", "topic id")
if err := fs.Parse(args); err != nil {
return 2
}
if *id == "" {
fmt.Fprintln(stderr, "get-topic: --id is required")
return 2
}
store, err := opener(*configPath)
if err != nil {
fmt.Fprintf(stderr, "open store: %v\n", err)
return 1
}
defer store.Close()
row := store.RawDB().QueryRow(
`SELECT id, source_path, anchor, created_by, created_at
FROM topics WHERE id = ?`, *id,
)
var out getTopicOutput
var anchor string
if err := row.Scan(&out.ID, &out.SourcePath, &anchor, &out.CreatedBy, &out.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
fmt.Fprintf(stderr, "topic %s not found\n", *id)
return 1
}
fmt.Fprintf(stderr, "scan: %v\n", err)
return 1
}
out.Anchor = json.RawMessage(anchor)
msgs, err := store.ListMessages(*id)
if err != nil {
fmt.Fprintf(stderr, "list messages: %v\n", err)
return 1
}
out.Messages = msgs
if err := json.NewEncoder(stdout).Encode(out); err != nil {
fmt.Fprintf(stderr, "encode: %v\n", err)
return 1
}
return 0
}
In cmd/wb-agent/main.go, replace the dispatcher's switch:
switch args[1] {
case "get-topic":
return runGetTopic(args[2:], stdout, stderr, opener)
case "list-open-topics", "insert-proposal", "get-persona", "put-perspective":
fmt.Fprintln(stderr, "subcommand not yet implemented")
return 2
default:
fmt.Fprintf(stderr, "unknown subcommand: %s\n%s\n", args[1], usage())
return 2
}
Also flesh out openStore:
func openStore(configPath string) (*collab.Store, error) {
cfg, err := config.Load(configPath)
if err != nil {
return nil, fmt.Errorf("load %s: %w", configPath, err)
}
// collab.Open runs Migrate as part of startup. The server has already
// migrated by the time wb-agent is invoked, so this is wasted work but
// not incorrect — Migrate is idempotent and the schema_migrations row
// short-circuits the apply path. Optimizing to skip Migrate here would
// require a new collab.OpenReadyMigrated flag; defer until profiling
// shows it matters.
return collab.Open(collab.Config{Path: cfg.CollabDB})
}
Add import "github.com/getorcha/wiki-browser/internal/config".
go test ./cmd/wb-agent/ -v
Expected: PASS.
git add cmd/wb-agent/main.go cmd/wb-agent/main_test.go cmd/wb-agent/get_topic.go
git commit -m "wiki-browser: wb-agent — get-topic subcommand"
wb-agent list-open-topicsFiles:
Create: cmd/wb-agent/list_open_topics.go
Modify: cmd/wb-agent/main.go
Modify: cmd/wb-agent/main_test.go
Step 1: Write the failing test
Append to cmd/wb-agent/main_test.go:
func TestListOpenTopics_EmitsJSONArray(t *testing.T) {
store, opener := newTestStore(t)
if _, err := store.RawDBForTest().Exec(
`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
); err != nil { t.Fatalf("seed user: %v", err) }
for _, id := range []string{"ta", "tb"} {
if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: id, SourcePath: "docs/foo.md",
Anchor: []byte(`{"kind":"global"}`),
CreatedBy: "u1",
FirstMessageID: "m-" + id,
FirstMessageBody: id,
}); err != nil { t.Fatalf("seed: %v", err) }
}
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{"wb-agent", "list-open-topics", "--source-path=docs/foo.md"},
&stdout, &stderr, opener,
)
if code != 0 {
t.Fatalf("exit = %d; stderr = %s", code, stderr.String())
}
var rows []map[string]any
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, stdout.String())
}
if len(rows) != 2 {
t.Fatalf("len(rows) = %d, want 2", len(rows))
}
}
go test ./cmd/wb-agent/ -run TestListOpen -v
Expected: FAIL — handler not yet implemented (dispatcher emits "not yet implemented").
cmd/wb-agent/list_open_topics.gopackage main
import (
"encoding/json"
"flag"
"fmt"
"io"
)
func 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", "", "repo-relative source path")
if err := fs.Parse(args); err != nil {
return 2
}
if *sourcePath == "" {
fmt.Fprintln(stderr, "list-open-topics: --source-path is required")
return 2
}
store, err := opener(*configPath)
if err != nil {
fmt.Fprintf(stderr, "open store: %v\n", err)
return 1
}
defer store.Close()
topics, err := store.ListOpenTopicsForSource(*sourcePath)
if err != nil {
fmt.Fprintf(stderr, "list: %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
}
In cmd/wb-agent/main.go:
case "list-open-topics":
return runListOpenTopics(args[2:], stdout, stderr, opener)
go test ./cmd/wb-agent/ -v
Expected: PASS.
git add cmd/wb-agent/list_open_topics.go cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — list-open-topics subcommand"
wb-agent insert-proposalFiles:
cmd/wb-agent/insert_proposal.gocmd/wb-agent/main.gocmd/wb-agent/main_test.goReads proposed Source from stdin. Allocates the next revision_number for the topic. Inserts with proposed_by = NULL.
Append to cmd/wb-agent/main_test.go:
func TestInsertProposal_AllocatesRevisionAndUsesNullProposedBy(t *testing.T) {
store, opener := newTestStore(t)
if _, err := store.RawDBForTest().Exec(
`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
); err != nil { t.Fatalf("seed user: %v", err) }
if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: "t1", SourcePath: "docs/foo.md",
Anchor: []byte(`{"kind":"global"}`),
CreatedBy: "u1", FirstMessageID: "m1", FirstMessageBody: "hi",
}); err != nil { t.Fatalf("seed: %v", err) }
args := []string{
"wb-agent", "insert-proposal",
"--topic-id=t1", "--base-sha=deadbeef",
}
var stdout, stderr bytes.Buffer
code := runInsertProposalWithStdin(t, args, &stdout, &stderr,
strings.NewReader("new body bytes"),
opener,
)
if code != 0 {
t.Fatalf("exit = %d; stderr = %s", code, stderr.String())
}
var resp struct{ ID string `json:"id"` }
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil || resp.ID == "" {
t.Fatalf("unmarshal: %v; stdout=%s", err, stdout.String())
}
// Verify row exists with null proposed_by.
var by sql.NullString
var rev int
if err := store.RawDBForTest().QueryRow(
`SELECT revision_number, proposed_by FROM incorporation_proposals WHERE id = ?`,
resp.ID,
).Scan(&rev, &by); err != nil {
t.Fatalf("scan: %v", err)
}
if rev != 1 || by.Valid {
t.Fatalf("rev=%d, proposed_by.Valid=%v; want rev=1, null", rev, by.Valid)
}
}
// runInsertProposalWithStdin is a small wrapper that exposes the stdin
// reader to the subcommand, since dispatch hard-codes os.Stdin in main.
func runInsertProposalWithStdin(t *testing.T, args []string, stdout, stderr io.Writer, stdin io.Reader, opener storeOpener) int {
t.Helper()
if len(args) < 2 || args[1] != "insert-proposal" {
t.Fatalf("test misuse: %v", args)
}
return runInsertProposal(args[2:], stdout, stderr, stdin, opener)
}
Add the needed imports: "database/sql", "io", "strings".
go test ./cmd/wb-agent/ -run TestInsertProposal -v
Expected: compile error — runInsertProposal not defined.
cmd/wb-agent/insert_proposal.gopackage main
import (
"encoding/json"
"flag"
"fmt"
"io"
"github.com/google/uuid"
"github.com/getorcha/wiki-browser/internal/collab"
)
func runInsertProposal(args []string, stdout, stderr io.Writer, stdin io.Reader, opener storeOpener) int {
fs := flag.NewFlagSet("insert-proposal", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "wiki-browser.yaml", "path to config file")
topicID := fs.String("topic-id", "", "topic id")
baseSHA := fs.String("base-sha", "", "base source SHA (git blob)")
if err := fs.Parse(args); err != nil {
return 2
}
if *topicID == "" || *baseSHA == "" {
fmt.Fprintln(stderr, "insert-proposal: --topic-id and --base-sha are required")
return 2
}
body, err := io.ReadAll(stdin)
if err != nil {
fmt.Fprintf(stderr, "read stdin: %v\n", err)
return 1
}
if len(body) == 0 {
fmt.Fprintln(stderr, "insert-proposal: stdin is empty")
return 2
}
store, err := opener(*configPath)
if err != nil {
fmt.Fprintf(stderr, "open store: %v\n", err)
return 1
}
defer store.Close()
// Allocate next revision number. This runs as a raw read because the
// write happens through the funnel via InsertProposal below; concurrent
// writers for the same topic would race here, but per the spec at most
// one agent job per source is in flight at a time, so collisions are
// impossible in practice.
var nextRev int
if err := store.RawDB().QueryRow(
`SELECT COALESCE(MAX(revision_number), 0) + 1
FROM incorporation_proposals WHERE topic_id = ?`,
*topicID,
).Scan(&nextRev); err != nil {
fmt.Fprintf(stderr, "next revision: %v\n", err)
return 1
}
id := uuid.NewString()
if _, err := store.InsertProposal(collab.NewProposal{
ID: id, TopicID: *topicID, RevisionNumber: nextRev,
ProposedSource: string(body), BaseSourceSHA: *baseSHA,
ProposedBy: nil,
}); err != nil {
fmt.Fprintf(stderr, "insert proposal: %v\n", err)
return 1
}
_ = json.NewEncoder(stdout).Encode(struct {
ID string `json:"id"`
}{ID: id})
return 0
}
In cmd/wb-agent/main.go:
case "insert-proposal":
return runInsertProposal(args[2:], stdout, stderr, os.Stdin, opener)
Update dispatch's signature to take stdin — or keep os.Stdin hardcoded since this is the only stdin-consuming subcommand. The test wraps runInsertProposal directly to inject stdin.
go test ./cmd/wb-agent/ -v
Expected: PASS.
git add cmd/wb-agent/insert_proposal.go cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — insert-proposal subcommand (null proposed_by)"
wb-agent — get-persona and put-perspective stubsFiles:
cmd/wb-agent/stubs.gocmd/wb-agent/main.gocmd/wb-agent/main_test.goThese are scaffolds — they accept the documented flags, exit 0, and print a placeholder so the runtime is testable. #5 implements the real behavior.
Append to cmd/wb-agent/main_test.go:
func TestStubs_ExitZero(t *testing.T) {
cases := [][]string{
{"wb-agent", "get-persona", "--source-path=docs/foo.md", "--name=CFO"},
{"wb-agent", "put-perspective", "--source-path=docs/foo.md",
"--persona=CFO", "--source-sha=sha", "--persona-sha=psha"},
}
for _, args := range cases {
_, opener := newTestStore(t)
var stdout, stderr bytes.Buffer
code := dispatch(args, &stdout, &stderr, opener)
if code != 0 {
t.Fatalf("args=%v: exit=%d stderr=%s", args, code, stderr.String())
}
if !strings.Contains(stdout.String(), "scaffold") {
t.Fatalf("args=%v: stdout=%q; want scaffold marker", args, stdout.String())
}
}
}
go test ./cmd/wb-agent/ -run TestStubs -v
Expected: FAIL — dispatcher still says "not yet implemented".
cmd/wb-agent/stubs.gopackage main
import (
"flag"
"fmt"
"io"
)
// runGetPersona is a #5-owned scaffold. #3 ships it so the runtime is
// end-to-end testable; the response shape will be replaced by #5.
func runGetPersona(args []string, stdout, stderr io.Writer, opener storeOpener) int {
fs := flag.NewFlagSet("get-persona", flag.ContinueOnError)
fs.SetOutput(stderr)
_ = fs.String("config", "wiki-browser.yaml", "")
_ = fs.String("source-path", "", "")
_ = fs.String("name", "", "")
if err := fs.Parse(args); err != nil {
return 2
}
fmt.Fprintln(stdout, `{"scaffold":"get-persona; #5 will replace this"}`)
return 0
}
// runPutPerspective is a #5-owned scaffold.
func runPutPerspective(args []string, stdout, stderr io.Writer, opener storeOpener) int {
fs := flag.NewFlagSet("put-perspective", flag.ContinueOnError)
fs.SetOutput(stderr)
_ = fs.String("config", "wiki-browser.yaml", "")
_ = fs.String("source-path", "", "")
_ = fs.String("persona", "", "")
_ = fs.String("source-sha", "", "")
_ = fs.String("persona-sha", "", "")
if err := fs.Parse(args); err != nil {
return 2
}
fmt.Fprintln(stdout, `{"scaffold":"put-perspective; #5 will replace this"}`)
return 0
}
In cmd/wb-agent/main.go:
case "get-persona":
return runGetPersona(args[2:], stdout, stderr, opener)
case "put-perspective":
return runPutPerspective(args[2:], stdout, stderr, opener)
Remove the "not yet implemented" arm.
go test ./cmd/wb-agent/ -v
Expected: PASS.
git add cmd/wb-agent/stubs.go cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — get-persona and put-perspective scaffold stubs"
Files:
internal/server/agent_jobs.gointernal/server/agent_jobs_test.goThree endpoints. All wrapped with auth.RequireCollaborator; POST also with auth.RequireCSRF. The handler depends on a new AgentService field on server.Deps.
First, the existing newTestServerWithSession helper does not let callers inject an AgentService. The cleanest fix is to extend the helper to accept an optional AgentService parameter (default nil); existing call sites remain unchanged because Go passes nil for unspecified variadic-like configuration. Concretely, change the helper from a fixed signature to take a small testServerOptions struct and keep the existing entry points for backward compatibility.
AgentService plumbing helperIn internal/server/handler_doc_test.go, replace newTestServerWithSession with a variant that exposes an option struct, but keep the previous signature working:
type testServerOptions struct {
SessionMiddleware func(http.Handler) http.Handler
AgentService AgentService
}
func newTestServerWithSession(t *testing.T, mw func(http.Handler) http.Handler) (*httptest.Server, string, *collab.Store) {
t.Helper()
return newTestServerWithOptions(t, testServerOptions{SessionMiddleware: mw})
}
func newTestServerWithOptions(t *testing.T, opts testServerOptions) (*httptest.Server, string, *collab.Store) {
t.Helper()
root := t.TempDir()
files := map[string]string{
"a.md": "# A\n\nalpha bravo charlie",
"raw.html": "<!doctype html><html><body><h1>hi</h1></body></html>",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md", ".html"}})
if err != nil { t.Fatal(err) }
idx, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
if err != nil { t.Fatal(err) }
idx.SetRoot(root)
cache := render.NewCache(4 << 20)
idx.SetCache(cache)
t.Cleanup(func() { idx.Close() })
for name := range files {
if err := idx.Reindex(filepath.Join(root, name)); err != nil { t.Fatal(err) }
}
store, err := collab.Open(collab.Config{Path: filepath.Join(t.TempDir(), "collab.db")})
if err != nil { t.Fatal(err) }
t.Cleanup(func() { _ = store.Close() })
if err := store.UpsertUser(collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}); err != nil {
t.Fatal(err)
}
mux := server.Mux(server.Deps{
Title: "Test",
Root: root,
Walker: w,
Index: idx,
Cache: cache,
Collab: store,
SessionMiddleware: opts.SessionMiddleware,
AgentService: opts.AgentService,
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts, root, store
}
Create internal/server/agent_jobs_test.go:
package server_test
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/getorcha/wiki-browser/internal/agent"
"github.com/getorcha/wiki-browser/internal/server"
)
type fakeAgentService struct {
submitted []agent.SubmitInput
id string
err error
}
func (f *fakeAgentService) Submit(in agent.SubmitInput) (string, error) {
f.submitted = append(f.submitted, in)
return f.id, f.err
}
func (f *fakeAgentService) AuthorIdentity() (string, string) {
return "Orcha Agent", "agent@orcha.local"
}
// Compile-time assertion: fakeAgentService satisfies the server interface.
var _ server.AgentService = (*fakeAgentService)(nil)
func TestPostAgentJob_CreatesAndReturnsID(t *testing.T) {
fake := &fakeAgentService{id: "job-1"}
ts, _, _ := newTestServerWithOptions(t, testServerOptions{
SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
AgentService: fake,
})
body := strings.NewReader(`{"kind":"incorporate","source_path":"docs/foo.md","topic_id":"t1","base_sha":"sha"}`)
req, _ := http.NewRequest("POST", ts.URL+"/api/agent/jobs", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "csrf")
resp, err := http.DefaultClient.Do(req)
if err != nil { t.Fatal(err) }
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d, body = %s", resp.StatusCode, b)
}
var out struct{ JobID string `json:"job_id"` }
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) }
if out.JobID != "job-1" {
t.Fatalf("job_id = %q, want job-1", out.JobID)
}
if len(fake.submitted) != 1 || fake.submitted[0].Kind != "incorporate" {
t.Fatalf("submitted = %+v", fake.submitted)
}
}
func TestPostAgentJob_409OnInflight(t *testing.T) {
fake := &fakeAgentService{err: agent.ErrInflight}
ts, _, _ := newTestServerWithOptions(t, testServerOptions{
SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
AgentService: fake,
})
body := strings.NewReader(`{"kind":"incorporate","source_path":"docs/foo.md","topic_id":"t1","base_sha":"sha"}`)
req, _ := http.NewRequest("POST", ts.URL+"/api/agent/jobs", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "csrf")
resp, err := http.DefaultClient.Do(req)
if err != nil { t.Fatal(err) }
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d, body = %s", resp.StatusCode, b)
}
}
func TestGetAgentJobs_RequiresAuth(t *testing.T) {
// newTestServerWithOptions wires a real Collab store internally, so the
// 503 "agent_unavailable" branch in handleListAgentJobs doesn't pre-empt
// the auth check. We want auth (RequireCollaborator) to fire BEFORE the
// handler runs, returning 401. Pass a non-nil AgentService for the same
// reason — POST validation needs it, and GET handlers fall through to
// the auth middleware first.
fake := &fakeAgentService{}
ts, _, _ := newTestServerWithOptions(t, testServerOptions{
// Nil session middleware: requests carry no session principal, so
// RequireCollaborator should reject with 401.
SessionMiddleware: nil,
AgentService: fake,
})
resp, err := http.Get(ts.URL + "/api/agent/jobs?source_path=docs/foo.md")
if err != nil { t.Fatal(err) }
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestPostAgentJob_RequiresCSRF(t *testing.T) {
// Authenticated request that omits X-CSRF-Token must return 403, not 401.
// Per #7: missing session → 401, authenticated-without-CSRF → 403.
fake := &fakeAgentService{id: "job-1"}
ts, _, _ := newTestServerWithOptions(t, testServerOptions{
SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
AgentService: fake,
})
body := strings.NewReader(`{"kind":"incorporate","source_path":"docs/foo.md","topic_id":"t1","base_sha":"sha"}`)
req, _ := http.NewRequest("POST", ts.URL+"/api/agent/jobs", body)
req.Header.Set("Content-Type", "application/json")
// Deliberately omit X-CSRF-Token.
resp, err := http.DefaultClient.Do(req)
if err != nil { t.Fatal(err) }
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (missing CSRF on authenticated request)", resp.StatusCode)
}
if len(fake.submitted) != 0 {
t.Fatalf("submitted = %+v; CSRF should have blocked before the service was called", fake.submitted)
}
}
func TestPostAgentJob_ValidationErrorCodes(t *testing.T) {
// The handler should differentiate validation failures from the catch-all
// "bad_request" so the client can route on the error code. agent.Submit
// returns typed errors; the handler maps them to specific JSON codes.
fake := &fakeAgentService{err: agent.ErrUnsupportedKind}
ts, _, _ := newTestServerWithOptions(t, testServerOptions{
SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
AgentService: fake,
})
body := strings.NewReader(`{"kind":"nope","source_path":"docs/foo.md"}`)
req, _ := http.NewRequest("POST", ts.URL+"/api/agent/jobs", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "csrf")
resp, err := http.DefaultClient.Do(req)
if err != nil { t.Fatal(err) }
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
var out struct{ Error string `json:"error"` }
_ = json.NewDecoder(resp.Body).Decode(&out)
if out.Error != "unsupported_kind" {
t.Fatalf("error code = %q, want unsupported_kind", out.Error)
}
}
go test ./internal/server/ -run TestPostAgentJob -v
Expected: compile error — Deps.AgentService doesn't exist, AgentService interface doesn't exist.
internal/server/agent_jobs.gopackage server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/getorcha/wiki-browser/internal/agent"
"github.com/getorcha/wiki-browser/internal/collab"
)
// AgentService is the subset of *agent.Service the HTTP layer needs. Lets
// tests inject a fake without instantiating a real queue. AuthorIdentity is
// exposed so #4's proposal-approval handler can read the git author from the
// same seam, rather than re-reading config.
type AgentService interface {
Submit(in agent.SubmitInput) (string, error)
AuthorIdentity() (name, email string)
}
type agentJobCreateRequest struct {
Kind string `json:"kind"`
SourcePath string `json:"source_path"`
TopicID string `json:"topic_id,omitempty"`
BaseSHA string `json:"base_sha,omitempty"`
PersonaName string `json:"persona_name,omitempty"`
SourceSHA string `json:"source_sha,omitempty"`
PersonaSHA string `json:"persona_sha,omitempty"`
}
func (d Deps) handleCreateAgentJob(w http.ResponseWriter, r *http.Request) {
if d.AgentService == nil {
writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
return
}
var req agentJobCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_json")
return
}
if _, err := collab.ValidateSourcePath(req.SourcePath); err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_source_path")
return
}
id, err := d.AgentService.Submit(agent.SubmitInput{
Kind: req.Kind, SourcePath: req.SourcePath,
TopicID: req.TopicID, BaseSHA: req.BaseSHA,
PersonaName: req.PersonaName, SourceSHA: req.SourceSHA, PersonaSHA: req.PersonaSHA,
})
switch {
case errors.Is(err, agent.ErrInflight):
writeJSONError(w, http.StatusConflict, "inflight")
return
case errors.Is(err, agent.ErrUnsupportedKind):
writeJSONError(w, http.StatusBadRequest, "unsupported_kind")
return
case errors.Is(err, agent.ErrMissingIncorporateFields):
writeJSONError(w, http.StatusBadRequest, "missing_incorporate_fields")
return
case errors.Is(err, agent.ErrMissingPerspectiveFields):
writeJSONError(w, http.StatusBadRequest, "missing_perspective_fields")
return
case errors.Is(err, agent.ErrMissingSourcePath):
writeJSONError(w, http.StatusBadRequest, "missing_source_path")
return
case err != nil:
// Catch-all for unexpected service errors (DB failures during the
// HasInflightForSource check, InsertJob failures, etc.). These
// represent server-side problems, not client validation issues.
writeJSONError(w, http.StatusInternalServerError, "submit_failed")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": id})
}
func (d Deps) handleListAgentJobs(w http.ResponseWriter, r *http.Request) {
if d.Collab == nil {
writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
return
}
sourcePath := r.URL.Query().Get("source_path")
limit := 20
if s := r.URL.Query().Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
limit = n
}
}
jobs, err := d.Collab.ListJobsForSource(sourcePath, limit)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_source_path")
return
}
writeJSON(w, http.StatusOK, jobs)
}
func (d Deps) handleGetAgentJob(w http.ResponseWriter, r *http.Request) {
if d.Collab == nil {
writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
return
}
job, err := d.Collab.GetJob(r.PathValue("id"))
if err != nil {
writeJSONError(w, http.StatusNotFound, "unknown_job")
return
}
writeJSON(w, http.StatusOK, job)
}
AgentService to Deps and register the routesEdit internal/server/server.go. Add to Deps:
type Deps struct {
// ... existing fields ...
AgentService AgentService
}
Add inside Mux:
mux.Handle("POST /api/agent/jobs",
d.withSession(auth.RequireCollaborator(auth.RequireCSRF(http.HandlerFunc(d.handleCreateAgentJob)))))
mux.Handle("GET /api/agent/jobs",
d.withSession(auth.RequireCollaborator(http.HandlerFunc(d.handleListAgentJobs))))
mux.Handle("GET /api/agent/jobs/{id}",
d.withSession(auth.RequireCollaborator(http.HandlerFunc(d.handleGetAgentJob))))
go test ./internal/server/ -run TestPostAgentJob -v
go test ./internal/server/ -run TestGetAgentJobs -v
Expected: PASS for both.
git add internal/server/agent_jobs.go internal/server/agent_jobs_test.go internal/server/server.go
git commit -m "wiki-browser: server — /api/agent/jobs endpoints"
agent.Service and startup sweep into main.goFiles:
cmd/wiki-browser/main.goConstruct the service after opening the collab store, run the startup sweep before collab.Recover, defer svc.Stop(), and pass it into server.Deps.
cmd/wiki-browser/main.goIn run, just after collabStore is opened (and before collab.Recover), insert:
// Sweep any agent_jobs left running by a previous boot. Run before
// collab.Recover so the reconciliation logic sees consistent state.
if swept, err := collabStore.SweepIncompleteJobs(); err != nil {
return fmt.Errorf("sweep agent jobs: %w", err)
} else if swept > 0 {
slog.Info("agent_jobs swept on startup", "count", swept)
}
Add internal/agent to imports. Construct the runner + service after the auth setup block, before mux := server.Mux(...):
runner := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{
ClaudeBin: cfg.Agent.ClaudeBin,
})
agentSvc := agent.NewService(agent.ServiceConfig{
Store: collabStore,
Runner: runner,
MaxConcurrentJobs: cfg.Agent.MaxConcurrentJobs,
IncorporateTimeout: cfg.Agent.IncorporateTimeout,
PerspectiveTimeout: cfg.Agent.PerspectiveTimeout,
RepoRoot: cfg.Root,
WikiRoot: wikiBrowserRoot(),
WBAgentPath: cfg.Agent.WBAgentBin,
LogDir: cfg.Agent.LogDir,
AuthorName: cfg.Agent.AuthorName,
AuthorEmail: cfg.Agent.AuthorEmail,
})
defer agentSvc.Stop()
Add a tiny helper at the bottom of the file:
// wikiBrowserRoot returns the directory of the running wiki-browser binary,
// which is also where .claude/skills/ lives in the deployed layout.
func wikiBrowserRoot() string {
self, err := os.Executable()
if err != nil {
// Fall back to cwd; the harness will fail-fast in validation
// elsewhere if this turns out wrong.
cwd, _ := os.Getwd()
return cwd
}
return filepath.Dir(self)
}
Pass into Deps:
mux := server.Mux(server.Deps{
Title: cfg.Title,
Root: cfg.Root,
Walker: w,
Index: idx,
Cache: renderCache,
Collab: collabStore,
Auth: authHandlers,
SessionMiddleware: sessionMiddleware,
AuthDevMode: cfg.Auth.DevMode,
AgentService: agentSvc,
})
AuthorName/AuthorEmail reach agentSvc via ServiceConfig above. #4's proposal-approval handler will read them through Deps.AgentService.AuthorIdentity() rather than cfg.Agent.*, which keeps the seam stable across config-shape changes. Until #4 lands, the values are validated at startup but the approval call site doesn't exist yet — that's fine, the seam is real and AuthorIdentity() is testable on agent.Service directly.
go build ./...
Expected: success.
go test ./...
Expected: PASS.
git add cmd/wiki-browser/main.go
git commit -m "wiki-browser: main — wire agent.Service, startup sweep, agent_jobs routes"
wb-incorporate and wb-perspectiveFiles:
.claude/skills/wb-incorporate/SKILL.md.claude/skills/wb-perspective/SKILL.mdThese are intentional scaffolds: they lock the parameter-block schema, the sequence of wb-agent calls, and the on-disk file layout. #4 and #5 fill the <REWRITE CONTRACT OWNED BY #N> placeholders with their domain content.
.claude/skills/wb-incorporate/SKILL.md---
name: wb-incorporate
description: Produce a proposed Source rewrite for an open Topic, re-anchoring
every other open non-global Topic on the same Source. Used by wiki-browser
during Topic incorporation.
---
# wb-incorporate
Parse the job parameters from the prompt body. They will look like:
Job ID: <uuid>
Topic ID: <topic-id>
Source path: <repo-relative>
Base source SHA: <git blob SHA>
Repo root: <absolute path to the orcha monorepo root>
wb-agent path: <absolute path to the wb-agent binary>
The Repo root + Source path concatenation gives you the absolute Source
file path. Always use the absolute path the harness gave you — never
rely on the current working directory for resolving Source files.
Always invoke wb-agent via the absolute path the harness gave you —
never rely on PATH lookup.
Then:
1. Read the Source file at `<Repo root>/<Source path>`.
2. Run `<wb-agent path> get-topic --id=<topic-id>` to load the topic,
anchor, and full message thread.
3. Run `<wb-agent path> list-open-topics --source-path=<Source path>`
to load every other open Topic on this Source, with anchors.
4. <REWRITE CONTRACT OWNED BY #4 — re-anchor + Source rewrite contract goes here.>
5. Pipe the proposed Source on stdin to:
`<wb-agent path> insert-proposal --topic-id=<topic-id> --base-sha=<Base source SHA>`
6. Exit 0 on success. Exit non-zero on any unrecoverable error; the wiki-browser
server will surface stderr to the operator.
.claude/skills/wb-perspective/SKILL.md---
name: wb-perspective
description: Generate or refresh a Perspective rendering of a Source for a
named persona. Used by wiki-browser when a stale or missing Perspective
is requested.
---
# wb-perspective
Parse the job parameters from the prompt body. They will look like:
Job ID: <uuid>
Source path: <repo-relative>
Persona name: <persona>
Source SHA: <git blob SHA>
Persona SHA: <sha256 of the persona prompt>
Repo root: <absolute path to the orcha monorepo root>
wb-agent path: <absolute path to the wb-agent binary>
The Repo root + Source path concatenation gives you the absolute Source
file path. Always use the absolute path the harness gave you — never
rely on the current working directory for resolving Source files.
Always invoke wb-agent via the absolute path the harness gave you —
never rely on PATH lookup.
Then:
1. Read the Source file at `<Repo root>/<Source path>`.
2. Run `<wb-agent path> get-persona --source-path=<Source path> --name=<Persona name>`
to load the persona prompt text.
3. <REWRITE CONTRACT OWNED BY #5 — Perspective generation contract goes here.>
4. Pipe the generated Perspective on stdin to:
`<wb-agent path> put-perspective --source-path=<Source path> --persona=<Persona name> --source-sha=<Source SHA> --persona-sha=<Persona SHA>`
5. Exit 0 on success. Exit non-zero on any unrecoverable error; the wiki-browser
server will surface stderr to the operator.
git add .claude/skills/wb-incorporate/SKILL.md .claude/skills/wb-perspective/SKILL.md
git commit -m "wiki-browser: skills — wb-incorporate and wb-perspective scaffolds"
Files:
Modify: Makefile
Modify: wiki-browser.example.yaml
Step 1: Modify Makefile
Replace the build and build-arm64 targets:
# Makefile — wiki-browser
.PHONY: build build-arm64 test run lint clean
build:
go build -trimpath -ldflags="-s -w" -o dist/wiki-browser ./cmd/wiki-browser
go build -trimpath -ldflags="-s -w" -o dist/wb-agent ./cmd/wb-agent
# Cross-compile both binaries for a 64-bit Pi.
build-arm64:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
go build -trimpath -ldflags="-s -w" -o dist/wiki-browser-arm64 ./cmd/wiki-browser
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
go build -trimpath -ldflags="-s -w" -o dist/wb-agent-arm64 ./cmd/wb-agent
test:
go test ./...
run:
go run ./cmd/wiki-browser -config=wiki-browser.yaml
lint:
go vet ./...
clean:
rm -rf dist
wiki-browser.example.yamlAdd the agent: block at the bottom:
agent:
author_name: "Orcha Agent"
author_email: "agent@orcha.local"
claude_bin: "" # optional; default "claude" in PATH
wb_agent_bin: "" # optional; default: sibling of wiki-browser binary
max_concurrent_jobs: 1
incorporate_timeout: "5m"
perspective_timeout: "3m"
log_dir: "./agent-logs" # optional; empty disables file logging
make build
ls dist/
Expected: dist/wiki-browser and dist/wb-agent both present.
git add Makefile wiki-browser.example.yaml
git commit -m "wiki-browser: build wb-agent and document agent: config"
Files:
internal/agent/e2e_test.goA higher-level test that uses FakeRunner to simulate the agent invoking wb-agent insert-proposal (via Go calls, not a subprocess). Validates that submit → run → complete → row-readable works without touching claude. This is the regression net for the whole runtime.
Create internal/agent/e2e_test.go:
package agent_test
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/getorcha/wiki-browser/internal/agent"
"github.com/getorcha/wiki-browser/internal/collab"
)
func TestEndToEnd_IncorporateProducesProposal(t *testing.T) {
store, err := collab.Open(collab.Config{Path: filepath.Join(t.TempDir(), "collab.db")})
if err != nil { t.Fatalf("Open: %v", err) }
t.Cleanup(func() { _ = store.Close() })
if _, err := store.RawDBForTest().Exec(
`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
); err != nil { t.Fatalf("seed user: %v", err) }
if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: "t1", SourcePath: "docs/foo.md",
Anchor: []byte(`{"kind":"global"}`),
CreatedBy: "u1", FirstMessageID: "m1", FirstMessageBody: "rewrite foo",
}); err != nil { t.Fatalf("seed topic: %v", err) }
runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
// Simulate the agent calling wb-agent insert-proposal.
if _, err := store.InsertProposal(collab.NewProposal{
ID: "p1", TopicID: j.TopicID, RevisionNumber: 1,
ProposedSource: "fake new source", BaseSourceSHA: j.BaseSHA, ProposedBy: nil,
}); err != nil {
return agent.RunResult{ExitCode: 1, ErrorTail: err.Error()}
}
return agent.RunResult{ExitCode: 0}
})
svc := agent.NewService(agent.ServiceConfig{
Store: store, Runner: runner, MaxConcurrentJobs: 1,
IncorporateTimeout: 2 * time.Second, PerspectiveTimeout: 2 * time.Second,
RepoRoot: t.TempDir(), WikiRoot: t.TempDir(), WBAgentPath: "/wb-agent",
})
t.Cleanup(svc.Stop)
jobID, err := svc.Submit(agent.SubmitInput{
Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: "t1", BaseSHA: "sha",
})
if err != nil { t.Fatalf("Submit: %v", err) }
// Poll until terminal.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
j, err := store.GetJob(jobID)
if err == nil && j.Status == "succeeded" {
// And the proposal row exists.
var n int
if err := store.RawDBForTest().QueryRow(
`SELECT COUNT(*) FROM incorporation_proposals WHERE topic_id = 't1'`,
).Scan(&n); err != nil { t.Fatalf("scan: %v", err) }
if n != 1 { t.Fatalf("proposal rows = %d, want 1", n) }
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("job did not reach succeeded state")
}
go test ./internal/agent/ -run TestEndToEnd -v -timeout=30s
Expected: PASS.
go test ./...
Expected: PASS.
git add internal/agent/e2e_test.go
git commit -m "wiki-browser: agent — end-to-end smoke test (FakeRunner)"
All 18 tasks committed in order produce:
proposed_by and creates agent_jobs, applied via a -- migrate:no-tx directive on the existing runner.internal/agent package with the queue, lifecycle, real subprocess runner (with process-group teardown), and a substitutable test runner.wb-agent CLI binary implementing the three read/write subcommands #3 owns plus two scaffold stubs for #5./api/agent/jobs, auth-gated like the existing topic API..claude/skills/wb-incorporate/ and wb-perspective/ ready for #4 and #5 to fill in their domain contracts.Subprojects #4 and #5 can now produce their prompt content without any further runtime work.