webapp: seed the demo hub with a realistic wiki

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA5pQH92cxk5SfiYJeTUjK
This commit is contained in:
Snow Lee
2026-07-19 15:28:45 -07:00
co-authored by Claude Opus 4.8
parent afac6bef65
commit 52f595efc0
+274 -56
View File
@@ -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.
`