diff --git a/docs/superpowers/plans/2026-07-06-local-seed-data.md b/docs/superpowers/plans/2026-07-06-local-seed-data.md new file mode 100644 index 0000000..994c698 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-local-seed-data.md @@ -0,0 +1,609 @@ +# Local Seed Data Implementation Plan + +> **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:** Restore a single local dataset that lights up every Canopy surface (My Work, Feed, Docs, Roadmap, Triage, Search) against the current schema, loaded by one command through the existing `DEV_LOGIN` local-mode toggle. + +**Architecture:** JSON fixture files under `fixtures/dev/` are the source of truth. A pure builder (`scripts/seed/build.mjs`) turns them into escaped SQL statements; a CLI loader (`scripts/seed-dev.mjs`) resets local D1 and applies them via `wrangler d1 execute --local` (refusing `--remote`). The app's D1 read paths are unchanged — FTS5 triggers auto-index on insert, so Search needs no direct seeding. A Vitest guard runs the same builder against the Miniflare D1 harness and asserts every surface returns data. + +**Tech Stack:** Node ESM (`.mjs`) dev scripts, Wrangler D1 CLI, Vitest + `cloudflare:test` Miniflare D1, TypeScript worker read paths. + +## Global Constraints + +- Dev identity is `AndresL230` — a migration-seeded `people` row and the `ADMIN_LOGINS` value. All seeded PR/issue events carry `subject_login: "AndresL230"`; `.dev.vars` must set `DEV_LOGIN=AndresL230`. +- Controlled vocabulary is fixed: sections ∈ `reference | context | decisions | needs-triage`; tags ∈ `auth | architecture | infra | api | ui | data` (`shared/vocabulary.ts`). Fixtures MUST use only these. +- The reset statement list MUST mirror the truncation in `test/apply-migrations.ts:18` verbatim (same FK-safe order, same `people` re-seed). It lives in exactly one place: `scripts/seed/reset.mjs`. +- The loader only ever targets LOCAL D1. It MUST refuse `--remote`. No fixtures or seed code are imported by the worker (`src/`). +- `events.raw` MUST match the shape `src/tools/mywork.ts` parses: PR → `{ pr: { number, title, body, html_url, merged, merged_at, closed_at, user:{login}, milestone } }`; issue → `{ action, issue: { number, title, body, html_url, state, updated_at, user:{login}, assignees:[{login}], labels:[string], milestone } }`. + +## File Structure + +- Create `scripts/seed/reset.mjs` — canonical reset statement array (mirrors `test/apply-migrations.ts`). +- Create `scripts/seed/build.mjs` — pure `buildSeedStatements(fx)` + `targetsRemote(argv)`; no fs, no network, no worker imports. +- Create `scripts/seed-dev.mjs` — CLI: refuse `--remote`, read fixtures, build SQL, write temp `.sql`, run wrangler against local D1. +- Create `fixtures/dev/docs.json`, `feed.json`, `adrs.json`, `triage.json`, `roadmap.json`, `events.json`, `identity.json` — the dataset. +- Create `test/seed-build.test.ts` — unit test for the builder's escaping/coverage. +- Create `test/seed-coverage.test.ts` — the guard: seed Miniflare D1, assert every surface non-empty. +- Modify `package.json` — add `"seed"` script. +- Modify `.dev.vars` — set `DEV_LOGIN=AndresL230` (git-ignored; local only). + +--- + +## Task 1: Seed builder & canonical reset + +Pure, dependency-free module that turns fixture objects into escaped SQL. Unit-tested with tiny inline fixtures — no real files yet. + +**Files:** +- Create: `scripts/seed/reset.mjs` +- Create: `scripts/seed/build.mjs` +- Test: `test/seed-build.test.ts` + +**Interfaces:** +- Produces: `buildSeedStatements(fx) → string[]` where `fx = { docs, feed, adrs, triage, roadmap, events, identity }` (each the parsed JSON of the matching fixture file, any key optional). Returns fully-escaped, standalone SQL statements (no trailing `;`), reset statements first. +- Produces: `targetsRemote(argv: string[]) → boolean` — true iff `--remote` is present. +- Produces: `RESET_STATEMENTS: string[]` from `reset.mjs`. + +- [ ] **Step 1: Write `scripts/seed/reset.mjs`** + +```js +// Canonical local-D1 reset. MUST mirror the beforeEach truncation in +// test/apply-migrations.ts (same FK-safe delete order, same people re-seed). +// If a migration adds a table to that truncation, add it here too. +export const RESET_STATEMENTS = [ + "DELETE FROM processed_items", + "DELETE FROM pr_summaries", + "DELETE FROM issue_summaries", + "DELETE FROM events", + "DELETE FROM milestone_progress", + "DELETE FROM plan_versions", + "UPDATE plan SET narrative = '', current_version = 0, updated_at = NULL, updated_by = NULL", + "DELETE FROM milestone_proposals", + "DELETE FROM milestones", + "DELETE FROM doc_versions", + "DELETE FROM docs", + "DELETE FROM feed", + "DELETE FROM entry_tags", + "DELETE FROM adrs", + "DELETE FROM needs_triage", + "DELETE FROM identity_tasks", + "DELETE FROM people", + "INSERT INTO people (login, person) VALUES ('AndresL230', 'Andres'), ('Jose-Gael-Cruz-Lopez', 'Jose'), ('lpcooper-arch', 'Luke'), ('Darkest-Teddy', 'Jack')", + "DELETE FROM sessions", + "DELETE FROM mcp_tokens", + "DELETE FROM users", +]; +``` + +- [ ] **Step 2: Write `scripts/seed/build.mjs`** + +```js +import { RESET_STATEMENTS } from "./reset.mjs"; + +// SQL string literal: wrap in single quotes, double any embedded quote. NULL for +// null/undefined. JSON.stringify guarantees no literal newlines in embedded JSON. +const q = (v) => (v === null || v === undefined ? "NULL" : `'${String(v).replace(/'/g, "''")}'`); +const num = (v) => (v === null || v === undefined ? "NULL" : String(Number(v))); +const jsonLit = (obj) => (obj === null || obj === undefined ? "NULL" : q(JSON.stringify(obj))); + +/** True iff the loader was asked to touch remote D1 — the loader must refuse. */ +export const targetsRemote = (argv) => argv.includes("--remote"); + +/** + * Turn parsed fixture objects into standalone, escaped SQL statements (no + * trailing ";"), reset statements first. FK-safe ordering: events before + * pr_summaries, milestones before milestone_progress. + */ +export function buildSeedStatements(fx) { + const s = [...RESET_STATEMENTS]; + + for (const d of fx.docs?.docs ?? []) { + s.push( + `INSERT INTO docs (slug, section, space, title, body, current_version, updated_at, updated_by) VALUES (` + + `${q(d.slug)}, ${q(d.section)}, ${q(d.space ?? "canopy")}, ${q(d.title)}, ${q(d.body)}, ${num(d.current_version)}, ${q(d.updated_at)}, ${q(d.updated_by)})` + ); + for (const v of d.versions ?? []) { + s.push( + `INSERT INTO doc_versions (slug, version, body, summary, status, confidence, created_at, created_by, change_kind, base_version, low_confidence) VALUES (` + + `${q(d.slug)}, ${num(v.version)}, ${q(v.body)}, ${q(v.summary)}, ${q(v.status)}, ${q(v.confidence)}, ${q(v.created_at)}, ${q(v.created_by)}, ${q(v.change_kind)}, ${num(v.base_version)}, ${num(v.low_confidence ?? 0)})` + ); + } + } + + for (const f of fx.feed?.feed ?? []) { + s.push( + `INSERT INTO feed (id, author, summary, body, artifacts, created_at) VALUES (` + + `${num(f.id)}, ${q(f.author)}, ${q(f.summary)}, ${q(f.body)}, ${jsonLit(f.artifacts)}, ${q(f.created_at)})` + ); + for (const t of f.tags ?? []) { + s.push(`INSERT INTO entry_tags (tag, entry_type, entry_id) VALUES (${q(t)}, 'feed', ${q(String(f.id))})`); + } + } + + for (const a of fx.adrs?.adrs ?? []) { + s.push( + `INSERT INTO adrs (id, title, context, decision, rationale, status, confidence, created_at, created_by) VALUES (` + + `${num(a.id)}, ${q(a.title)}, ${q(a.context)}, ${q(a.decision)}, ${q(a.rationale)}, ${q(a.status)}, ${q(a.confidence)}, ${q(a.created_at)}, ${q(a.created_by)})` + ); + } + + for (const t of fx.triage?.needs_triage ?? []) { + s.push( + `INSERT INTO needs_triage (raw, reason, source_author, resolved, created_at) VALUES (` + + `${q(t.raw)}, ${q(t.reason)}, ${q(t.source_author)}, ${num(t.resolved ?? 0)}, ${q(t.created_at)})` + ); + } + + for (const m of fx.triage?.milestone_proposals ?? []) { + s.push( + `INSERT INTO milestone_proposals (title, target_date, status, github_ref, change_summary, confidence, staged_status, created_at, created_by) VALUES (` + + `${q(m.title)}, ${q(m.target_date)}, ${q(m.status)}, ${q(m.github_ref)}, ${q(m.change_summary)}, ${q(m.confidence)}, ${q(m.staged_status ?? "staged")}, ${q(m.created_at)}, ${q(m.created_by)})` + ); + } + + const rm = fx.roadmap; + if (rm) { + s.push( + `UPDATE plan SET narrative = ${q(rm.narrative)}, current_version = ${num(rm.version)}, updated_at = ${q(rm.updated_at)}, updated_by = ${q(rm.updated_by)} WHERE id = 1` + ); + s.push( + `INSERT INTO plan_versions (version, narrative, milestones_json, created_at, created_by) VALUES (` + + `${num(rm.version)}, ${q(rm.narrative)}, ${jsonLit(rm.milestones ?? [])}, ${q(rm.updated_at)}, ${q(rm.updated_by)})` + ); + for (const m of rm.milestones ?? []) { + s.push( + `INSERT INTO milestones (id, title, description, phase, target_date, status, github_ref, created_at, created_by, updated_at) VALUES (` + + `${num(m.id)}, ${q(m.title)}, ${q(m.description)}, ${q(m.phase)}, ${q(m.target_date)}, ${q(m.status)}, ${q(m.github_ref)}, ${q(m.created_at)}, ${q(m.created_by)}, ${q(m.updated_at)})` + ); + if (m.progress) { + s.push( + `INSERT INTO milestone_progress (milestone_id, closed, total, source, computed_at) VALUES (` + + `${num(m.id)}, ${num(m.progress.closed)}, ${num(m.progress.total)}, ${q(m.progress.source ?? "recompute")}, ${q(m.progress.computed_at)})` + ); + } + } + } + + for (const e of fx.events?.events ?? []) { + s.push( + `INSERT INTO events (semantic_key, event_type, ref_number, subject_login, raw, provenance, occurred_at, recorded_at, recorded_by) VALUES (` + + `${q(e.semantic_key)}, ${q(e.event_type)}, ${num(e.ref_number)}, ${q(e.subject_login)}, ${jsonLit(e.raw)}, ${q(e.provenance ?? "backfill")}, ${q(e.occurred_at)}, ${q(e.recorded_at)}, ${q(e.recorded_by ?? "github-webhook")})` + ); + if (e.pr_summary) { + s.push( + `INSERT INTO pr_summaries (semantic_key, pr_number, summary, model, created_at) VALUES (` + + `${q(e.semantic_key)}, ${num(e.ref_number)}, ${q(e.pr_summary)}, 'excerpt', ${q(e.recorded_at)})` + ); + } + if (e.issue_summary) { + s.push( + `INSERT INTO issue_summaries (issue_number, summary, model, created_at) VALUES (` + + `${num(e.ref_number)}, ${q(e.issue_summary)}, 'excerpt', ${q(e.recorded_at)})` + ); + } + } + + for (const t of fx.identity?.identity_tasks ?? []) { + s.push( + `INSERT INTO identity_tasks (login, first_seen, status, resolved_at, resolved_by) VALUES (` + + `${q(t.login)}, ${q(t.first_seen)}, ${q(t.status ?? "pending")}, ${q(t.resolved_at)}, ${q(t.resolved_by)})` + ); + } + + return s; +} +``` + +- [ ] **Step 3: Write the failing test `test/seed-build.test.ts`** + +```ts +import { describe, it, expect } from "vitest"; +import { buildSeedStatements, targetsRemote } from "../scripts/seed/build.mjs"; +import { RESET_STATEMENTS } from "../scripts/seed/reset.mjs"; + +describe("buildSeedStatements", () => { + it("prepends the canonical reset, in order", () => { + const out = buildSeedStatements({}); + expect(out.slice(0, RESET_STATEMENTS.length)).toEqual(RESET_STATEMENTS); + }); + + it("escapes single quotes in values", () => { + const out = buildSeedStatements({ docs: { docs: [{ slug: "s", section: "reference", title: "O'Hara", body: "b", current_version: 1, updated_at: "t", updated_by: "u", versions: [] }] } }); + const insert = out.find((s) => s.startsWith("INSERT INTO docs")); + expect(insert).toContain("'O''Hara'"); + }); + + it("serializes event raw as a JSON string literal with no literal newline", () => { + const out = buildSeedStatements({ events: { events: [{ semantic_key: "k", event_type: "pr_merged", ref_number: 1, subject_login: "AndresL230", provenance: "backfill", occurred_at: "t", recorded_at: "t", raw: { pr: { body: "line1\nline2" } } }] } }); + const insert = out.find((s) => s.startsWith("INSERT INTO events")); + expect(insert).toContain("line1\\nline2"); + expect(insert.includes("\n")).toBe(false); + }); + + it("emits a milestone_progress insert only when progress is present", () => { + const withP = buildSeedStatements({ roadmap: { narrative: "n", version: 1, milestones: [{ id: 1, title: "m", target_date: "2026-01-01", status: "done", progress: { closed: 2, total: 2, computed_at: "t" } }] } }); + const without = buildSeedStatements({ roadmap: { narrative: "n", version: 1, milestones: [{ id: 2, title: "m2", target_date: "2026-01-01", status: "upcoming" }] } }); + expect(withP.some((s) => s.startsWith("INSERT INTO milestone_progress"))).toBe(true); + expect(without.some((s) => s.startsWith("INSERT INTO milestone_progress"))).toBe(false); + }); + + it("targetsRemote detects the --remote flag", () => { + expect(targetsRemote(["--remote"])).toBe(true); + expect(targetsRemote(["--local"])).toBe(false); + }); +}); +``` + +- [ ] **Step 4: Run the test — expect FAIL (module not found), then PASS after Steps 1–2** + +Run: `npx vitest run test/seed-build.test.ts` +Expected: PASS (all 5 assertions). If run before Steps 1–2, FAIL with a module-resolution error. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/seed/reset.mjs scripts/seed/build.mjs test/seed-build.test.ts +git commit -m "feat(seed): pure SQL builder + canonical reset for local seed" +``` + +--- + +## Task 2: Dev fixtures + coverage guard + +Author the seven fixture files, then a test that runs the real builder against the Miniflare D1 and asserts every surface returns data. This is the load-bearing task — the green test proves the seed lights up My Work, Roadmap, Search, Triage, and Feed. + +**Files:** +- Create: `fixtures/dev/docs.json`, `fixtures/dev/feed.json`, `fixtures/dev/adrs.json`, `fixtures/dev/triage.json`, `fixtures/dev/roadmap.json`, `fixtures/dev/events.json`, `fixtures/dev/identity.json` +- Test: `test/seed-coverage.test.ts` + +**Interfaces:** +- Consumes: `buildSeedStatements` (Task 1); worker read paths `getMyWork`, `get_plan`, `query`, `get_feed`, `list_proposals`, `list_needs_triage`, `list_adrs`, `list_identity_tasks`. + +- [ ] **Step 1: Write `fixtures/dev/docs.json`** + +```json +{ + "docs": [ + { + "slug": "mcp-server", "section": "reference", "space": "canopy", + "title": "MCP Server", "current_version": 2, + "updated_at": "2026-06-23T00:00:00Z", "updated_by": "meilin", + "body": "The MCP server is the only write path into Canopy. Coding agents connect over the Model Context Protocol and post session output through a typed contract. Every request carries a bearer token, compared in constant time. Token rotation is tracked in #142.", + "versions": [ + { "version": 1, "body": "v1 body — initial page.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-04-01T00:00:00Z", "created_by": "devraj" }, + { "version": 2, "body": "The MCP server is the only write path into Canopy. Coding agents connect over the Model Context Protocol and post session output through a typed contract. Every request carries a bearer token, compared in constant time. Token rotation is tracked in #142.", "summary": "Documented the typed contract", "status": "promoted", "confidence": "high", "created_at": "2026-06-23T00:00:00Z", "created_by": "meilin" }, + { "version": 3, "body": "The MCP server is the only write path. Tokens are compared in constant time. Rotation: revoke and re-mint from Settings.", "summary": "Clarify token rotation", "status": "staged", "confidence": "high", "created_at": "2026-06-24T00:00:00Z", "created_by": "meilin", "change_kind": "edit", "base_version": 2 } + ] + }, + { + "slug": "product-overview", "section": "context", "space": "canopy", + "title": "Product Overview", "current_version": 1, + "updated_at": "2026-06-10T00:00:00Z", "updated_by": "sanaok", + "body": "Canopy is the shared source of truth and working memory for Sapling, a four-person software team.", + "versions": [ + { "version": 1, "body": "Canopy is the shared source of truth and working memory for Sapling, a four-person software team.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-06-10T00:00:00Z", "created_by": "sanaok" } + ] + }, + { + "slug": "postgres-store", "section": "decisions", "space": "canopy", + "title": "ADR-001 · Postgres for the store", "current_version": 1, + "updated_at": "2026-06-02T00:00:00Z", "updated_by": "sanaok", + "body": "Use a single Postgres instance as the store. Sections, versions, feed entries, and decisions are all rows.", + "versions": [ + { "version": 1, "body": "Use a single Postgres instance as the store. Sections, versions, feed entries, and decisions are all rows.", "summary": "Initial ADR", "status": "promoted", "confidence": "high", "created_at": "2026-06-02T00:00:00Z", "created_by": "sanaok" } + ] + } + ] +} +``` + +- [ ] **Step 2: Write `fixtures/dev/feed.json`** + +```json +{ + "feed": [ + { "id": 1, "author": "meilin", "summary": "Implemented Mermaid + D2 rendering in the Docs reader", "body": "Fenced mermaid and d2 blocks now render to inline SVG on the client, with the source block as a fallback.", "artifacts": { "prs": ["145"], "commits": ["7b1e004"], "issues": [] }, "created_at": "2026-06-25T11:30:00Z", "tags": ["ui", "architecture"] }, + { "id": 2, "author": "AndresL230", "summary": "Switched MCP token comparison to constant-time", "body": "Replaces the early-return string compare flagged in #138. Adds a timing test that fails on the old implementation.", "artifacts": { "prs": ["142"], "commits": ["a3f9c21"], "issues": [138] }, "created_at": "2026-06-25T10:00:00Z", "tags": ["auth"] }, + { "id": 3, "author": "sanaok", "summary": "Drafted ADR: append-only feed as the system of record", "body": null, "artifacts": { "prs": [], "commits": [], "issues": [150] }, "created_at": "2026-06-25T08:00:00Z", "tags": ["architecture", "data"] } + ] +} +``` + +- [ ] **Step 3: Write `fixtures/dev/adrs.json`** + +```json +{ + "adrs": [ + { "id": 1, "title": "Agent write contract", "context": "Agents post to Canopy at the end of a session over MCP. Without a fixed contract, writes arrived in inconsistent shapes.", "decision": "Agents write through a typed contract; every write lands STAGED and unplaceable writes go to Triage.", "rationale": "Keeping every agent write non-destructive and staged preserves the human review gate.", "status": "draft", "confidence": "high", "created_at": "2026-06-24T00:00:00Z", "created_by": "devraj" }, + { "id": 2, "title": "Single-accent color system", "context": "Early mocks used several accent colors and gray surfaces.", "decision": "One electric-green accent with two tuned values; no gray surfaces.", "rationale": "A single accent keeps live and active state unambiguous.", "status": "ratified", "confidence": "high", "created_at": "2026-06-12T00:00:00Z", "created_by": "devraj" } + ] +} +``` + +- [ ] **Step 4: Write `fixtures/dev/triage.json`** + +```json +{ + "needs_triage": [ + { "raw": "The MCP server should rate-limit per token. Proposed 60 writes/min burst, 600/hour sustained.", "reason": "No clear section. Mixes a Reference description with an unmade Decision about limits.", "source_author": "jose-a", "resolved": 0, "created_at": "2026-06-25T09:00:00Z" }, + { "raw": "Onboarding: 1) get added to the org, 2) sign in to Canopy, 3) mint an MCP token in Settings.", "reason": "Ambiguous between Context (team process) and Reference (how-to). Needs a human to choose.", "source_author": "meilin", "resolved": 0, "created_at": "2026-06-24T00:00:00Z" } + ], + "milestone_proposals": [ + { "title": "Self-host & deploy guide", "target_date": "2026-09-20", "status": "upcoming", "github_ref": null, "change_summary": "Run the whole store on your own infrastructure.", "confidence": "high", "staged_status": "staged", "created_at": "2026-06-25T00:00:00Z", "created_by": "devraj" } + ] +} +``` + +- [ ] **Step 5: Write `fixtures/dev/roadmap.json`** + +```json +{ + "narrative": "## Canopy roadmap\n\nCanopy is the team's working memory: agents propose context through a reconciling gate and humans confirm the consequential changes. The near-term focus is trustworthy capture (staged writes, replay-safe) and the read-side brain (ranked FTS across docs, decisions, feed, and roadmap).", + "version": 1, + "updated_at": "2026-06-26T00:00:00Z", + "updated_by": "AndresL230", + "milestones": [ + { "id": 1, "title": "MCP write contract — GA", "description": "Typed, staged-only writes for every agent over MCP.", "phase": "Now", "target_date": "2026-04-30", "status": "done", "github_ref": "1", "created_at": "2026-03-01T00:00:00Z", "created_by": "sanaok", "updated_at": "2026-04-30T00:00:00Z", "progress": { "closed": 6, "total": 6, "source": "recompute", "computed_at": "2026-06-26T00:00:00Z" } }, + { "id": 2, "title": "Token rotation & audit log", "description": "Constant-time comparison, revoke, and a read trail.", "phase": "Weeks 3-4", "target_date": "2026-06-10", "status": "in_progress", "github_ref": "[160,162,175]", "created_at": "2026-05-01T00:00:00Z", "created_by": "AndresL230", "updated_at": null, "progress": { "closed": 2, "total": 3, "source": "recompute", "computed_at": "2026-06-26T00:00:00Z" } }, + { "id": 3, "title": "Semantic search ranking", "description": "Mixed feed/doc results ordered by meaning, not match.", "phase": "Next", "target_date": "2026-07-18", "status": "upcoming", "github_ref": null, "created_at": "2026-06-01T00:00:00Z", "created_by": "meilin", "updated_at": null } + ] +} +``` + +- [ ] **Step 6: Write `fixtures/dev/events.json`** (subject `AndresL230` so My Work populates; one unmapped-login PR feeds the identity sample) + +```json +{ + "events": [ + { + "semantic_key": "gh:pr:145:merged", "event_type": "pr_merged", "ref_number": 145, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-25T11:30:00Z", "recorded_at": "2026-06-25T11:31:00Z", + "raw": { "pr": { "number": 145, "title": "Render Mermaid + D2 in the Docs reader", "body": "## What\n\nFenced mermaid/d2 blocks render to inline SVG client-side.\n\nCloses #133.", "html_url": "https://github.com/SaplingLearn/sapling/pull/145", "merged": true, "merged_at": "2026-06-25T11:30:00Z", "closed_at": "2026-06-25T11:30:00Z", "user": { "login": "AndresL230" }, "milestone": null } }, + "pr_summary": "Renders fenced mermaid and d2 diagram blocks to inline SVG in the Docs reader, with the source block as a fallback." + }, + { + "semantic_key": "gh:pr:142:merged", "event_type": "pr_merged", "ref_number": 142, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-25T10:00:00Z", "recorded_at": "2026-06-25T10:01:00Z", + "raw": { "pr": { "number": 142, "title": "Constant-time MCP token comparison", "body": "Replaces the early-return string compare flagged in #138. Adds a timing test.", "html_url": "https://github.com/SaplingLearn/sapling/pull/142", "merged": true, "merged_at": "2026-06-25T10:00:00Z", "closed_at": "2026-06-25T10:00:00Z", "user": { "login": "AndresL230" }, "milestone": null } }, + "pr_summary": "Switches MCP bearer-token comparison to a constant-time check and adds a timing test that fails on the old implementation." + }, + { + "semantic_key": "gh:issue:160:assigned:AndresL230:2026-06-24T14:00:00Z", "event_type": "issue", "ref_number": 160, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-24T14:00:00Z", "recorded_at": "2026-06-24T14:01:00Z", + "raw": { "action": "assigned", "issue": { "number": 160, "title": "[P1] Add token revoke endpoint", "body": "Revoke a bearer token from Settings; hash-match then soft-delete.", "html_url": "https://github.com/SaplingLearn/sapling/issues/160", "state": "open", "updated_at": "2026-06-24T14:00:00Z", "user": { "login": "sanaok" }, "assignees": [{ "login": "AndresL230" }], "labels": ["auth"], "milestone": { "number": 2, "open_issues": 1, "closed_issues": 2 } } }, + "issue_summary": "Build the Settings action that revokes a bearer token by hash-match then soft-delete." + }, + { + "semantic_key": "gh:issue:175:assigned:AndresL230:2026-06-25T09:30:00Z", "event_type": "issue", "ref_number": 175, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-25T09:30:00Z", "recorded_at": "2026-06-25T09:31:00Z", + "raw": { "action": "assigned", "issue": { "number": 175, "title": "[P2] Roadmap progress cache backstop", "body": "Scheduled recompute of milestone progress in case a webhook delivery is missed.", "html_url": "https://github.com/SaplingLearn/sapling/issues/175", "state": "open", "updated_at": "2026-06-25T09:30:00Z", "user": { "login": "meilin" }, "assignees": [{ "login": "AndresL230" }], "labels": ["infra"], "milestone": { "number": 2, "open_issues": 1, "closed_issues": 2 } } }, + "issue_summary": "Add a scheduled recompute backstop for the milestone progress cache." + }, + { + "semantic_key": "gh:pr:151:merged", "event_type": "pr_merged", "ref_number": 151, "subject_login": "octo-drifter", + "provenance": "backfill", "occurred_at": "2026-06-22T16:00:00Z", "recorded_at": "2026-06-22T16:01:00Z", + "raw": { "pr": { "number": 151, "title": "Fix typo in onboarding doc", "body": "One-line copy fix.", "html_url": "https://github.com/SaplingLearn/sapling/pull/151", "merged": true, "merged_at": "2026-06-22T16:00:00Z", "closed_at": "2026-06-22T16:00:00Z", "user": { "login": "octo-drifter" }, "milestone": null } }, + "pr_summary": "Fixes a one-line typo in the onboarding doc." + } + ] +} +``` + +- [ ] **Step 7: Write `fixtures/dev/identity.json`** (unmapped login `octo-drifter` from the PR above) + +```json +{ + "identity_tasks": [ + { "login": "octo-drifter", "first_seen": "2026-06-22T16:01:00Z", "status": "pending" } + ] +} +``` + +- [ ] **Step 8: Write the failing test `test/seed-coverage.test.ts`** + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +import { env } from "cloudflare:test"; +import { buildSeedStatements } from "../scripts/seed/build.mjs"; +import { getMyWork } from "../src/tools/mywork"; +import { get_plan } from "../src/tools/plan"; +import { query, get_feed, list_proposals, list_needs_triage, list_adrs, list_identity_tasks } from "../src/tools/reads"; +import docs from "../fixtures/dev/docs.json"; +import feed from "../fixtures/dev/feed.json"; +import adrs from "../fixtures/dev/adrs.json"; +import triage from "../fixtures/dev/triage.json"; +import roadmap from "../fixtures/dev/roadmap.json"; +import events from "../fixtures/dev/events.json"; +import identity from "../fixtures/dev/identity.json"; + +const fx = { docs, feed, adrs, triage, roadmap, events, identity }; + +beforeEach(async () => { + for (const stmt of buildSeedStatements(fx)) { + await env.DB.prepare(stmt).run(); + } +}); + +describe("dev seed lights up every surface", () => { + it("My Work: previous activity + to-dos for AndresL230", async () => { + const mw = await getMyWork(env.DB, "AndresL230"); + expect(mw.degraded).toBe(false); + expect(mw.person).toBe("Andres"); + expect(mw.previousActivity.length).toBeGreaterThan(0); + expect(mw.todo.length).toBeGreaterThan(0); + // Priority tag parsed + stripped from an assigned issue. + expect(mw.todo.some((t) => t.priority === "P1")).toBe(true); + }); + + it("Roadmap: narrative + milestones carrying progress", async () => { + const plan = await get_plan(env.DB); + expect(plan.narrative.length).toBeGreaterThan(0); + expect(plan.milestones.length).toBe(3); + expect(plan.milestones.some((m) => m.progress && m.progress.total > 0)).toBe(true); + }); + + it("Search: ranked hits for a known term", async () => { + const r = await query(env.DB, { q: "MCP", include_staged: true }); + expect(r.primary.length).toBeGreaterThan(0); + }); + + it("Feed: tagged entries present", async () => { + expect((await get_feed(env.DB, {})).length).toBeGreaterThan(0); + }); + + it("Triage: all four queues populated", async () => { + expect((await list_proposals(env.DB)).length).toBeGreaterThan(0); + expect((await list_needs_triage(env.DB)).length).toBeGreaterThan(0); + expect((await list_adrs(env.DB, "draft")).length).toBeGreaterThan(0); + expect((await list_identity_tasks(env.DB)).length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 9: Run the coverage test — expect FAIL first, then PASS once fixtures are correct** + +Run: `npx vitest run test/seed-coverage.test.ts` +Expected: PASS (all 5 tests). A failure here means a fixture shape is wrong (most likely an `events.raw` mismatch or an out-of-vocab tag/section) — fix the fixture, not the read path. + +- [ ] **Step 10: Commit** + +```bash +git add fixtures/dev/ test/seed-coverage.test.ts +git commit -m "feat(seed): dev fixtures + coverage guard across every surface" +``` + +--- + +## Task 3: CLI loader, npm script, and DEV_LOGIN wiring + +Wrap the builder in a CLI that resets and seeds LOCAL D1, refusing `--remote`; expose it as `npm run seed`; point `DEV_LOGIN` at the seeded identity. Ends with an end-to-end verification against a running `wrangler dev`. + +**Files:** +- Create: `scripts/seed-dev.mjs` +- Modify: `package.json` (scripts) +- Modify: `.dev.vars` (`DEV_LOGIN=AndresL230`) + +**Interfaces:** +- Consumes: `buildSeedStatements`, `targetsRemote` (Task 1); the seven fixture files (Task 2). + +- [ ] **Step 1: Write `scripts/seed-dev.mjs`** + +```js +#!/usr/bin/env node +// Local-only seed loader. Reads fixtures/dev/*.json, builds escaped SQL via the +// shared builder, and applies it to LOCAL D1 through wrangler. Never touches +// remote D1 — it refuses --remote outright. +import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { buildSeedStatements, targetsRemote } from "./seed/build.mjs"; + +const argv = process.argv.slice(2); +if (targetsRemote(argv)) { + console.error("seed-dev: refusing --remote. This seed only ever targets LOCAL D1."); + process.exit(1); +} + +const dir = fileURLToPath(new URL("../fixtures/dev/", import.meta.url)); +const load = (name) => JSON.parse(readFileSync(join(dir, name), "utf8")); +const fx = { + docs: load("docs.json"), + feed: load("feed.json"), + adrs: load("adrs.json"), + triage: load("triage.json"), + roadmap: load("roadmap.json"), + events: load("events.json"), + identity: load("identity.json"), +}; + +const statements = buildSeedStatements(fx); +const sql = statements.map((s) => s + ";").join("\n"); + +const file = join(mkdtempSync(join(tmpdir(), "canopy-seed-")), "seed.sql"); +writeFileSync(file, sql, "utf8"); + +console.log(`seed-dev: applying ${statements.length} statements to LOCAL D1…`); +execFileSync("npx", ["wrangler", "d1", "execute", "canopy", "--local", `--file=${file}`], { stdio: "inherit" }); +console.log("seed-dev: done — local D1 seeded for every surface. Set DEV_LOGIN=AndresL230 and run `npm run dev`."); +``` + +- [ ] **Step 2: Add the `seed` script to `package.json`** + +In the `"scripts"` block, add (after `"db:migrate:remote"`): + +```json + "seed": "node scripts/seed-dev.mjs" +``` + +- [ ] **Step 3: Point `.dev.vars` at the seeded identity** + +Set the `DEV_LOGIN` line in `.dev.vars` to: + +``` +DEV_LOGIN=AndresL230 +``` + +- [ ] **Step 4: Verify the loader refuses remote** + +Run: `npm run seed -- --remote` +Expected: prints `seed-dev: refusing --remote…` and exits non-zero. Nothing is written. + +- [ ] **Step 5: Apply migrations + seed local D1** + +Run: +```bash +npm run db:migrate:local +npm run seed +``` +Expected: wrangler reports the statements executed against local D1 with no errors; final line `seed-dev: done — local D1 seeded for every surface.` + +- [ ] **Step 6: End-to-end verification against the app** + +Run `npm run dev`, then in a second shell exercise the seeded surfaces (DEV_LOGIN bypasses auth, so no cookie needed): + +```bash +curl -s localhost:8787/me/dashboard | head -c 400 # previousActivity + todo non-empty +curl -s localhost:8787/roadmap | head -c 400 # narrative + 3 milestones, progress present +curl -s "localhost:8787/search?q=MCP" | head -c 400 # ranked hits +curl -s localhost:8787/proposals | head -c 400 # staged mcp-server v3 +``` +Expected: each returns populated JSON. (`/search` defaults to `include_staged:false` — the staged doc v3 won't appear there, but the promoted docs will.) + +- [ ] **Step 7: Run the full suite + typecheck** + +Run: `npm test && npm run typecheck` +Expected: all green (new tests included; no type errors from the `.mjs`/JSON imports). + +- [ ] **Step 8: Commit** + +```bash +git add scripts/seed-dev.mjs package.json +git commit -m "feat(seed): npm run seed loader (local-only) + DEV_LOGIN wiring" +``` + +Note: `.dev.vars` is git-ignored — the `DEV_LOGIN` change is local only and is not committed. + +--- + +## Self-Review + +**1. Spec coverage:** +- JSON fixtures under `fixtures/dev/` → Task 2 (all seven files). ✓ +- `scripts/seed-dev.mjs` loader, reset-then-load, refuse `--remote` → Task 3 (Steps 1, 4) + reset in Task 1. ✓ +- `npm run seed` → Task 3 Step 2. ✓ +- Vitest coverage guard → Task 2 (`test/seed-coverage.test.ts`). ✓ +- Option A (JSON → D1, app reads D1 unchanged; FTS via triggers) → builder inserts base rows only; Search asserted via `query` with no direct FTS seeding. ✓ +- Identity wiring `DEV_LOGIN == people == subject_login` = `AndresL230` → Global Constraints + events fixtures + Task 3 Step 3. ✓ +- Coverage matrix (Docs staged version, Feed tags, Roadmap plan+phase+progress, My Work events+summaries, Triage four queues, Search) → fixtures in Task 2 + assertions in coverage test. ✓ +- Reset mirrors `test/apply-migrations.ts` → Task 1 `reset.mjs` with a cross-reference comment. ✓ +- Safety: no worker imports of seed/fixtures; loader local-only → builder/loader are `scripts/`-only; refusal tested. ✓ + +**2. Placeholder scan:** No TBD/TODO; every code and fixture step shows complete content; every command has an expected result. ✓ + +**3. Type/name consistency:** `buildSeedStatements` and `targetsRemote` are defined in Task 1 and consumed with the same names/shapes in Tasks 2–3. Fixture keys (`fx.docs.docs`, `fx.triage.milestone_proposals`, `fx.roadmap.milestones[].progress`, `fx.events.events[].raw`, `fx.identity.identity_tasks`) match the builder's reads exactly. Read-path functions (`getMyWork`, `get_plan`, `query`, `get_feed`, `list_proposals`, `list_needs_triage`, `list_adrs`, `list_identity_tasks`) match their `src/tools/` exports. ✓ + +## Execution notes + +- `npm run dev` is intentionally left unchanged; `npm run seed` is the explicit contract. Chaining seed into dev can be added later if desired. +- If a future migration adds a data table, update `scripts/seed/reset.mjs` in lockstep with `test/apply-migrations.ts` — the coverage test will surface FK/constraint breakage. diff --git a/docs/superpowers/specs/2026-07-06-local-seed-data-design.md b/docs/superpowers/specs/2026-07-06-local-seed-data-design.md new file mode 100644 index 0000000..fa98a3a --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-local-seed-data-design.md @@ -0,0 +1,159 @@ +# Local seed data, wired across every surface + +**Date:** 2026-07-06 +**Status:** Approved (design), pending implementation plan + +## Problem + +Canopy used to ship a local dev seed — `scripts/seed-dev.sql`, applied to local D1 after +migrations, paired with `scripts/dev-cookie.mjs` (forges a signed session cookie) and +`scripts/dev-shot.mjs` (CDP screenshot). It populated docs, feed, ADRs, needs-triage, +milestones, and a `focus` row so every web surface had data locally. + +The data model then shifted and the seed rotted into a no-op that actively errors: + +- `seed-dev.sql` does `INSERT INTO focus (...)`, but `focus` was **dropped in migration + `0014`**. That statement alone aborts the whole seed. +- **My Work** no longer reads `focus` + `feed`. `src/tools/mywork.ts` is now a D1-only + projection over captured GitHub `events` joined to the `people` identity map, plus + `pr_summaries` / `issue_summaries`. The old seed populates none of these. +- **Roadmap** is now an admin-authored `plan` (singleton narrative + `plan_versions` + snapshots) over `milestones` (which gained a `phase` column) merged with a + `milestone_progress` cache. The old seed sets no `plan`, no `phase`, no progress. +- Tables with **zero** seed coverage today: `events`, `pr_summaries`, `issue_summaries`, + `milestone_progress`, `plan`, `plan_versions`, `identity_tasks`. Docs never get `space` set. + +Net effect: My Work and Roadmap render empty locally, and Docs/Feed/Search/Triage would +work only if the seed didn't abort on the `focus` line first. + +## Goal + +Restore a single local dataset that lights up **every** surface — My Work, Feed, Docs, +Roadmap, Triage, Search — against the *current* schema, wired in through the local-mode +toggle that already exists so bringing it up is one command. + +Non-goals: changing any runtime read path; seeding the remote/production database +(`build-prod-seed.mjs` / `seed-prod.sql` are separate and out of scope); adding a new auth +flow (the `DEV_LOGIN` bypass already exists). + +## Approach (chosen) + +**Option A — the toggle loads JSON into D1; the app reads D1 unchanged.** + +JSON fixture files are the source of truth. A loader resets local D1 and inserts them. Every +existing read path — window-function joins in My Work, FTS5 in Search, the plan+milestones+ +progress merge in Roadmap — runs unchanged against D1. The seed is content only; it never +introduces a parallel read implementation, so it cannot lie about how the app behaves. + +Rejected alternatives: + +- **Read JSON directly at runtime (bypass D1).** Would need a second read implementation per + surface that drifts from the real SQL and never exercises FTS/joins/progress. Rejected. +- **Drive the real HTTP write paths (`/webhook/github` + `/ingest`).** Higher fidelity but + needs a running server, HMAC signing, and a readiness dance. Overkill for a local seed; + the FTS triggers already give us schema-faithful indexing without it. + +## The toggle + +The local-mode toggle already exists: `DEV_LOGIN` in `.dev.vars` (`src/auth/principal.ts:49`) +bypasses OAuth and makes the app act as a seeded user. It never exists in prod vars/secrets, +so it is inert there. `DEV_LOGIN` answers *who you are*; this work adds the missing half — +*the data that user sees*. + +- `npm run seed` runs the loader. +- The loader **hard-refuses `--remote`**: it only ever executes against the local sqlite + (`wrangler d1 execute canopy --local`). This is the guardrail against touching production. +- The loader lives in `scripts/` and is never imported by the worker — no seed code or + fixtures ship in the deployed bundle. + +## Components + +### 1. Fixture files — `fixtures/dev/*.json` + +Plain JSON, one file per surface, hand-authored and diffable. For `events.json`, each entry +is a GitHub-webhook-shaped payload identical to what lands in `events.raw` — realistic and +reusable. Files: `docs.json`, `feed.json`, `adrs.json`, `triage.json`, `roadmap.json` +(plan narrative + milestones), `events.json`, `identity.json`. + +Fixture location is `fixtures/dev/` at repo root. + +### 2. Loader — `scripts/seed-dev.mjs` + +1. Refuse if invoked with `--remote` or any target other than local. +2. **Reset**: run the exact truncation statement from `test/apply-migrations.ts:18` (same + FK-safe delete order, same `people` re-seed). Keeping one canonical reset list means the + seed cannot drift as migrations add tables. +3. **Load**: read each fixture, build parameter-safe INSERTs (JSON string bodies escaped + correctly), and apply them to local D1. Order respects FKs + (`events` before `pr_summaries`; `milestones` before `milestone_progress`). +4. FTS5 (`docs_fts` / `feed_fts` / `adrs_fts` / `roadmap_fts`) is populated automatically by + the DB triggers on insert — no direct FTS seeding. + +Re-running is a clean reset-then-load (idempotent). + +### 3. `package.json` + +Add `"seed": "node scripts/seed-dev.mjs"`. (Whether `npm run dev` chains it is left to the +plan; the explicit command is the contract.) + +## Identity wiring + +My Work only surfaces work whose `subject_login` / issue assignee **matches the logged-in +user**, resolved through `people`. The seed is coherent only when three things line up: + + DEV_LOGIN == a row in people == subject_login on the seeded events + +**Decision:** use `AndresL230` — already a migration-seeded person and the `ADMIN_LOGINS` +value, so admin-only Triage actions work in the same local session. Seeded PR/issue events +carry that subject. The loader re-asserts the four migration `people` rows so it is +self-contained. + +## Coverage matrix + +| Surface | Tables seeded | Result | +|---|---|---| +| Docs | `docs` + `doc_versions` (with `space`) | Docs across reference/context/decisions; one carries a **staged newer version** | +| Feed | `feed` + `entry_tags` | Several tagged entries | +| Roadmap | `plan` + `plan_versions`, `milestones` (`phase`; done/in-progress/upcoming), `milestone_progress` | Narrative + milestones with real closed/total progress | +| My Work | `events` (pr_merged/closed + open issues assigned to `DEV_LOGIN`), `pr_summaries`, `issue_summaries` | Previous-activity list + a to-do list with priorities/labels | +| Triage | `needs_triage`, staged `doc_versions`, `adrs` draft, staged `milestone_proposals`, `identity_tasks` | All four triage queues populated | +| Search | *(none — FTS triggers auto-fill)* | Ranked hits across docs/feed/ADRs/roadmap | + +The staged doc version, ADR draft, milestone proposal, and one `identity_tasks` row (raised +by an event from an unmapped login) exist so Triage's Review **and** Maintenance surfaces +both have something to act on. + +## Data flow + +``` +fixtures/dev/*.json ──> scripts/seed-dev.mjs ──> local D1 (reset + INSERT) + │ + FTS triggers fire on insert + │ + wrangler dev (DEV_LOGIN=AndresL230) ── reads D1 unchanged ──> every web surface +``` + +## Idempotency & safety + +- Reset list is the single canonical copy from `test/apply-migrations.ts`; re-running + `npm run seed` is a clean reset+load. +- The loader refuses `--remote`; it can only ever write local sqlite. +- No fixtures or seed logic are reachable from the worker bundle. + +## Testing + +One Vitest file on the Miniflare D1 harness that applies the generated seed and asserts each +read path returns non-empty: + +- `getMyWork('AndresL230')` returns non-empty `previousActivity` **and** `todo`. +- Roadmap read returns milestones carrying progress. +- `query()` returns hits for a known term across types. +- The staged-proposals join returns at least one row. + +This test is the guard that keeps the seed honest as the schema evolves — the same reason the +reset list is kept in one place. + +## Open questions + +None blocking. `npm run dev` auto-seeding vs. explicit `npm run seed` is an ergonomics detail +to settle in the plan; the explicit command is the contract either way. diff --git a/fixtures/dev/adrs.json b/fixtures/dev/adrs.json new file mode 100644 index 0000000..64c3b95 --- /dev/null +++ b/fixtures/dev/adrs.json @@ -0,0 +1,10 @@ +{ + "adrs": [ + { "id": 1, "title": "Agent write contract", "context": "Agents post to Canopy at the end of a session over MCP. Without a fixed contract, writes arrived in inconsistent shapes.", "decision": "Agents write through a typed contract; every write lands STAGED and unplaceable writes go to Triage.", "rationale": "Keeping every agent write non-destructive and staged preserves the human review gate.", "status": "draft", "confidence": "high", "created_at": "2026-06-24T00:00:00Z", "created_by": "lpcooper-arch" }, + { "id": 2, "title": "Single-accent color system", "context": "Early mocks used several accent colors and gray surfaces.", "decision": "One electric-green accent with two tuned values; no gray surfaces.", "rationale": "A single accent keeps live and active state unambiguous.", "status": "ratified", "confidence": "high", "created_at": "2026-06-12T00:00:00Z", "created_by": "lpcooper-arch" }, + { "id": 3, "title": "Structured capture-time summaries", "context": "The old My Work cards parsed a free-form summary with a What-changed regex that broke on prose drift.", "decision": "Summarize each PR/issue once at capture into validated structured columns (PR: title/what/why/impact; issue: title/summary/next_step), with a deterministic excerpt fallback.", "rationale": "Structured fields make rendering deterministic and let Sync regenerate prose-era rows exactly once.", "status": "ratified", "confidence": "high", "created_at": "2026-07-05T00:00:00Z", "created_by": "AndresL230" }, + { "id": 4, "title": "Events are external fact, deduped by semantic key", "context": "Webhook redeliveries and backfill overlap would double-write captured events.", "decision": "Each event carries a UNIQUE semantic_key (gh:pr:42:merged) written INSERT OR IGNORE; a redelivery drops as unchanged.", "rationale": "An event is captured verbatim, post-HMAC, and must be idempotent regardless of delivery order.", "status": "ratified", "confidence": "high", "created_at": "2026-06-19T00:00:00Z", "created_by": "Darkest-Teddy" }, + { "id": 5, "title": "Progress is a stored absolute cache", "context": "Rendering milestone progress from live GitHub was slow and per-user-token bound.", "decision": "Store absolute closed/total per milestone, written by the webhook and a scheduled backstop; never computed at render.", "rationale": "Absolute values make delivery order irrelevant (last write wins) and keep the render path GitHub-free.", "status": "draft", "confidence": "medium", "created_at": "2026-06-26T00:00:00Z", "created_by": "AndresL230" }, + { "id": 6, "title": "One Worker, one origin", "context": "Splitting the API, MCP, webhook, and web across services multiplied deploy and auth surface.", "decision": "Serve the HTTP API, /mcp, /webhook/github, and the static web build from a single Worker on one origin.", "rationale": "One origin keeps auth, routing, and deploys simple and the bearer/HMAC/cookie classes co-located.", "status": "ratified", "confidence": "high", "created_at": "2026-06-08T00:00:00Z", "created_by": "Jose-Gael-Cruz-Lopez" } + ] +} diff --git a/fixtures/dev/docs.json b/fixtures/dev/docs.json new file mode 100644 index 0000000..71fd16a --- /dev/null +++ b/fixtures/dev/docs.json @@ -0,0 +1,109 @@ +{ + "docs": [ + { + "slug": "mcp-server", "section": "reference", "space": "canopy", + "title": "MCP Server", "current_version": 2, + "updated_at": "2026-06-23T00:00:00Z", "updated_by": "AndresL230", + "body": "The MCP server is the only write path into Canopy. Coding agents connect over the Model Context Protocol and post session output through a typed contract. Every request carries a bearer token, compared in constant time. Token rotation is tracked in #142.", + "versions": [ + { "version": 1, "body": "v1 body — initial page.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-04-01T00:00:00Z", "created_by": "AndresL230" }, + { "version": 2, "body": "The MCP server is the only write path into Canopy. Coding agents connect over the Model Context Protocol and post session output through a typed contract. Every request carries a bearer token, compared in constant time. Token rotation is tracked in #142.", "summary": "Documented the typed contract", "status": "promoted", "confidence": "high", "created_at": "2026-06-23T00:00:00Z", "created_by": "AndresL230" }, + { "version": 3, "body": "The MCP server is the only write path. Tokens are compared in constant time. Rotation: revoke and re-mint from Settings. A fresh McpServer is constructed per request.", "summary": "Clarify token rotation + per-request server", "status": "staged", "confidence": "high", "created_at": "2026-06-24T00:00:00Z", "created_by": "Jose-Gael-Cruz-Lopez", "change_kind": "edit", "base_version": 2 } + ] + }, + { + "slug": "the-gate", "section": "reference", "space": "canopy", + "title": "The reconciling gate", "current_version": 1, + "updated_at": "2026-06-18T00:00:00Z", "updated_by": "Jose-Gael-Cruz-Lopez", + "body": "Every ingested entry funnels through consume(): a replay ledger drops re-POSTs, content-hash dedupe drops identical bodies, and change-typing (new/edit/rewrite) is computed by an LCS diff against the promoted body. Low-confidence new slugs go to triage; low-confidence edits stage-and-flag.", + "versions": [ + { "version": 1, "body": "Every ingested entry funnels through consume(): a replay ledger drops re-POSTs, content-hash dedupe drops identical bodies, and change-typing (new/edit/rewrite) is computed by an LCS diff against the promoted body. Low-confidence new slugs go to triage; low-confidence edits stage-and-flag.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-06-18T00:00:00Z", "created_by": "Jose-Gael-Cruz-Lopez" } + ] + }, + { + "slug": "auth-classes", "section": "reference", "space": "canopy", + "title": "Three auth classes", "current_version": 2, + "updated_at": "2026-06-20T00:00:00Z", "updated_by": "lpcooper-arch", + "body": "Session cookie (humans, signed), bearer token (agents at /mcp, hashed with a canopy_mcp_ prefix), and the GitHub webhook (HMAC-SHA256 over the raw body). Each is kept strictly separate; /mcp is bearer-only and returns a bare 401 with no OAuth discovery.", + "versions": [ + { "version": 1, "body": "Session cookie, bearer token, webhook HMAC.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-05-02T00:00:00Z", "created_by": "lpcooper-arch" }, + { "version": 2, "body": "Session cookie (humans, signed), bearer token (agents at /mcp, hashed with a canopy_mcp_ prefix), and the GitHub webhook (HMAC-SHA256 over the raw body). Each is kept strictly separate; /mcp is bearer-only and returns a bare 401 with no OAuth discovery.", "summary": "Detail each auth class", "status": "promoted", "confidence": "high", "created_at": "2026-06-20T00:00:00Z", "created_by": "lpcooper-arch" } + ] + }, + { + "slug": "my-work-projection", "section": "reference", "space": "canopy", + "title": "My Work projection", "current_version": 1, + "updated_at": "2026-07-02T00:00:00Z", "updated_by": "AndresL230", + "body": "My Work is a D1-only projection over captured events: previousActivity (summarized merged/closed PRs) and todo (open assigned issues, 5 most recent). Each PR and issue is summarized once at capture time into structured columns (PR: title/what/why/impact; issue: title/summary/next_step).", + "versions": [ + { "version": 1, "body": "My Work is a D1-only projection over captured events: previousActivity (summarized merged/closed PRs) and todo (open assigned issues, 5 most recent). Each PR and issue is summarized once at capture time into structured columns (PR: title/what/why/impact; issue: title/summary/next_step).", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-07-02T00:00:00Z", "created_by": "AndresL230" } + ] + }, + { + "slug": "product-overview", "section": "context", "space": "canopy", + "title": "Product Overview", "current_version": 1, + "updated_at": "2026-06-10T00:00:00Z", "updated_by": "lpcooper-arch", + "body": "Canopy is the shared source of truth and working memory for Sapling, a four-person software team. Agents propose context through a reconciling gate; humans confirm the consequential changes in Triage.", + "versions": [ + { "version": 1, "body": "Canopy is the shared source of truth and working memory for Sapling, a four-person software team. Agents propose context through a reconciling gate; humans confirm the consequential changes in Triage.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-06-10T00:00:00Z", "created_by": "lpcooper-arch" } + ] + }, + { + "slug": "team-workflow", "section": "context", "space": "canopy", + "title": "How the team works", "current_version": 1, + "updated_at": "2026-06-14T00:00:00Z", "updated_by": "Darkest-Teddy", + "body": "Orient with load-context before touching an area, work, then record-session at the end to stage one reconciled batch. Trust live results; scrutinize anything staged, unpromoted, or draft.", + "versions": [ + { "version": 1, "body": "Orient with load-context before touching an area, work, then record-session at the end to stage one reconciled batch. Trust live results; scrutinize anything staged, unpromoted, or draft.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-06-14T00:00:00Z", "created_by": "Darkest-Teddy" } + ] + }, + { + "slug": "onboarding", "section": "context", "space": "canopy", + "title": "Onboarding a new agent", "current_version": 1, + "updated_at": "2026-06-28T00:00:00Z", "updated_by": "AndresL230", + "body": "Get added to the SaplingLearn org, sign in to Canopy, then mint an MCP bearer token in Settings. Point your agent at /mcp with the bearer; the connection is stateless.", + "versions": [ + { "version": 1, "body": "Get added to the SaplingLearn org, sign in to Canopy, then mint an MCP bearer token in Settings. Point your agent at /mcp with the bearer; the connection is stateless.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-06-28T00:00:00Z", "created_by": "AndresL230" } + ] + }, + { + "slug": "postgres-store", "section": "decisions", "space": "canopy", + "title": "ADR-001 · D1 for the store", "current_version": 2, + "updated_at": "2026-06-15T00:00:00Z", "updated_by": "lpcooper-arch", + "body": "Use a single Cloudflare D1 database as the store. Sections, versions, feed entries, events, and decisions are all rows. One Worker on one origin serves the API, MCP, webhook, and static web.", + "versions": [ + { "version": 1, "body": "Use a single Postgres instance as the store.", "summary": "Initial ADR", "status": "promoted", "confidence": "high", "created_at": "2026-06-02T00:00:00Z", "created_by": "lpcooper-arch" }, + { "version": 2, "body": "Use a single Cloudflare D1 database as the store. Sections, versions, feed entries, events, and decisions are all rows. One Worker on one origin serves the API, MCP, webhook, and static web.", "summary": "Switch Postgres to D1 after the Workers move", "status": "promoted", "confidence": "high", "created_at": "2026-06-15T00:00:00Z", "created_by": "lpcooper-arch" } + ] + }, + { + "slug": "sapling-architecture", "section": "reference", "space": "sapling", + "title": "Sapling platform architecture", "current_version": 1, + "updated_at": "2026-06-11T00:00:00Z", "updated_by": "Jose-Gael-Cruz-Lopez", + "body": "The Sapling learning platform is a Next.js frontend backed by a Workers API and D1. Lessons are authored as MDX, compiled at build, and served from the edge. Progress and streaks live in Durable Objects.", + "versions": [ + { "version": 1, "body": "The Sapling learning platform is a Next.js frontend backed by a Workers API and D1. Lessons are authored as MDX, compiled at build, and served from the edge. Progress and streaks live in Durable Objects.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-06-11T00:00:00Z", "created_by": "Jose-Gael-Cruz-Lopez" } + ] + }, + { + "slug": "sapling-content-pipeline", "section": "reference", "space": "sapling", + "title": "Content pipeline", "current_version": 1, + "updated_at": "2026-06-22T00:00:00Z", "updated_by": "Darkest-Teddy", + "body": "Authors write lessons in MDX in the sapling repo. A GitHub Action validates frontmatter against the schema, renders previews, and on merge publishes to R2. Broken links fail the check.", + "versions": [ + { "version": 1, "body": "Authors write lessons in MDX in the sapling repo. A GitHub Action validates frontmatter against the schema, renders previews, and on merge publishes to R2. Broken links fail the check.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-06-22T00:00:00Z", "created_by": "Darkest-Teddy" } + ] + }, + { + "slug": "sapling-api", "section": "reference", "space": "sapling", + "title": "Sapling public API", "current_version": 2, + "updated_at": "2026-06-30T00:00:00Z", "updated_by": "AndresL230", + "body": "The public API exposes /courses, /lessons, and /progress. Auth is a per-learner JWT. Rate limits are 120 req/min per token. All responses are cache-friendly with ETags.", + "versions": [ + { "version": 1, "body": "The public API exposes /courses and /lessons.", "summary": "Initial page", "status": "promoted", "confidence": "high", "created_at": "2026-06-05T00:00:00Z", "created_by": "AndresL230" }, + { "version": 2, "body": "The public API exposes /courses, /lessons, and /progress. Auth is a per-learner JWT. Rate limits are 120 req/min per token. All responses are cache-friendly with ETags.", "summary": "Add /progress, JWT auth, rate limits", "status": "promoted", "confidence": "high", "created_at": "2026-06-30T00:00:00Z", "created_by": "AndresL230" }, + { "version": 3, "body": "The public API exposes /courses, /lessons, /progress, and /certificates. Auth is a per-learner JWT with refresh. Rate limits are 120 req/min per token; 429s carry Retry-After.", "summary": "Add /certificates + refresh tokens + Retry-After", "status": "staged", "confidence": "medium", "created_at": "2026-07-04T00:00:00Z", "created_by": "Darkest-Teddy", "change_kind": "edit", "base_version": 2, "low_confidence": 1 } + ] + } + ] +} diff --git a/fixtures/dev/events.json b/fixtures/dev/events.json new file mode 100644 index 0000000..21a7cb7 --- /dev/null +++ b/fixtures/dev/events.json @@ -0,0 +1,96 @@ +{ + "events": [ + { + "semantic_key": "gh:pr:162:merged", "event_type": "pr_merged", "ref_number": 162, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-07-05T14:00:00Z", "recorded_at": "2026-07-05T14:01:00Z", + "raw": { "pr": { "number": 162, "title": "Render My Work cards from structured summary fields", "body": "## What\n\nThe PR and to-do cards now read title/what/why/impact and title/summary/next_step from the DTO. Deletes the What-changed regex convention.\n\nCloses #155.", "html_url": "https://github.com/SaplingLearn/sapling/pull/162", "merged": true, "merged_at": "2026-07-05T14:00:00Z", "closed_at": "2026-07-05T14:00:00Z", "user": { "login": "AndresL230" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Render My Work cards from structured summary fields", "what": "Rewrote the PR and to-do cards to render the structured summary columns directly and removed the old What-changed regex.", "why": "The regex broke whenever the summary prose drifted from its expected shape.", "impact": "My Work cards now show a stable humanized title, what changed, and a next step without brittle parsing." } + }, + { + "semantic_key": "gh:pr:158:merged", "event_type": "pr_merged", "ref_number": 158, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-07-04T16:20:00Z", "recorded_at": "2026-07-04T16:21:00Z", + "raw": { "pr": { "number": 158, "title": "Widen captured event raw with PR base.ref and issue milestone", "body": "Records the PR base branch and the issue milestone title/due_on at capture so My Work can render them with no live GitHub call.", "html_url": "https://github.com/SaplingLearn/sapling/pull/158", "merged": true, "merged_at": "2026-07-04T16:20:00Z", "closed_at": "2026-07-04T16:20:00Z", "user": { "login": "AndresL230" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Capture PR base branch and issue milestone in the event", "what": "Widened the captured event raw to include the PR base.ref and the issue milestone title and due date.", "why": "My Work needed those fields to render footers and milestone chips without a render-time GitHub call.", "impact": "Cards can now show 'into main' and a milestone with its due date entirely from D1." } + }, + { + "semantic_key": "gh:pr:153:merged", "event_type": "pr_merged", "ref_number": 153, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-29T11:00:00Z", "recorded_at": "2026-06-29T11:01:00Z", + "raw": { "pr": { "number": 153, "title": "Milestone progress bars on the Roadmap page", "body": "Each milestone renders a closed/total bar from the stored progress cache.", "html_url": "https://github.com/SaplingLearn/sapling/pull/153", "merged": true, "merged_at": "2026-06-29T11:00:00Z", "closed_at": "2026-06-29T11:00:00Z", "user": { "login": "AndresL230" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Add milestone progress bars to the Roadmap", "what": "Rendered a closed/total progress bar per milestone from the stored progress cache.", "why": null, "impact": "The Roadmap now shows how far each milestone is along at a glance." } + }, + { + "semantic_key": "gh:pr:149:merged", "event_type": "pr_merged", "ref_number": 149, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-27T09:15:00Z", "recorded_at": "2026-06-27T09:16:00Z", + "raw": { "pr": { "number": 149, "title": "Roadmap FTS so query() stops being roadmap-blind", "body": "Adds a standalone roadmap_fts over the plan narrative and milestones.", "html_url": "https://github.com/SaplingLearn/sapling/pull/149", "merged": true, "merged_at": "2026-06-27T09:15:00Z", "closed_at": "2026-06-27T09:15:00Z", "user": { "login": "AndresL230" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Index the roadmap for search", "what": "Added a standalone roadmap_fts virtual table over the plan narrative and milestones and wired it into query().", "why": "Search returned docs, decisions, and feed but never surfaced roadmap items.", "impact": "Searching now finds milestones and the plan narrative alongside everything else." } + }, + { + "semantic_key": "gh:pr:146:merged", "event_type": "pr_merged", "ref_number": 146, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-26T18:45:00Z", "recorded_at": "2026-06-26T18:46:00Z", + "raw": { "pr": { "number": 146, "title": "Scheduled progress recompute backstop", "body": "A cron recomputes milestone progress off the render path in case a webhook delivery is missed.", "html_url": "https://github.com/SaplingLearn/sapling/pull/146", "merged": true, "merged_at": "2026-06-26T18:45:00Z", "closed_at": "2026-06-26T18:45:00Z", "user": { "login": "AndresL230" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Add a scheduled progress recompute backstop", "what": "Added a cron that recomputes milestone progress off the render path using absolute closed/total counts.", "why": "A missed webhook delivery could leave milestone progress stale.", "impact": "Progress self-heals within the cron interval even if a webhook is dropped." } + }, + { + "semantic_key": "gh:pr:142:merged", "event_type": "pr_merged", "ref_number": 142, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-25T10:00:00Z", "recorded_at": "2026-06-25T10:01:00Z", + "raw": { "pr": { "number": 142, "title": "Constant-time MCP token comparison", "body": "Replaces the early-return string compare flagged in #138. Adds a timing test.", "html_url": "https://github.com/SaplingLearn/sapling/pull/142", "merged": true, "merged_at": "2026-06-25T10:00:00Z", "closed_at": "2026-06-25T10:00:00Z", "user": { "login": "AndresL230" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Compare MCP tokens in constant time", "what": "Switched bearer-token comparison to a constant-time check and added a timing test that fails on the old code.", "why": "The early-return string compare in #138 leaked timing information.", "impact": "Token verification no longer leaks a timing side channel." } + }, + { + "semantic_key": "gh:pr:139:closed", "event_type": "pr_closed", "ref_number": 139, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-24T09:00:00Z", "recorded_at": "2026-06-24T09:01:00Z", + "raw": { "pr": { "number": 139, "title": "Spike: Durable Object session cache for MCP", "body": "Abandoned — the stateless per-request server is simpler and the SDK guards reuse.", "html_url": "https://github.com/SaplingLearn/sapling/pull/139", "merged": false, "merged_at": null, "closed_at": "2026-06-24T09:00:00Z", "user": { "login": "AndresL230" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Close the Durable Object MCP session spike", "what": "Closed the spike that explored caching MCP sessions in a Durable Object without merging.", "why": "The stateless per-request server proved simpler and the SDK already guards against reuse.", "impact": null } + }, + + { + "semantic_key": "gh:issue:180:assigned:AndresL230:2026-07-05T18:00:00Z", "event_type": "issue", "ref_number": 180, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-07-05T18:00:00Z", "recorded_at": "2026-07-05T18:01:00Z", + "raw": { "action": "assigned", "issue": { "number": 180, "title": "[P0] Bearer token revoke + audit endpoint", "body": "Revoke a bearer token from Settings (hash-match then soft-delete) and record the revocation in the audit log.", "html_url": "https://github.com/SaplingLearn/sapling/issues/180", "state": "open", "updated_at": "2026-07-05T18:00:00Z", "user": { "login": "lpcooper-arch" }, "assignees": [{ "login": "AndresL230" }], "labels": ["auth", "backend"], "milestone": { "title": "Token rotation & audit log", "due_on": "2026-06-10T00:00:00Z" } } }, + "issue_summary": { "title": "Bearer token revoke and audit endpoint", "summary": "Add a Settings action that revokes a bearer token by hash-matching then soft-deleting it, and records the revocation.", "next_step": "Wire the revoke route to the audit log write and cover it with a hash-match test." } + }, + { + "semantic_key": "gh:issue:181:assigned:AndresL230:2026-07-05T12:00:00Z", "event_type": "issue", "ref_number": 181, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-07-05T12:00:00Z", "recorded_at": "2026-07-05T12:01:00Z", + "raw": { "action": "assigned", "issue": { "number": 181, "title": "[P1] Per-token audit log query", "body": "Expose a queryable read trail of every MCP write, filterable by token.", "html_url": "https://github.com/SaplingLearn/sapling/issues/181", "state": "open", "updated_at": "2026-07-05T12:00:00Z", "user": { "login": "AndresL230" }, "assignees": [{ "login": "AndresL230" }], "labels": ["auth", "api"], "milestone": { "title": "Token rotation & audit log", "due_on": "2026-06-10T00:00:00Z" } } }, + "issue_summary": { "title": "Per-token audit log query", "summary": "Provide a read trail of every MCP write that can be filtered by the token that made it.", "next_step": "Design the audit row shape and add a token-scoped query behind the session gate." } + }, + { + "semantic_key": "gh:issue:184:assigned:AndresL230:2026-07-04T11:30:00Z", "event_type": "issue", "ref_number": 184, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-07-04T11:30:00Z", "recorded_at": "2026-07-04T11:31:00Z", + "raw": { "action": "assigned", "issue": { "number": 184, "title": "[P1] read-plan: diff the plan against captured events", "body": "read-plan should check the authored roadmap against captured events and stored progress, flagging drift.", "html_url": "https://github.com/SaplingLearn/sapling/issues/184", "state": "open", "updated_at": "2026-07-04T11:30:00Z", "user": { "login": "Darkest-Teddy" }, "assignees": [{ "login": "AndresL230" }], "labels": ["data"], "milestone": { "title": "Roadmap reads itself against reality", "due_on": "2026-07-25T00:00:00Z" } } }, + "issue_summary": { "title": "read-plan diffs the plan against reality", "summary": "Have the read-plan skill compare the authored roadmap to captured events and stored progress and report where they diverge.", "next_step": "Enumerate the milestone github_refs and compare their event-derived progress to the authored status." } + }, + { + "semantic_key": "gh:issue:186:assigned:AndresL230:2026-07-03T10:30:00Z", "event_type": "issue", "ref_number": 186, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-07-03T10:30:00Z", "recorded_at": "2026-07-03T10:31:00Z", + "raw": { "action": "assigned", "issue": { "number": 186, "title": "[P2] Namespace Sapling and Canopy slugs in Search", "body": "Sapling lesson slugs and Canopy doc slugs collide in Search results. Namespace them so a query can disambiguate.", "html_url": "https://github.com/SaplingLearn/sapling/issues/186", "state": "open", "updated_at": "2026-07-03T10:30:00Z", "user": { "login": "lpcooper-arch" }, "assignees": [{ "login": "AndresL230" }], "labels": ["api", "data"], "milestone": null } }, + "issue_summary": { "title": "Namespace Sapling and Canopy slugs in Search", "summary": "Disambiguate colliding lesson and doc slugs in Search results by namespacing them per space.", "next_step": null } + }, + { + "semantic_key": "gh:issue:188:assigned:AndresL230:2026-07-02T15:45:00Z", "event_type": "issue", "ref_number": 188, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-07-02T15:45:00Z", "recorded_at": "2026-07-02T15:46:00Z", + "raw": { "action": "assigned", "issue": { "number": 188, "title": "[P2] Vectorize spike for semantic search", "body": "Prototype a semantic candidate stream and merge it with FTS via Reciprocal Rank Fusion.", "html_url": "https://github.com/SaplingLearn/sapling/issues/188", "state": "open", "updated_at": "2026-07-02T15:45:00Z", "user": { "login": "Jose-Gael-Cruz-Lopez" }, "assignees": [{ "login": "AndresL230" }], "labels": ["architecture"], "milestone": { "title": "Semantic search ranking", "due_on": "2026-09-18T00:00:00Z" } } }, + "issue_summary": { "title": "Vectorize spike for semantic search", "summary": "Prototype a Vectorize-backed semantic candidate stream and fuse it with the existing FTS results.", "next_step": "Stand up a throwaway Vectorize index and compare RRF-fused results against FTS-only on a few queries." } + }, + { + "semantic_key": "gh:issue:190:assigned:AndresL230:2026-06-30T09:00:00Z", "event_type": "issue", "ref_number": 190, "subject_login": "AndresL230", + "provenance": "backfill", "occurred_at": "2026-06-30T09:00:00Z", "recorded_at": "2026-06-30T09:01:00Z", + "raw": { "action": "assigned", "issue": { "number": 190, "title": "[P3] Docs: self-host guide outline", "body": "Draft an outline for running the whole store on your own Cloudflare account.", "html_url": "https://github.com/SaplingLearn/sapling/issues/190", "state": "open", "updated_at": "2026-06-30T09:00:00Z", "user": { "login": "Darkest-Teddy" }, "assignees": [{ "login": "AndresL230" }], "labels": ["infra"], "milestone": { "title": "Self-host & deploy guide", "due_on": "2026-10-20T00:00:00Z" } } }, + "issue_summary": { "title": "Outline the self-host guide", "summary": "Sketch the sections a self-host guide needs: provisioning D1, setting secrets, applying migrations, and deploying.", "next_step": "List the required secrets and bindings as the first section." } + }, + + { + "semantic_key": "gh:pr:151:merged", "event_type": "pr_merged", "ref_number": 151, "subject_login": "octo-drifter", + "provenance": "backfill", "occurred_at": "2026-06-22T16:00:00Z", "recorded_at": "2026-06-22T16:01:00Z", + "raw": { "pr": { "number": 151, "title": "Fix typo in onboarding doc", "body": "One-line copy fix.", "html_url": "https://github.com/SaplingLearn/sapling/pull/151", "merged": true, "merged_at": "2026-06-22T16:00:00Z", "closed_at": "2026-06-22T16:00:00Z", "user": { "login": "octo-drifter" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Fix a typo in the onboarding doc", "what": "Corrected a one-line typo in the onboarding documentation.", "why": null, "impact": null } + }, + { + "semantic_key": "gh:pr:205:merged", "event_type": "pr_merged", "ref_number": 205, "subject_login": "sky-42", + "provenance": "backfill", "occurred_at": "2026-06-21T13:00:00Z", "recorded_at": "2026-06-21T13:01:00Z", + "raw": { "pr": { "number": 205, "title": "Bump lesson schema validator to v3", "body": "Upgrades the MDX frontmatter validator in the Sapling content pipeline.", "html_url": "https://github.com/SaplingLearn/sapling/pull/205", "merged": true, "merged_at": "2026-06-21T13:00:00Z", "closed_at": "2026-06-21T13:00:00Z", "user": { "login": "sky-42" }, "base": { "ref": "main" }, "milestone": null } }, + "pr_summary": { "title": "Bump the lesson schema validator to v3", "what": "Upgraded the MDX frontmatter validator used by the content pipeline to v3.", "why": null, "impact": "Lessons are now validated against the newer frontmatter schema." } + } + ] +} diff --git a/fixtures/dev/feed.json b/fixtures/dev/feed.json new file mode 100644 index 0000000..29b7a30 --- /dev/null +++ b/fixtures/dev/feed.json @@ -0,0 +1,18 @@ +{ + "feed": [ + { "id": 1, "author": "Jose-Gael-Cruz-Lopez", "summary": "Implemented Mermaid + D2 rendering in the Docs reader", "body": "Fenced mermaid and d2 blocks now render to inline SVG on the client, with the source block as a fallback.", "artifacts": { "prs": ["145"], "commits": ["7b1e004"], "issues": [] }, "created_at": "2026-06-25T11:30:00Z", "tags": ["ui", "architecture"] }, + { "id": 2, "author": "AndresL230", "summary": "Switched MCP token comparison to constant-time", "body": "Replaces the early-return string compare flagged in #138. Adds a timing test that fails on the old implementation.", "artifacts": { "prs": ["142"], "commits": ["a3f9c21"], "issues": [138] }, "created_at": "2026-06-25T10:00:00Z", "tags": ["auth"] }, + { "id": 3, "author": "lpcooper-arch", "summary": "Drafted ADR: append-only feed as the system of record", "body": null, "artifacts": { "prs": [], "commits": [], "issues": [150] }, "created_at": "2026-06-25T08:00:00Z", "tags": ["architecture", "data"] }, + { "id": 4, "author": "AndresL230", "summary": "Structured My Work summaries land at capture time", "body": "PRs summarize into title/what/why/impact and issues into title/summary/next_step. The card layout renders the structured fields; the old What-changed regex is gone.", "artifacts": { "prs": ["162"], "commits": ["c1a2b3d"], "issues": [155] }, "created_at": "2026-07-05T14:00:00Z", "tags": ["ui", "data"] }, + { "id": 5, "author": "Darkest-Teddy", "summary": "Widened captured event raw with PR base.ref and issue milestone", "body": "The webhook now records the PR base branch and the issue milestone title/due_on so My Work cards can show them without a live GitHub call.", "artifacts": { "prs": ["158"], "commits": ["deadbee"], "issues": [] }, "created_at": "2026-07-04T16:20:00Z", "tags": ["api", "data"] }, + { "id": 6, "author": "Jose-Gael-Cruz-Lopez", "summary": "Roadmap FTS so query() stops being roadmap-blind", "body": "Standalone roadmap_fts over the plan narrative + milestones. Search now surfaces roadmap items alongside docs, decisions, and feed.", "artifacts": { "prs": ["149"], "commits": ["fea7123"], "issues": [] }, "created_at": "2026-06-27T09:15:00Z", "tags": ["architecture", "api"] }, + { "id": 7, "author": "AndresL230", "summary": "Scheduled progress recompute backstop", "body": "A cron recomputes milestone progress off the render path when a webhook delivery is missed. Absolute closed/total so delivery order is irrelevant.", "artifacts": { "prs": ["146"], "commits": ["b0b0caf"], "issues": [175] }, "created_at": "2026-06-26T18:45:00Z", "tags": ["infra"] }, + { "id": 8, "author": "lpcooper-arch", "summary": "Identity triage queue for unmapped GitHub logins", "body": "Each unknown login on a captured event raises one pending identity task. The map-to-person route performs the people table's only runtime write.", "artifacts": { "prs": ["151"], "commits": ["11dede1"], "issues": [148] }, "created_at": "2026-06-26T12:00:00Z", "tags": ["auth", "data"] }, + { "id": 9, "author": "Darkest-Teddy", "summary": "Sapling lesson previews render in CI comments", "body": "The content Action posts a rendered preview of changed MDX lessons as a PR comment. Broken internal links now fail the check.", "artifacts": { "prs": ["203"], "commits": ["5a1b2c3"], "issues": [] }, "created_at": "2026-06-24T13:30:00Z", "tags": ["ui", "infra"] }, + { "id": 10, "author": "Jose-Gael-Cruz-Lopez", "summary": "Sapling API adds /progress with per-learner JWT", "body": "Learners can read their own progress with a scoped token. Rate limited to 120 req/min; responses carry ETags.", "artifacts": { "prs": ["198"], "commits": ["9f8e7d6"], "issues": [190] }, "created_at": "2026-06-23T15:10:00Z", "tags": ["api", "auth"] }, + { "id": 11, "author": "AndresL230", "summary": "Local dev seed wired across every surface", "body": "npm run seed loads JSON fixtures into local D1 so My Work, Feed, Docs, Roadmap, Triage, and Search all light up as the DEV_LOGIN user.", "artifacts": { "prs": ["21"], "commits": ["6dfa05a"], "issues": [] }, "created_at": "2026-07-06T17:00:00Z", "tags": ["infra", "ui"] }, + { "id": 12, "author": "Darkest-Teddy", "summary": "Milestone progress bars on the Roadmap page", "body": "Each milestone renders a closed/total bar from the stored cache. No live GitHub at render.", "artifacts": { "prs": ["153"], "commits": ["ca11ab1"], "issues": [] }, "created_at": "2026-06-29T11:00:00Z", "tags": ["ui", "data"] }, + { "id": 13, "author": "lpcooper-arch", "summary": "Docs space toggle: Sapling vs Canopy", "body": "The Docs reader gains a top-level space toggle. Grouping and display only, not an access boundary.", "artifacts": { "prs": ["140"], "commits": ["7e7e7e7"], "issues": [] }, "created_at": "2026-06-21T10:30:00Z", "tags": ["ui"] }, + { "id": 14, "author": "Jose-Gael-Cruz-Lopez", "summary": "Sapling streaks moved to Durable Objects", "body": "Per-learner streak counters now live in a Durable Object keyed by learner id, replacing the racy KV counter.", "artifacts": { "prs": ["209"], "commits": ["d0d0d0d"], "issues": [201] }, "created_at": "2026-07-01T08:20:00Z", "tags": ["architecture", "infra"] } + ] +} diff --git a/fixtures/dev/identity.json b/fixtures/dev/identity.json new file mode 100644 index 0000000..4ccbd11 --- /dev/null +++ b/fixtures/dev/identity.json @@ -0,0 +1,6 @@ +{ + "identity_tasks": [ + { "login": "octo-drifter", "first_seen": "2026-06-22T16:01:00Z", "status": "pending" }, + { "login": "sky-42", "first_seen": "2026-06-21T13:01:00Z", "status": "pending" } + ] +} diff --git a/fixtures/dev/roadmap.json b/fixtures/dev/roadmap.json new file mode 100644 index 0000000..445a524 --- /dev/null +++ b/fixtures/dev/roadmap.json @@ -0,0 +1,15 @@ +{ + "narrative": "## Canopy roadmap\n\nCanopy is the team's working memory: agents propose context through a reconciling gate and humans confirm the consequential changes. \n\n**Now** we're hardening trustworthy capture (staged writes, replay-safe events) and the read-side brain (ranked FTS across docs, decisions, feed, and roadmap). **Next** is making the personal surfaces earn their keep — structured My Work summaries and a roadmap that reads itself against reality. **Later** we open the store up: semantic search over Vectorize and a self-host path.", + "version": 2, + "updated_at": "2026-07-05T00:00:00Z", + "updated_by": "AndresL230", + "milestones": [ + { "id": 1, "title": "MCP write contract — GA", "description": "Typed, staged-only writes for every agent over MCP.", "phase": "Now", "target_date": "2026-04-30", "status": "done", "github_ref": "1", "created_at": "2026-03-01T00:00:00Z", "created_by": "lpcooper-arch", "updated_at": "2026-04-30T00:00:00Z", "progress": { "closed": 6, "total": 6, "source": "recompute", "computed_at": "2026-07-05T00:00:00Z" } }, + { "id": 2, "title": "Reconciling gate + replay ledger", "description": "Content-hash dedupe, change-typing, and a per-item replay ledger.", "phase": "Now", "target_date": "2026-05-28", "status": "done", "github_ref": "[120,121,122]", "created_at": "2026-04-01T00:00:00Z", "created_by": "Jose-Gael-Cruz-Lopez", "updated_at": "2026-05-28T00:00:00Z", "progress": { "closed": 4, "total": 4, "source": "recompute", "computed_at": "2026-07-05T00:00:00Z" } }, + { "id": 3, "title": "Token rotation & audit log", "description": "Constant-time comparison, revoke, and a read trail.", "phase": "Weeks 3-4", "target_date": "2026-06-10", "status": "in_progress", "github_ref": "[160,162,175]", "created_at": "2026-05-01T00:00:00Z", "created_by": "AndresL230", "updated_at": null, "progress": { "closed": 2, "total": 3, "source": "recompute", "computed_at": "2026-07-05T00:00:00Z" } }, + { "id": 4, "title": "Structured My Work summaries", "description": "Capture-time structured PR/issue summaries rendered on the dashboard cards.", "phase": "Weeks 3-4", "target_date": "2026-07-06", "status": "in_progress", "github_ref": "[155,158,162]", "created_at": "2026-06-15T00:00:00Z", "created_by": "AndresL230", "updated_at": null, "progress": { "closed": 3, "total": 4, "source": "recompute", "computed_at": "2026-07-05T00:00:00Z" } }, + { "id": 5, "title": "Roadmap reads itself against reality", "description": "read-plan checks the authored plan against captured events and progress.", "phase": "Next", "target_date": "2026-07-25", "status": "upcoming", "github_ref": "[170,171]", "created_at": "2026-06-20T00:00:00Z", "created_by": "Darkest-Teddy", "updated_at": null, "progress": { "closed": 0, "total": 2, "source": "recompute", "computed_at": "2026-07-05T00:00:00Z" } }, + { "id": 6, "title": "Semantic search ranking", "description": "Mixed feed/doc results ordered by meaning, not match, via Vectorize + RRF.", "phase": "Later", "target_date": "2026-09-18", "status": "upcoming", "github_ref": null, "created_at": "2026-06-01T00:00:00Z", "created_by": "Jose-Gael-Cruz-Lopez", "updated_at": null }, + { "id": 7, "title": "Self-host & deploy guide", "description": "Run the whole store on your own Cloudflare account.", "phase": "Later", "target_date": "2026-10-20", "status": "upcoming", "github_ref": null, "created_at": "2026-06-25T00:00:00Z", "created_by": "lpcooper-arch", "updated_at": null } + ] +} diff --git a/fixtures/dev/triage.json b/fixtures/dev/triage.json new file mode 100644 index 0000000..fbf9d30 --- /dev/null +++ b/fixtures/dev/triage.json @@ -0,0 +1,14 @@ +{ + "needs_triage": [ + { "raw": "The MCP server should rate-limit per token. Proposed 60 writes/min burst, 600/hour sustained.", "reason": "No clear section. Mixes a Reference description with an unmade Decision about limits.", "source_author": "AndresL230", "resolved": 0, "created_at": "2026-06-25T09:00:00Z" }, + { "raw": "Onboarding: 1) get added to the org, 2) sign in to Canopy, 3) mint an MCP token in Settings.", "reason": "Ambiguous between Context (team process) and Reference (how-to). Needs a human to choose.", "source_author": "Jose-Gael-Cruz-Lopez", "resolved": 0, "created_at": "2026-06-24T00:00:00Z" }, + { "raw": "We keep saying 'the store' and 'the KB' for the same thing. Pick one term across docs and UI.", "reason": "A vocabulary/naming decision, not a doc — belongs in a Decision once someone chooses.", "source_author": "Darkest-Teddy", "resolved": 0, "created_at": "2026-07-01T14:00:00Z" }, + { "raw": "Sapling lesson slugs and Canopy doc slugs collide in Search. Namespace them?", "reason": "Cross-surface concern; unclear whether it's a Reference note or an infra Decision.", "source_author": "lpcooper-arch", "resolved": 0, "created_at": "2026-07-03T10:30:00Z" }, + { "raw": "Should record-session auto-fire at the end of long sessions, or stay explicit-only?", "reason": "Process question with real tradeoffs; needs a human ruling, not a guessed doc edit.", "source_author": "AndresL230", "resolved": 0, "created_at": "2026-07-05T19:00:00Z" } + ], + "milestone_proposals": [ + { "title": "Self-host & deploy guide", "target_date": "2026-09-20", "status": "upcoming", "github_ref": null, "change_summary": "Run the whole store on your own Cloudflare account.", "confidence": "high", "staged_status": "staged", "created_at": "2026-06-25T00:00:00Z", "created_by": "Darkest-Teddy" }, + { "title": "Vectorize semantic search", "target_date": "2026-10-15", "status": "upcoming", "github_ref": null, "change_summary": "Add a semantic candidate stream merged with FTS via RRF.", "confidence": "medium", "staged_status": "staged", "created_at": "2026-07-02T00:00:00Z", "created_by": "Jose-Gael-Cruz-Lopez" }, + { "title": "Per-token audit log", "target_date": "2026-08-30", "status": "upcoming", "github_ref": "[181,182]", "change_summary": "A read trail of every MCP write, queryable per token.", "confidence": "high", "staged_status": "staged", "created_at": "2026-07-04T00:00:00Z", "created_by": "AndresL230" } + ] +} diff --git a/package.json b/package.json index 785b534..54488f0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "typecheck": "tsc -p tsconfig.worker.json && tsc -p tsconfig.web.json", "db:create": "wrangler d1 create canopy", "db:migrate:local": "wrangler d1 migrations apply canopy --local", - "db:migrate:remote": "wrangler d1 migrations apply canopy --remote" + "db:migrate:remote": "wrangler d1 migrations apply canopy --remote", + "seed": "node scripts/seed-dev.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/scripts/seed-dev.mjs b/scripts/seed-dev.mjs new file mode 100644 index 0000000..23909a7 --- /dev/null +++ b/scripts/seed-dev.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Local-only seed loader. Reads fixtures/dev/*.json, builds escaped SQL via the +// shared builder, and applies it to LOCAL D1 through wrangler. Never touches +// remote D1 — it refuses --remote outright. +import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { buildSeedStatements, targetsRemote } from "./seed/build.mjs"; + +const argv = process.argv.slice(2); +if (targetsRemote(argv)) { + console.error("seed-dev: refusing --remote. This seed only ever targets LOCAL D1."); + process.exit(1); +} + +const dir = fileURLToPath(new URL("../fixtures/dev/", import.meta.url)); +const load = (name) => JSON.parse(readFileSync(join(dir, name), "utf8")); +const fx = { + docs: load("docs.json"), + feed: load("feed.json"), + adrs: load("adrs.json"), + triage: load("triage.json"), + roadmap: load("roadmap.json"), + events: load("events.json"), + identity: load("identity.json"), +}; + +const statements = buildSeedStatements(fx); +const sql = statements.map((s) => s + ";").join("\n"); + +const file = join(mkdtempSync(join(tmpdir(), "canopy-seed-")), "seed.sql"); +writeFileSync(file, sql, "utf8"); + +console.log(`seed-dev: applying ${statements.length} statements to LOCAL D1…`); +execFileSync("npx", ["wrangler", "d1", "execute", "canopy", "--local", `--file=${file}`], { stdio: "inherit" }); +console.log("seed-dev: done — local D1 seeded for every surface. Set DEV_LOGIN=AndresL230 and run `npm run dev`."); diff --git a/scripts/seed/build.d.mts b/scripts/seed/build.d.mts new file mode 100644 index 0000000..0c2fbc2 --- /dev/null +++ b/scripts/seed/build.d.mts @@ -0,0 +1,6 @@ +// Type surface for build.mjs so TypeScript test files importing it type-check +// under tsconfig.worker.json (which includes test/) without allowJs. The loader +// consumes parsed fixture JSON; the runtime module reads keys defensively, so a +// permissive record is the honest public shape. +export function buildSeedStatements(fx: Record): string[]; +export function targetsRemote(argv: string[]): boolean; diff --git a/scripts/seed/build.mjs b/scripts/seed/build.mjs new file mode 100644 index 0000000..734e566 --- /dev/null +++ b/scripts/seed/build.mjs @@ -0,0 +1,126 @@ +import { RESET_STATEMENTS } from "./reset.mjs"; + +// SQL string literal: wrap in single quotes, double any embedded quote. NULL for +// null/undefined. JSON.stringify guarantees no literal newlines in embedded JSON. +const q = (v) => (v === null || v === undefined ? "NULL" : `'${String(v).replace(/'/g, "''")}'`); +const num = (v) => (v === null || v === undefined ? "NULL" : String(Number(v))); +const jsonLit = (obj) => (obj === null || obj === undefined ? "NULL" : q(JSON.stringify(obj))); + +// Provenance stamped on structured summary rows so they read as "done" (My Work's +// Sync skip-check treats a row as generated only when model != 'excerpt' AND +// title IS NOT NULL). Matches WORKERS_AI_MODEL in src/tools/summarize.ts. +const STRUCTURED_MODEL = "@cf/google/gemma-4-26b-a4b-it"; + +/** True iff the loader was asked to touch remote D1 — the loader must refuse. */ +export const targetsRemote = (argv) => argv.includes("--remote"); + +/** + * Turn parsed fixture objects into standalone, escaped SQL statements (no + * trailing ";"), reset statements first. FK-safe ordering: events before + * pr_summaries, milestones before milestone_progress. + */ +export function buildSeedStatements(fx) { + const s = [...RESET_STATEMENTS]; + + for (const d of fx.docs?.docs ?? []) { + s.push( + `INSERT INTO docs (slug, section, space, title, body, current_version, updated_at, updated_by) VALUES (` + + `${q(d.slug)}, ${q(d.section)}, ${q(d.space ?? "canopy")}, ${q(d.title)}, ${q(d.body)}, ${num(d.current_version)}, ${q(d.updated_at)}, ${q(d.updated_by)})` + ); + for (const v of d.versions ?? []) { + s.push( + `INSERT INTO doc_versions (slug, version, body, summary, status, confidence, created_at, created_by, change_kind, base_version, low_confidence) VALUES (` + + `${q(d.slug)}, ${num(v.version)}, ${q(v.body)}, ${q(v.summary)}, ${q(v.status)}, ${q(v.confidence)}, ${q(v.created_at)}, ${q(v.created_by)}, ${q(v.change_kind)}, ${num(v.base_version)}, ${num(v.low_confidence ?? 0)})` + ); + } + } + + for (const f of fx.feed?.feed ?? []) { + s.push( + `INSERT INTO feed (id, author, summary, body, artifacts, created_at) VALUES (` + + `${num(f.id)}, ${q(f.author)}, ${q(f.summary)}, ${q(f.body)}, ${jsonLit(f.artifacts)}, ${q(f.created_at)})` + ); + for (const t of f.tags ?? []) { + s.push(`INSERT INTO entry_tags (tag, entry_type, entry_id) VALUES (${q(t)}, 'feed', ${q(String(f.id))})`); + } + } + + for (const a of fx.adrs?.adrs ?? []) { + s.push( + `INSERT INTO adrs (id, title, context, decision, rationale, status, confidence, created_at, created_by) VALUES (` + + `${num(a.id)}, ${q(a.title)}, ${q(a.context)}, ${q(a.decision)}, ${q(a.rationale)}, ${q(a.status)}, ${q(a.confidence)}, ${q(a.created_at)}, ${q(a.created_by)})` + ); + } + + for (const t of fx.triage?.needs_triage ?? []) { + s.push( + `INSERT INTO needs_triage (raw, reason, source_author, resolved, created_at) VALUES (` + + `${q(t.raw)}, ${q(t.reason)}, ${q(t.source_author)}, ${num(t.resolved ?? 0)}, ${q(t.created_at)})` + ); + } + + for (const m of fx.triage?.milestone_proposals ?? []) { + s.push( + `INSERT INTO milestone_proposals (title, target_date, status, github_ref, change_summary, confidence, staged_status, created_at, created_by) VALUES (` + + `${q(m.title)}, ${q(m.target_date)}, ${q(m.status)}, ${q(m.github_ref)}, ${q(m.change_summary)}, ${q(m.confidence)}, ${q(m.staged_status ?? "staged")}, ${q(m.created_at)}, ${q(m.created_by)})` + ); + } + + const rm = fx.roadmap; + if (rm) { + s.push( + `UPDATE plan SET narrative = ${q(rm.narrative)}, current_version = ${num(rm.version)}, updated_at = ${q(rm.updated_at)}, updated_by = ${q(rm.updated_by)} WHERE id = 1` + ); + s.push( + `INSERT INTO plan_versions (version, narrative, milestones_json, created_at, created_by) VALUES (` + + `${num(rm.version)}, ${q(rm.narrative)}, ${jsonLit(rm.milestones ?? [])}, ${q(rm.updated_at)}, ${q(rm.updated_by)})` + ); + for (const m of rm.milestones ?? []) { + s.push( + `INSERT INTO milestones (id, title, description, phase, target_date, status, github_ref, created_at, created_by, updated_at) VALUES (` + + `${num(m.id)}, ${q(m.title)}, ${q(m.description)}, ${q(m.phase)}, ${q(m.target_date)}, ${q(m.status)}, ${q(m.github_ref)}, ${q(m.created_at)}, ${q(m.created_by)}, ${q(m.updated_at)})` + ); + if (m.progress) { + s.push( + `INSERT INTO milestone_progress (milestone_id, closed, total, source, computed_at) VALUES (` + + `${num(m.id)}, ${num(m.progress.closed)}, ${num(m.progress.total)}, ${q(m.progress.source ?? "recompute")}, ${q(m.progress.computed_at)})` + ); + } + } + } + + for (const e of fx.events?.events ?? []) { + s.push( + `INSERT INTO events (semantic_key, event_type, ref_number, subject_login, raw, provenance, occurred_at, recorded_at, recorded_by) VALUES (` + + `${q(e.semantic_key)}, ${q(e.event_type)}, ${num(e.ref_number)}, ${q(e.subject_login)}, ${jsonLit(e.raw)}, ${q(e.provenance ?? "backfill")}, ${q(e.occurred_at)}, ${q(e.recorded_at)}, ${q(e.recorded_by ?? "github-webhook")})` + ); + // Structured summaries (0018): fixtures carry an object; `summary` (the NOT + // NULL prose mirror) is `what`/`summary`, and title/what/why/impact | next_step + // land in their own columns. A bare `model:"excerpt"` fixture stays prose-only. + if (e.pr_summary) { + const p = e.pr_summary; + const model = p.model ?? STRUCTURED_MODEL; + s.push( + `INSERT INTO pr_summaries (semantic_key, pr_number, summary, model, created_at, title, what, why, impact) VALUES (` + + `${q(e.semantic_key)}, ${num(e.ref_number)}, ${q(p.what)}, ${q(model)}, ${q(e.recorded_at)}, ${q(p.title)}, ${q(p.what)}, ${q(p.why)}, ${q(p.impact)})` + ); + } + if (e.issue_summary) { + const i = e.issue_summary; + const model = i.model ?? STRUCTURED_MODEL; + s.push( + `INSERT INTO issue_summaries (issue_number, summary, model, created_at, title, next_step) VALUES (` + + `${num(e.ref_number)}, ${q(i.summary)}, ${q(model)}, ${q(e.recorded_at)}, ${q(i.title)}, ${q(i.next_step)})` + ); + } + } + + for (const t of fx.identity?.identity_tasks ?? []) { + s.push( + `INSERT INTO identity_tasks (login, first_seen, status, resolved_at, resolved_by) VALUES (` + + `${q(t.login)}, ${q(t.first_seen)}, ${q(t.status ?? "pending")}, ${q(t.resolved_at)}, ${q(t.resolved_by)})` + ); + } + + return s; +} diff --git a/scripts/seed/reset.d.mts b/scripts/seed/reset.d.mts new file mode 100644 index 0000000..1dafc0a --- /dev/null +++ b/scripts/seed/reset.d.mts @@ -0,0 +1,3 @@ +// Type surface for reset.mjs so TypeScript test files importing it type-check +// under tsconfig.worker.json (which includes test/) without allowJs. +export const RESET_STATEMENTS: string[]; diff --git a/scripts/seed/reset.mjs b/scripts/seed/reset.mjs new file mode 100644 index 0000000..9859264 --- /dev/null +++ b/scripts/seed/reset.mjs @@ -0,0 +1,27 @@ +// Canonical data-table reset for Canopy, shared by the test harness +// (test/apply-migrations.ts) and the dev seed loader. FK-safe delete order; +// re-seeds the people identity map. When a migration adds a data table, add +// its DELETE here. +export const RESET_STATEMENTS = [ + "DELETE FROM processed_items", + "DELETE FROM pr_summaries", + "DELETE FROM issue_summaries", + "DELETE FROM events", + "DELETE FROM milestone_progress", + "DELETE FROM plan_versions", + "UPDATE plan SET narrative = '', current_version = 0, updated_at = NULL, updated_by = NULL", + "DELETE FROM milestone_proposals", + "DELETE FROM milestones", + "DELETE FROM doc_versions", + "DELETE FROM docs", + "DELETE FROM feed", + "DELETE FROM entry_tags", + "DELETE FROM adrs", + "DELETE FROM needs_triage", + "DELETE FROM identity_tasks", + "DELETE FROM people", + "INSERT INTO people (login, person) VALUES ('AndresL230', 'Andres'), ('Jose-Gael-Cruz-Lopez', 'Jose'), ('lpcooper-arch', 'Luke'), ('Darkest-Teddy', 'Jack')", + "DELETE FROM sessions", + "DELETE FROM mcp_tokens", + "DELETE FROM users", +]; diff --git a/test/apply-migrations.ts b/test/apply-migrations.ts index eba67b2..bf78ea5 100644 --- a/test/apply-migrations.ts +++ b/test/apply-migrations.ts @@ -1,5 +1,6 @@ import { applyD1Migrations, env } from "cloudflare:test"; import { beforeEach } from "vitest"; +import { RESET_STATEMENTS } from "../scripts/seed/reset.mjs"; // Runs once per test worker before the suite. applyD1Migrations is idempotent. // Schema + seeded vocabulary (sections, tags) are applied here and persist for @@ -8,13 +9,9 @@ import { beforeEach } from "vitest"; await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); beforeEach(async () => { - // Truncate all user-writable data tables, preserving vocabulary tables - // (sections, tags) that were seeded by the migration. - await env.DB.exec( - // pr_summaries.semantic_key REFERENCES events(semantic_key) — delete the - // child before its parent, or the FK constraint rejects the parent delete. - // people gains a runtime write path (identity resolve), so it is reset to - // the 0012 seed each test rather than left to accumulate mappings. - "DELETE FROM processed_items; DELETE FROM pr_summaries; DELETE FROM issue_summaries; DELETE FROM events; DELETE FROM milestone_progress; DELETE FROM plan_versions; UPDATE plan SET narrative = '', current_version = 0, updated_at = NULL, updated_by = NULL; DELETE FROM milestone_proposals; DELETE FROM milestones; DELETE FROM doc_versions; DELETE FROM docs; DELETE FROM feed; DELETE FROM entry_tags; DELETE FROM adrs; DELETE FROM needs_triage; DELETE FROM identity_tasks; DELETE FROM people; INSERT INTO people (login, person) VALUES ('AndresL230', 'Andres'), ('Jose-Gael-Cruz-Lopez', 'Jose'), ('lpcooper-arch', 'Luke'), ('Darkest-Teddy', 'Jack'); DELETE FROM sessions; DELETE FROM mcp_tokens; DELETE FROM users;" - ); + // The canonical data-table reset lives in scripts/seed/reset.mjs, shared by + // this harness and the dev seed loader (FK-safe delete order + people + // re-seed). Truncates all user-writable data tables, preserving vocabulary + // tables (sections, tags) that were seeded by the migration. + await env.DB.exec(RESET_STATEMENTS.join("; ") + ";"); }); diff --git a/test/seed-build.test.ts b/test/seed-build.test.ts new file mode 100644 index 0000000..370ece1 --- /dev/null +++ b/test/seed-build.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { buildSeedStatements, targetsRemote } from "../scripts/seed/build.mjs"; +import { RESET_STATEMENTS } from "../scripts/seed/reset.mjs"; + +describe("buildSeedStatements", () => { + it("prepends the canonical reset, in order", () => { + const out = buildSeedStatements({}); + expect(out.slice(0, RESET_STATEMENTS.length)).toEqual(RESET_STATEMENTS); + }); + + it("escapes single quotes in values", () => { + const out = buildSeedStatements({ docs: { docs: [{ slug: "s", section: "reference", title: "O'Hara", body: "b", current_version: 1, updated_at: "t", updated_by: "u", versions: [] }] } }); + const insert = out.find((s) => s.startsWith("INSERT INTO docs")); + expect(insert).toContain("'O''Hara'"); + }); + + it("serializes event raw as a JSON string literal with no literal newline", () => { + const out = buildSeedStatements({ events: { events: [{ semantic_key: "k", event_type: "pr_merged", ref_number: 1, subject_login: "AndresL230", provenance: "backfill", occurred_at: "t", recorded_at: "t", raw: { pr: { body: "line1\nline2" } } }] } }); + const insert = out.find((s) => s.startsWith("INSERT INTO events")); + expect(insert).toBeDefined(); + expect(insert).toContain("line1\\nline2"); + expect(insert!.includes("\n")).toBe(false); + }); + + it("emits a milestone_progress insert only when progress is present", () => { + const withP = buildSeedStatements({ roadmap: { narrative: "n", version: 1, milestones: [{ id: 1, title: "m", target_date: "2026-01-01", status: "done", progress: { closed: 2, total: 2, computed_at: "t" } }] } }); + const without = buildSeedStatements({ roadmap: { narrative: "n", version: 1, milestones: [{ id: 2, title: "m2", target_date: "2026-01-01", status: "upcoming" }] } }); + expect(withP.some((s) => s.startsWith("INSERT INTO milestone_progress"))).toBe(true); + expect(without.some((s) => s.startsWith("INSERT INTO milestone_progress"))).toBe(false); + }); + + it("targetsRemote detects the --remote flag", () => { + expect(targetsRemote(["--remote"])).toBe(true); + expect(targetsRemote(["--local"])).toBe(false); + }); +}); diff --git a/test/seed-coverage.test.ts b/test/seed-coverage.test.ts new file mode 100644 index 0000000..953e417 --- /dev/null +++ b/test/seed-coverage.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { env } from "cloudflare:test"; +import { buildSeedStatements } from "../scripts/seed/build.mjs"; +import { getMyWork } from "../src/tools/mywork"; +import { get_plan } from "../src/tools/plan"; +import { query, get_feed, list_proposals, list_needs_triage, list_adrs, list_identity_tasks } from "../src/tools/reads"; +import docs from "../fixtures/dev/docs.json"; +import feed from "../fixtures/dev/feed.json"; +import adrs from "../fixtures/dev/adrs.json"; +import triage from "../fixtures/dev/triage.json"; +import roadmap from "../fixtures/dev/roadmap.json"; +import events from "../fixtures/dev/events.json"; +import identity from "../fixtures/dev/identity.json"; + +const fx = { docs, feed, adrs, triage, roadmap, events, identity }; + +beforeEach(async () => { + for (const stmt of buildSeedStatements(fx)) { + await env.DB.prepare(stmt).run(); + } +}); + +describe("dev seed lights up every surface", () => { + it("My Work: previous activity + to-dos for AndresL230", async () => { + const mw = await getMyWork(env.DB, "AndresL230"); + expect(mw.degraded).toBe(false); + expect(mw.person).toBe("Andres"); + expect(mw.previousActivity.length).toBeGreaterThan(0); + expect(mw.todo.length).toBeGreaterThan(0); + // Priority tags parsed + stripped from assigned issues. + expect(mw.todo.some((t) => t.priority === "P0")).toBe(true); + expect(mw.todo.some((t) => t.priority === "P1")).toBe(true); + // Structured summaries (0018) reach the DTO, not just the prose mirror. + expect(mw.previousActivity.some((p) => p.what !== null && p.displayTitle !== null)).toBe(true); + expect(mw.previousActivity.some((p) => p.baseRef === "main")).toBe(true); + expect(mw.todo.some((t) => t.displayTitle !== null && t.nextStep !== null)).toBe(true); + // Widened issue raw (0018): milestone title/due_on renders on a card. + expect(mw.todo.some((t) => t.milestone !== null && t.milestone.title.length > 0)).toBe(true); + }); + + it("Roadmap: narrative + milestones carrying progress", async () => { + const plan = await get_plan(env.DB); + expect(plan.narrative.length).toBeGreaterThan(0); + expect(plan.milestones.length).toBe(7); + expect(plan.milestones.some((m) => m.progress && m.progress.total > 0)).toBe(true); + // Milestones span multiple roadmap phases. + expect(new Set(plan.milestones.map((m) => m.phase)).size).toBeGreaterThan(1); + }); + + it("Search: ranked hits for a known term", async () => { + const r = await query(env.DB, { q: "MCP", include_staged: true }); + expect(r.primary.length).toBeGreaterThan(0); + }); + + it("Feed: tagged entries present", async () => { + expect((await get_feed(env.DB, {})).length).toBeGreaterThan(0); + expect((await get_feed(env.DB, { tags: ["auth"] })).length).toBeGreaterThan(0); + }); + + it("Triage: all four queues populated", async () => { + expect((await list_proposals(env.DB)).length).toBeGreaterThan(0); + expect((await list_needs_triage(env.DB)).length).toBeGreaterThan(0); + expect((await list_adrs(env.DB, "draft")).length).toBeGreaterThan(0); + expect((await list_identity_tasks(env.DB)).length).toBeGreaterThan(0); + }); +});