From 52f595efc0433d20de6dcad45fa8f536fef7ccdd Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sun, 19 Jul 2026 15:28:45 -0700 Subject: [PATCH] webapp: seed the demo hub with a realistic wiki MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo harness generated files named run-015.md, des-031.md and so on, with one-line bodies. Screenshots taken from it end up on the website, and "des-031.md" in a treemap tells a visitor nothing about what BearDrive is for. Replaced the generator with a wiki a company would actually have: runbooks, ADRs with real slugs, dated meeting notes, product and research docs, and three hand-written documents (q3-findings, incident-response, first-week) whose markdown renders with headings, tables, code and lists so the file view is worth screenshotting. Read-heat shaping is unchanged — runbooks are what the on-call agents live in, research notes are written for humans and barely read by anything — so the insights views still light up. Project renamed proj -> acme-wiki to match. Also dropped the "must never be committed" note: the file has been in the tree for a while and is genuinely useful for exploring the UI and taking product screenshots. It still only runs under BDRIVE_MANUAL_SERVE=1. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RA5pQH92cxk5SfiYJeTUjK --- internal/webapp/manual_serve_test.go | 330 ++++++++++++++++++++++----- 1 file changed, 274 insertions(+), 56 deletions(-) diff --git a/internal/webapp/manual_serve_test.go b/internal/webapp/manual_serve_test.go index 927211b..b79dc14 100644 --- a/internal/webapp/manual_serve_test.go +++ b/internal/webapp/manual_serve_test.go @@ -1,14 +1,19 @@ package webapp -// Temporary manual demo harness: a seeded hub (~500 files, zipf-ish read -// data, four agent devices) for exploring the web UI by hand. Not part of -// the test suite: it only runs with BDRIVE_MANUAL_SERVE=1 and must never be -// committed. +// Manual demo harness: a seeded hub (a realistic company wiki, zipf-ish read +// data, four agent devices) for exploring the web UI by hand and for taking +// product screenshots. Not part of the test suite: it only runs with +// BDRIVE_MANUAL_SERVE=1. // // State lives in a STABLE directory (os.TempDir()/bdrive-demo-hub, override // with BDRIVE_MANUAL_STATE), so restarting the harness — e.g. after a // frontend change — keeps accounts, browser sessions, the project id, and // all seeded/demo data. Delete the directory to reset the demo. +// +// The seed is deliberately realistic: real-looking runbooks, ADRs, dated +// meeting notes and product docs, with bodies that render as proper markdown +// (headings, tables, code, callouts). Screenshots taken here end up on the +// website, and "run-015.md" in a treemap tells a visitor nothing. import ( "crypto/sha256" @@ -18,6 +23,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "testing" "time" @@ -45,7 +51,7 @@ func TestManualServe(t *testing.T) { if err != nil { t.Fatal(err) } - p, _, err := db.GetOrCreate("proj", "") // create-or-join: id is stable across restarts + p, _, err := db.GetOrCreate("acme-wiki", "") // create-or-join: id is stable across restarts if err != nil { t.Fatal(err) } @@ -82,26 +88,26 @@ func TestManualServe(t *testing.T) { time.Sleep(8 * time.Hour) } -// seedDemo writes ~500 files (journal + blobs) and their read buckets. Runs +// doc is one seeded file: a path and the markdown body behind it. +type doc struct { + path string + body string +} + +// seedDemo writes the demo wiki (journal + blobs) and its read buckets. Runs // once per state dir. func seedDemo(t *testing.T, state, prefix, projectID string) { t.Helper() os.MkdirAll(filepath.Join(prefix, "journal"), 0o755) os.MkdirAll(filepath.Join(prefix, "blobs"), 0o755) + seed := int64(42) rnd := func() float64 { seed = (seed*16807 + 7) % 2147483647 return float64(seed) / 2147483647 } - folders := []struct { - dir string - n int - }{ - {"wiki/onboarding", 28}, {"wiki/architecture", 40}, {"wiki/runbooks", 55}, - {"wiki/api", 70}, {"wiki/decisions", 45}, {"docs/product", 60}, - {"docs/design", 35}, {"notes/meetings", 80}, {"notes/research", 45}, - {"shared/reports", 42}, - } + + docs := demoDocs() humans := []string{"alice@x.io", "bob@x.io", "carol@x.io"} agents := []string{"dev-ci", "dev-snow", "codex-mia", "gemini-doc"} @@ -109,51 +115,50 @@ func seedDemo(t *testing.T, state, prefix, projectID string) { var ops []journal.Op var stats []ReadStat var lam, seq int64 - fileNo := 0 - for _, f := range folders { - short := filepath.Base(f.dir)[:3] - for i := 0; i < f.n; i++ { - fileNo++ - path := fmt.Sprintf("%s/%s-%03d.md", f.dir, short, i+1) - content := fmt.Sprintf("# %s\n\nSeeded demo file %d in %s.\n", path, fileNo, f.dir) - sum := sha256.Sum256([]byte(content)) - blob := hex.EncodeToString(sum[:]) - os.WriteFile(filepath.Join(prefix, "blobs", blob), []byte(content), 0o644) - stale := int(400 * rnd() * rnd()) - lam++ - seq++ - ops = append(ops, journal.Op{ - Seq: seq, Lamport: lam, Time: now.AddDate(0, 0, -stale), - Device: "seed", DeviceName: "seed", Author: "alice@x.io", - User: "alice@x.io", UserName: "Alice", - Kind: journal.KindPut, Path: path, Blob: blob, - Size: int64(len(content)), Mode: 0o644, - }) - hot := rnd() < 0.15 - day := now.AddDate(0, 0, -int(rnd()*20)).Format("2006-01-02") - for _, a := range humans { - n := int64(math.Floor((map[bool]float64{true: 30, false: 3}[hot]) * rnd() * rnd())) - if n > 0 { - stats = append(stats, ReadStat{Project: projectID, Path: path, Day: day, - Kind: ReadKindHuman, Actor: a, Count: n, Last: now}) - } + + for _, d := range docs { + sum := sha256.Sum256([]byte(d.body)) + blob := hex.EncodeToString(sum[:]) + os.WriteFile(filepath.Join(prefix, "blobs", blob), []byte(d.body), 0o644) + stale := int(400 * rnd() * rnd()) + lam++ + seq++ + ops = append(ops, journal.Op{ + Seq: seq, Lamport: lam, Time: now.AddDate(0, 0, -stale), + Device: "seed", DeviceName: "seed", Author: "alice@x.io", + User: "alice@x.io", UserName: "Alice", + Kind: journal.KindPut, Path: d.path, Blob: blob, + Size: int64(len(d.body)), Mode: 0o644, + }) + + dir := filepath.Dir(d.path) + hot := rnd() < 0.15 + day := now.AddDate(0, 0, -int(rnd()*20)).Format("2006-01-02") + for _, a := range humans { + n := int64(math.Floor((map[bool]float64{true: 30, false: 3}[hot]) * rnd() * rnd())) + if n > 0 { + stats = append(stats, ReadStat{Project: projectID, Path: d.path, Day: day, + Kind: ReadKindHuman, Actor: a, Count: n, Last: now}) } - for ai, a := range agents { - boost := 1.0 - if f.dir == "wiki/runbooks" && ai < 2 { - boost = 4 - } - if f.dir == "notes/research" { - boost = 0.05 - } - n := int64(math.Floor((map[bool]float64{true: 60, false: 5}[hot]) * rnd() * rnd() * boost)) - if n > 0 { - stats = append(stats, ReadStat{Project: projectID, Path: path, Day: day, - Kind: ReadKindAgent, Actor: a, Count: n, Last: now}) - } + } + for ai, a := range agents { + // Runbooks are what the on-call agents live in; research notes are + // written for humans and barely read by anything. + boost := 1.0 + if dir == "wiki/runbooks" && ai < 2 { + boost = 4 + } + if dir == "notes/research" { + boost = 0.05 + } + n := int64(math.Floor((map[bool]float64{true: 60, false: 5}[hot]) * rnd() * rnd() * boost)) + if n > 0 { + stats = append(stats, ReadStat{Project: projectID, Path: d.path, Day: day, + Kind: ReadKindAgent, Actor: a, Count: n, Last: now}) } } } + if err := journal.Append(filepath.Join(prefix, "journal", "seed.jsonl"), ops); err != nil { t.Fatal(err) } @@ -161,3 +166,216 @@ func seedDemo(t *testing.T, state, prefix, projectID string) { t.Fatal(err) } } + +// demoDocs builds the seeded wiki: a handful of hand-written documents that +// render well enough to screenshot, plus plausible filler so the folder +// listings and the insights treemap look like a real company's knowledge base. +func demoDocs() []doc { + var out []doc + add := func(path, body string) { out = append(out, doc{path, body}) } + + add("wiki/q3-findings.md", q3Findings) + add("wiki/runbooks/incident-response.md", incidentResponse) + add("wiki/onboarding/first-week.md", firstWeek) + + // Filler with real-looking names. Bodies follow a per-folder shape so a + // reader who opens one sees something plausible rather than lorem ipsum. + type group struct { + dir string + heading string + names []string + } + groups := []group{ + {"wiki/onboarding", "Onboarding", []string{ + "engineering-setup", "who-does-what", "glossary", "tools-and-access", + "first-pull-request", "how-we-write-docs", "meeting-culture", "expenses", + "security-basics", "support-rotation", "vacation-policy", + }}, + {"wiki/architecture", "Architecture", []string{ + "system-overview", "auth-and-sessions", "data-model", "event-pipeline", + "storage-layout", "caching-strategy", "multi-region", "rate-limiting", + "background-jobs", "search-indexing", "webhooks-delivery", "observability", + "secrets-management", "migration-strategy", + }}, + {"wiki/runbooks", "Runbook", []string{ + "deploy-and-rollback", "database-failover", "oncall-handoff", + "restore-from-backup", "rotate-credentials", "scale-up-workers", + "clear-stuck-queue", "expired-certificate", "region-evacuation", + "data-export-request", "hotfix-process", "postmortem-template", + "paging-policy", "load-shedding", "cache-flush", + }}, + {"wiki/api", "API", []string{ + "rest-conventions", "authentication", "errors", "pagination", + "rate-limits", "webhooks", "versioning", "idempotency", + "batch-endpoints", "sdk-guidelines", "deprecation-policy", + "changelog", "sandbox-environment", + }}, + {"wiki/decisions", "Decision record", []string{ + "0001-postgres-over-dynamo", "0002-monorepo", "0003-typescript-everywhere", + "0004-no-graphql", "0005-queue-choice", "0006-feature-flags", + "0007-tenant-isolation", "0008-append-only-journals", "0009-object-storage", + "0010-auth-provider", "0011-observability-stack", "0012-release-cadence", + "0013-pricing-model", "0014-support-tiers", "0015-data-retention", + "0016-mobile-strategy", "0017-i18n", "0018-schema-migrations", + }}, + {"docs/product", "Product", []string{ + "pricing-v2", "roadmap-h2", "personas", "activation-metrics", + "onboarding-flow", "trial-experiment", "churn-drivers", "packaging", + "competitive-positioning", "feature-requests", "beta-program", + "launch-checklist", "success-metrics", "pricing-faq", + }}, + {"docs/design", "Design", []string{ + "design-system", "brand-voice", "iconography", "empty-states", + "motion-principles", "accessibility", "dark-mode", "form-patterns", + "illustration-style", + }}, + {"notes/research", "Research", []string{ + "competitive-landscape", "user-interviews-q2", "pricing-sensitivity", + "churn-interviews", "market-sizing", "buyer-personas", "win-loss-review", + "support-ticket-themes", "nps-verbatims", "usability-round-3", + "agent-usage-patterns", "enterprise-requirements", + }}, + {"shared/reports", "Report", []string{ + "board-update-q3", "churn-analysis", "revenue-review", "hiring-plan", + "security-review", "uptime-report", "cost-breakdown", "growth-review", + "customer-health", "quarterly-okrs", "annual-plan", "budget-forecast", + "partner-review", + }}, + } + for _, g := range groups { + for _, n := range g.names { + out = append(out, doc{ + path: g.dir + "/" + n + ".md", + body: fillerDoc(g.heading, n), + }) + } + } + + // Dated meeting notes: the long tail every wiki has. + start := time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC) + kinds := []string{"standup", "retro", "planning", "design-review", "incident-review"} + for i := 0; i < 60; i++ { + d := start.AddDate(0, 0, i*3) + kind := kinds[i%len(kinds)] + out = append(out, doc{ + path: fmt.Sprintf("notes/meetings/%s-%s.md", d.Format("2006-01-02"), kind), + body: fillerDoc("Meeting", fmt.Sprintf("%s %s", d.Format("Jan 2"), kind)), + }) + } + return out +} + +// fillerDoc renders a plausible short document for the long tail. +func fillerDoc(heading, slug string) string { + title := strings.ToUpper(slug[:1]) + strings.ReplaceAll(slug[1:], "-", " ") + return fmt.Sprintf(`# %s + +_%s · owned by the platform team_ + +## Summary + +Short context on %s so anyone — human or agent — can act without asking. + +## Details + +- What it covers and who it affects +- The constraint that made us choose this +- What to do when it changes + +## Related + +See the rest of the %s section. +`, title, heading, title, strings.ToLower(heading)) +} + +// --- hand-written documents (these are the ones that get screenshotted) --- + +const q3Findings = `# Q3 findings + +_Written by Claude on Snow's machine · reviewed by Alice_ + +Churn is concentrated in self-serve accounts that never reach a second +seat. Everything below follows from that one fact. + +## Headline numbers + +| Metric | Q2 | Q3 | Change | +| --- | --- | --- | --- | +| Net revenue retention | 104% | 97% | **−7 pts** | +| Self-serve churn | 4.1% | 6.8% | +2.7 pts | +| Team-plan churn | 1.9% | 1.7% | −0.2 pts | +| Median seats at churn | 1.0 | 1.0 | — | + +## What we learned + +1. **Single-seat accounts churn 4× faster.** Accounts that never invite a + teammate leave within 40 days on average. Accounts that add a second seat + in week one almost never leave. +2. **The aha requires two people.** Every retained account has at least one + shared folder with activity from more than one machine. +3. **Price is not the driver.** Only 6% of exit surveys mention cost; + 41% say "never really got started". + +## What we are doing about it + +- Make the second seat reachable without a credit card +- Move the invite step into the first session, not the settings page +- Instrument time-to-second-device as the activation metric + +## Open questions + +- Does a 3-seat free tier cannibalize Team, or feed it? +- Can onboarding create the second seat automatically for an agent? +` + +const incidentResponse = `# Incident response runbook + +How we handle a production incident, start to finish. **Agents: read this +before touching anything during an active incident.** + +## Severity levels + +| Level | Meaning | Response | +| --- | --- | --- | +| SEV-1 | Customer-facing outage | Page on-call, all hands | +| SEV-2 | Degraded service | On-call handles, updates hourly | +| SEV-3 | Internal breakage | Ticket, fix within the week | + +## First 15 minutes + +1. Acknowledge the page and open an incident channel. +2. Assign an incident commander — one voice, one timeline. +3. Freeze deploys: + +` + "```sh\ndeployctl freeze --reason \"SEV-1 in progress\"\n```" + ` + +4. Post the first status update **before** debugging. + +## After the incident + +- Write the retro within 48 hours (blameless, timeline-first) +- File follow-ups as issues with the ` + "`incident`" + ` label +- Update this runbook if reality disagreed with it +` + +const firstWeek = `# Your first week + +Welcome. This page is the short version; everything else is linked from here. + +## Day one + +- Get access to the wiki, the repo, and the on-call rotation +- Run the setup script and open a pull request that changes one line +- Say hello in the team channel + +## Day two to five + +1. Pair with someone on a real ticket +2. Read the [architecture overview](../architecture/system-overview.md) +3. Shadow an on-call handoff + +## How we work + +We write things down. If you asked a question and the answer was not in the +wiki, the answer belongs in the wiki — your agent can add it for you. +`