From e0ea1fe12bde0678413334a03fae7ea853feead4 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 14 May 2026 01:20:53 +0200 Subject: [PATCH] Add reviews for Bootstrap-wizard plans v3.2 to v3.4 - Review v3.2: Addressed executor model, fire-and-forget issues, and PAT handling. - Review v3.3: Improved transaction handling, stale recovery, and ID generation. - Review v3.4: Finalized GitHub permissions, catalog versioning, and E2E verification queries. - Updated recommendations for each version to enhance implementation readiness. --- docs/INDEX.md | 9 +- docs/plans/M8-bootstrap-wizard.md | 1170 +++++++++++++++++ ...bootstrap-wizard-plan-review-2026-05-13.md | 89 ++ ...-plan-v2-web-research-review-2026-05-13.md | 210 +++ ...trap-wizard-plan-v3-2-review-2026-05-14.md | 109 ++ ...trap-wizard-plan-v3-3-review-2026-05-14.md | 73 + ...trap-wizard-plan-v3-4-review-2026-05-14.md | 121 ++ 7 files changed, 1780 insertions(+), 1 deletion(-) create mode 100644 docs/plans/M8-bootstrap-wizard.md create mode 100644 docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md create mode 100644 docs/recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md create mode 100644 docs/recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md create mode 100644 docs/recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md create mode 100644 docs/recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 3a4dccf..9222da8 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # Documentation Index -Auto-generated on 2026-05-11 from front-matter and headings. +Auto-generated on 2026-05-13 from front-matter and headings. ## Architecture Decision Records @@ -43,6 +43,8 @@ Auto-generated on 2026-05-11 from front-matter and headings. | [Plan: model + mode-selectie per ClaudeJob-kind](./plans/job-model-selection.md) | — | — | | [Verbeterplan load/render Product Backlog, Sprint en Solo](./plans/load-render-improvement-plan-2026-05-10.md) | draft | 2026-05-10 | | [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — | +| [Plan v3.5 — Bootstrap-wizard voor nieuwe Product-repo (Scrum4Me feature)](./plans/M8-bootstrap-wizard.md) | reviewed | — | +| [PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen](./plans/PBI-80-demo-prefs.md) | — | — | | [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — | | [Sprint MCP-tools — create_sprint & update_sprint](./plans/sprint-mcp-tools.md) | draft | 2026-05-11 | | [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 | @@ -112,6 +114,11 @@ Auto-generated on 2026-05-11 from front-matter and headings. | [Scrum4Me — API Test Plan](./qa/api-test-plan.md) | `qa/api-test-plan.md` | active | 2026-05-03 | | [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | active | 2026-05-03 | | [Caveman plan — Beelink naar Ubuntu Scrum4Me server](./recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md) | `recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md` | draft | 2026-05-09 | +| [Review - Bootstrap-wizard plan](./recommendations/bootstrap-wizard-plan-review-2026-05-13.md) | `recommendations/bootstrap-wizard-plan-review-2026-05-13.md` | draft | 2026-05-13 | +| [Review - Bootstrap-wizard plan v2 met webresearch](./recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md) | `recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md` | draft | 2026-05-13 | +| [Review - Bootstrap-wizard plan v3.2](./recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md) | `recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md` | draft | 2026-05-14 | +| [Review - Bootstrap-wizard plan v3.3](./recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md) | `recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md` | draft | 2026-05-14 | +| [Review — M8 bootstrap-wizard plan v3.4](./recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md) | `recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md` | — | — | | [Aanbeveling — Claude VM jobflow en gitstrategie](./recommendations/claude-vm-job-flow-git-strategy.md) | `recommendations/claude-vm-job-flow-git-strategy.md` | draft | 2026-05-09 | | [Load/render implementatie review](./recommendations/load-render-implementation-review-2026-05-10.md) | `recommendations/load-render-implementation-review-2026-05-10.md` | review | 2026-05-10 | | [Agent-flow: open issues & decision log](./runbooks/agent-flow-pitfalls.md) | `runbooks/agent-flow-pitfalls.md` | active | 2026-05-03 | diff --git a/docs/plans/M8-bootstrap-wizard.md b/docs/plans/M8-bootstrap-wizard.md new file mode 100644 index 0000000..25a5c54 --- /dev/null +++ b/docs/plans/M8-bootstrap-wizard.md @@ -0,0 +1,1170 @@ +--- +status: reviewed +author: Claude +version: 6 +created_at: 2026-05-14 +reviewed_by: [haiku, sonnet, opus] +--- + +# Plan v3.5 — Bootstrap-wizard voor nieuwe Product-repo (Scrum4Me feature) + +## Context + +Bij het aanmaken van een nieuw Product in Scrum4Me wil de user direct een GitHub-repo bootstrappen volgens canonical conventies (MD3-theme, ADR-systeem, docs-structuur, tooling). De catalogus van aanvinkbare opties + uitvoer-recepten leeft in de **database** (configureerbaar, audit-bar). Uitvoering gebeurt server-side via een **aparte `bootstrap-service`** — geen Claude-CLI, geen serverless fire-and-forget. + +**v3.2 verwerkt twee reviews + vijf pre-implementatie correcties + vijf clarificaties**: + +- **v1 review**: deterministic runtime, status-sync, schema-relaties, secret-boundary, action-validatie, ADR-coverage, route-groups, repo-slug +- **v2 web-research review**: één executor-model, PAT-shape, dry-run als feature, owner-picker, action-permissions, catalog-versioning, conditie weg uit MVP, tag-pinning behouden +- **Pre-implementatie correcties (v3.1)**: + 1. Snake-case DB-tables via `@@map` + 2. NOTIFY payload contract: `type: 'claude_job_status'`, `user_id` verplicht + 3. `lease_until` veld (bestaand) voor lease-renewal + 4. Git push via `isomorphic-git` (in-process credentials) + 5. `bootstrap-service` als sibling-directory +- **Pre-implementatie clarificaties (v3.2)**: + 1. Shared package npm-publicatie: trigger op derde consumer of coordination-pijn + 2. Recipe-hash determinisme: hash over `recipe_snapshot` (niet `selected_options`), canonicalized + 3. Dry-run file-tree: gefilterd ignore-set + cap 500 entries + 4. Template cache: geen cache in MVP; fase-2 disk-cache met TTL-sweep + 5. Deployment target: multi-arch Dockerfile, Mac arm64 als primary +- **Plan v3.3 verwerkt review-v3.2** (5 P1 + 6 P2): + 1. **Claim-identiteit**: `claimed_by_worker_id String?` toegevoegd aan ClaudeJob (niet het bestaande `claimed_by_token_id` misbruiken) + 2. **Shared package in Scrum4Me-repo**: `packages/bootstrap-actions/` binnen deze repo (geen secrets, deploybaar); bootstrap-service consumeert via release + 3. **GitHub-side-effect checkpoints**: `github_repo_created_at`/`github_repo_id`/`github_repo_full_name`/`push_completed_at` + status `FAILED_NEEDS_CLEANUP` + 4. **Stale-recovery kind-filter**: SQL altijd `AND kind='BOOTSTRAP_REPO'` + 5. **Atomic enqueue**: pre-generated cuid IDs binnen transaction-array + 6. **Cancel-safe terminal sync**: conditional `updateMany` voor success/failure + 7. **`last_bootstrap_run_id`** met expliciete Prisma-relation + relation-name (om ambiguïteit met `Product.bootstrap_runs` te voorkomen) + `onDelete: SetNull` + 8. **Action-permissions naar action-niveau**: `risk_level`/`requires_role` op `BootstrapAction`; option-level derived van max + 9. **ID-strategie**: alle nieuwe modellen gebruiken `@default(cuid())` (consistent met 22 bestaande modellen) + 10. **Classic PAT MVP**: scope-detectie via `x-oauth-scopes` werkt alleen voor classic; fine-grained PATs als open punt + 11. **`.env.example` + deployment docs** in filelijst +- **Plan v3.4 verwerkt review-v3.3** (3 P1 + 4 P2): + 1. **Status-sync echt transactioneel**: één `prisma.$transaction(async tx => …)` callback met alle 3 updates + count-checks; lease_until + claimed_by_worker_id terminal op null + 2. **Stale-recovery split**: `FAILED_NEEDS_CLEANUP` alleen bij `github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL`; rest `FAILED` + 3. **Geen `@paralleldrive/cuid2` dep**: transaction-callback vorm met door Prisma gegenereerde cuid's + 4. `User.github_pat_scopes` krijgt `@default([])` voor migration-safety + 5. NOTIFY-payload `status` is **lowercase** (`jobStatusToApi`-output); DB blijft UPPER_SNAKE + 6. Stale-recovery komt naar **Sprint 1d** (MVP), niet pas fase-2 + 7. **Org-owner preflight via Octokit-call**: `RepoOwnerPicker` toont alleen owners waarvoor `octokit.repos.create…`-preflight slaagt; scope alleen is niet genoeg +- **Plan v3.5 verwerkt review-v3.4** (4 P2 + 3 P3 — geen P1's; go-signaal na P2-verwerking): + 1. **Org-owner preflight expliciet best-effort**: discovery toont owners; collision via `GET /repos/{owner}/{repo}`; finale autorisatie pas bij service-create; 403/422 → duidelijke wizard-fout; org-policy via `members_can_create_repositories` waar beschikbaar; ontbrekende info = "unknown" (niet automatisch verbergen) + 2. **`syncRunning` timestamp-contract**: bootstrap_runs.started_at én claude_jobs.started_at in **dezelfde transaction** met **dezelfde `now`-waarde**; unit-test voor PENDING/CLAIMED → RUNNING + 3. **`catalog_version` deterministisch**: canonical JSON over categories+options+actions, gesorteerd op display_order/slug/execution_order, alle relevante velden geïncludeerd, sha256 (niet md5) + 4. **E2E verification-query** JOIN naar `claude_jobs` voor `lease_until` + 5. **Stale-recovery globaal** (geen `claimed_by_worker_id`-filter); `claimed_by_worker_id` alleen voor renewal/observability + 6. **Vendor-copy drift CI-check** als concrete sprint-taak in Sprint 1a + verificatie-stap; service logt geladen `ActionSchema`-hash bij startup + 7. **`ADD_DEPENDENCY.version` regex**: MVP expliciet "alleen exact/range semver"; fase-2 `npm-package-arg`-parser voor `latest`/prerelease/`workspace:*`/`npm:`-aliases + +--- + +## Architectuur-besluiten + +| # | Onderwerp | Keuze | +|---|---|---| +| 1 | Scope | Server doet GitHub-side via **Octokit** (repo-create) + **isomorphic-git** (clone + push) | +| 2 | DB-model | Declaratieve recepten + Zod-validatie + action-permissions | +| 3 | Wizard | Gemengd radio/checkbox + **dry-run preview-stap** | +| 4 | Uitvoer | `ClaudeJobKind.BOOTSTRAP_REPO` voor uniforme UI/status; **aparte `bootstrap-service` claimt** | +| 5 | GitHub-auth | Per-user PAT (encrypted); service decrypt per run binnen execution-boundary | +| 6 | Schema | Product (`repo_owner`, `repo_slug`, `template_version`, …) + `BootstrapRun` + versioning-velden | +| 7 | UX | Twee-staps: Product → wizard (Configure → Preview → Run) | +| 8 | Catalog mgmt | Hybride — seed canonical, admin-UI fase-2 met `recipe_hash`-publish | + +--- + +## Executor: nieuwe `bootstrap-service` (sibling-directory) + +### Locatie + +**Sibling-folder**: `~/Development/bootstrap-service/` — naast `Scrum4Me/`, `scrum4me-mcp/`, `scrum4me-docker/`. Eigen `package.json`, `tsconfig.json`, `Dockerfile`, `prisma/schema.prisma` (gesynced via `sync-schema.sh` zoals scrum4me-mcp). + +**Niet**: +- In deze repo (zou worker-secrets in app-build mengen) +- Monorepo-package (deze codebase heeft geen monorepo-tooling; zou aparte tooling-investering vereisen) + +### Environment (`bootstrap-service/env.ts`) + +```ts +const Env = z.object({ + DATABASE_URL: z.string().url(), + DIRECT_URL: z.string().url(), // LISTEN/NOTIFY + BOOTSTRAP_ENCRYPTION_KEY: z.string().min(32), // AES-256-GCM key (gedeeld met app) + BOOTSTRAP_TEMPLATE_REPO: z.string().default('madhura68/nextjs-baseline'), + // GEEN ANTHROPIC_API_KEY, SESSION_SECRET, CRON_SECRET +}) +``` + +### Claim-protocol (gebruikt bestaand `lease_until` + nieuw `claimed_by_worker_id`) + +ClaudeJob krijgt een nieuw veld `claimed_by_worker_id String?` (separaat van bestaand `claimed_by_token_id` dat voor ApiToken-claim wordt gebruikt). Worker-ID is een unieke service-instance-identifier (`${hostname}-${pid}-${startTs}`). + +```ts +// In bootstrap-service/src/claim.ts: +const row = await prisma.$queryRaw<{ id: string }[]>` + UPDATE claude_jobs + SET status = 'CLAIMED', + lease_until = NOW() + INTERVAL '60 seconds', + claimed_at = NOW(), + claimed_by_worker_id = ${WORKER_ID} + WHERE id = ( + SELECT id FROM claude_jobs + WHERE status = 'QUEUED' AND kind = 'BOOTSTRAP_REPO' + ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1 + ) + RETURNING id +` +``` + +**Lease-renewal**: setInterval(30s) doet `UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '60 seconds' WHERE id = $1 AND claimed_by_worker_id = ${WORKER_ID}` (only-mine-guard). + +**Stale-recovery** — **strikt kind-gefilterd én split op externe side-effects**. Hoort in **Sprint 1d (MVP)**, niet pas fase-2; zonder dit kan een service-crash een job langdurig in `CLAIMED`/`RUNNING` laten hangen. + +```sql +-- Stap 1: markeer verlopen BOOTSTRAP_REPO jobs als FAILED (DB-side) +UPDATE claude_jobs +SET status='FAILED', error='lease expired', finished_at=NOW(), + lease_until=NULL, claimed_by_worker_id=NULL +WHERE status IN ('CLAIMED','RUNNING') + AND kind = 'BOOTSTRAP_REPO' + AND lease_until < NOW(); + +-- Stap 2a: runs met external side-effects → FAILED_NEEDS_CLEANUP (orphan repo mogelijk) +UPDATE bootstrap_runs +SET status='FAILED_NEEDS_CLEANUP', error='lease expired', finished_at=NOW() +WHERE status IN ('PENDING','RUNNING') + AND claude_job_id IN ( + SELECT id FROM claude_jobs + WHERE status='FAILED' AND kind='BOOTSTRAP_REPO' AND error='lease expired' + ) + AND (github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL); + +-- Stap 2b: runs zonder external side-effects → FAILED (clean failure) +UPDATE bootstrap_runs +SET status='FAILED', error='lease expired', finished_at=NOW() +WHERE status IN ('PENDING','RUNNING') + AND claude_job_id IN ( + SELECT id FROM claude_jobs + WHERE status='FAILED' AND kind='BOOTSTRAP_REPO' AND error='lease expired' + ) + AND github_repo_full_name IS NULL + AND github_repo_created_at IS NULL; +``` + +Bestaande Claude-runner cleanup (in `app/api/cron/cleanup-agent-artifacts/route.ts`) blijft ongemoeid — dit is een dedicated SQL-pad voor BOOTSTRAP_REPO. + +**Wanneer draait dit**: +- In MVP via een dedicated cron-route (`app/api/cron/bootstrap-stale-recovery/route.ts`) elke 5 minuten, getrigggerd door Vercel-cron of de bestaande cron-runner. Bearer-secret-protected zoals andere cron-routes. +- `bootstrap-service` draait dezelfde SQL bij startup als **globale recovery** voor alle verlopen `BOOTSTRAP_REPO`-leases (van wie dan ook). **Niet** filteren op `claimed_by_worker_id` — een herstartende service heeft een nieuw `worker_id` (hostname + pid + start-timestamp) en zou zijn eigen oude leases anders niet matchen. `claimed_by_worker_id` is dus puur voor lease-renewal-only-mine-guard en observability/log-correlatie; stale-recovery is `kind`- en `lease_until`-gebaseerd. + +**LISTEN-fallback**: service luistert op `scrum4me_changes` filtered op nieuwe enqueues (`type: 'claude_job_enqueued'`, `kind: 'BOOTSTRAP_REPO'`); poll-interval 30s als safety. + +### `scrum4me-docker` skip-filter + +`scrum4me-docker/bin/run-one-job.ts` claim-query toevoegen: `AND kind <> 'BOOTSTRAP_REPO'`. Twee runners delen de `claude_jobs`-tabel zonder overlap. + +--- + +## Status-sync (transactional + post-commit NOTIFY) + +```ts +// In bootstrap-service/src/status-sync.ts: +import { jobStatusToApi } from '@/lib/job-status' // gespiegeld via shared-package of bootstrap-service-eigen kopie + +async function syncSuccess(runId: string, jobId: string, productId: string, userId: string, repoUrl: string, templateVersion: string) { + // ÉÉN transaction; geen partial commits. + const result = await prisma.$transaction(async (tx) => { + const runUpdate = await tx.bootstrapRun.updateMany({ + where: { id: runId, status: 'RUNNING' }, + data: { status: 'SUCCEEDED', finished_at: new Date(), repo_url: repoUrl, push_completed_at: new Date() }, + }) + if (runUpdate.count === 0) { + // Was al CANCELLED of FAILED — abort, geen verdere writes + return { committed: false as const } + } + const jobUpdate = await tx.claudeJob.updateMany({ + where: { id: jobId, status: 'RUNNING' }, + data: { + status: 'DONE', + finished_at: new Date(), + summary: `Bootstrap completed: ${repoUrl}`, + lease_until: null, + claimed_by_worker_id: null, + }, + }) + if (jobUpdate.count === 0) { + // Race: job is buiten verwachting al terminal — gooi om transaction te rollbacken + throw new Error('job-state-mismatch') + } + await tx.product.update({ + where: { id: productId }, + data: { repo_url: repoUrl, template_version: templateVersion, last_bootstrap_run_id: runId }, + }) + return { committed: true as const } + }) + + if (!result.committed) return // niets te notifyen + + // NA commit: + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', + job_id: jobId, + user_id: userId, + kind: 'BOOTSTRAP_REPO', + status: jobStatusToApi('DONE'), // 'done' lowercase per bestaand contract + bootstrap_run_id: runId, + repo_url: repoUrl, + })}::text) + ` +} +``` + +**Belangrijke eigenschappen**: +- **Echt transactioneel**: bootstrap_runs, claude_jobs, en products updates zitten in dezelfde DB-transaction. Faal in een willekeurige stap → volledige rollback. Geen partial-committed-state mogelijk. +- **Cancel-safe**: status-filter in `where` zorgt dat een `CANCELLED`-overgang die tussendoor gebeurde niet door late terminal-write wordt overschreven. +- **Lease-cleanup terminal**: `lease_until` en `claimed_by_worker_id` worden expliciet `null` gezet bij terminal status; voorkomt dat stale-recovery deze record nog "ziet". +- **NOTIFY na commit**: pas na succesvolle commit; rollback betekent geen NOTIFY. +- **Status lowercase in payload**: `jobStatusToApi('DONE') → 'done'` matched bestaande SSE-clients; DB blijft UPPER_SNAKE. + +Voor `syncFailed`: identieke vorm, met aanvullende beslislogica voor terminal-status: +```ts +const terminalRunStatus = (run.github_repo_full_name || run.github_repo_created_at) + ? 'FAILED_NEEDS_CLEANUP' + : 'FAILED' +``` + +Voor `syncRunning`: **cancel-safe** analoog aan syncSuccess én **timestamp-contract expliciet**. Beide tabellen krijgen `started_at = now` in dezelfde transaction met dezelfde `now`-waarde, zodat downstream metrics, UI-sortering en E2E-queries betrouwbaar zijn. + +```ts +async function syncRunning(runId: string, jobId: string, userId: string) { + const now = new Date() // één waarde, gedeeld tussen run + job + const result = await prisma.$transaction(async (tx) => { + const runUpdate = await tx.bootstrapRun.updateMany({ + where: { id: runId, status: 'PENDING' }, + data: { status: 'RUNNING', started_at: now }, + }) + if (runUpdate.count === 0) return { committed: false as const } // CANCELLED ertussen + const jobUpdate = await tx.claudeJob.updateMany({ + where: { id: jobId, status: 'CLAIMED' }, + data: { status: 'RUNNING', started_at: now }, + }) + if (jobUpdate.count === 0) throw new Error('job-state-mismatch') // rollback + return { committed: true as const } + }) + if (!result.committed) return + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', job_id: jobId, user_id: userId, + kind: 'BOOTSTRAP_REPO', status: jobStatusToApi('RUNNING'), // 'running' + bootstrap_run_id: runId, + })}::text) + ` +} +``` + +**Verplichte unit-test** (Sprint 1a): `bootstrap_runs.started_at == claude_jobs.started_at` na `syncRunning`; allebei niet-null; transitie PENDING/CLAIMED → RUNNING. + +**GitHub side-effect checkpoint writes** (apart van terminal-sync, na elke external mutatie): +```ts +// Na Octokit createForAuthenticatedUser: +await prisma.bootstrapRun.update({ + where: { id: runId }, + data: { + github_repo_created_at: new Date(), + github_repo_id: repo.id, + github_repo_full_name: repo.full_name, + }, +}) +// Na isomorphic-git push success: +await prisma.bootstrapRun.update({ + where: { id: runId }, + data: { push_completed_at: new Date() }, +}) +``` + +Dit zorgt dat na een service-crash een stale-recovery weet wat er extern gebeurd is en kan beslissen tussen compensating delete vs. `FAILED_NEEDS_CLEANUP`-flag. + +**Payload-contract** matched bestaand `JobPayload`-type uit `app/api/realtime/jobs/route.ts`: +```ts +type JobPayload = { + type: 'claude_job_enqueued' | 'claude_job_status' + job_id: string + user_id: string // verplicht voor SSE-filter + kind?: string // 'BOOTSTRAP_REPO' + status: string + bootstrap_run_id?: string // nieuw extension-veld voor deze kind + task_id?: null + idea_id?: null + sprint_run_id?: null + ... +} +``` + +`bootstrap_run_id` is een **additieve uitbreiding**; bestaande consumers negeren onbekende velden veilig. + +--- + +## Git operaties: `isomorphic-git` (pure-JS) + +**Waarom isomorphic-git, niet shell `git`**: +- Geen subprocess in service-container +- PAT als HTTP-header in-process (`onAuth` callback), nooit in URL of shell-argv +- Geen credential-helper-state op disk +- Werkt op alle platforms zonder git-binary-dependency + +```ts +import * as git from 'isomorphic-git' +import http from 'isomorphic-git/http/node' +import fs from 'fs' + +// Clone template (tag-pinned): +await git.clone({ + fs, http, dir: tmpdir, + url: `https://github.com/${BOOTSTRAP_TEMPLATE_REPO}.git`, + ref: templateVersion, // bv. 'v1.0.0' + singleBranch: true, + depth: 1, +}) +const sourceSha = await git.resolveRef({ fs, dir: tmpdir, ref: 'HEAD' }) + +// (recipe-acties muteren tmpdir hier) + +// Init opnieuw als clean git-history voor target: +await fs.promises.rm(`${tmpdir}/.git`, { recursive: true, force: true }) +await git.init({ fs, dir: tmpdir, defaultBranch: 'main' }) +await git.add({ fs, dir: tmpdir, filepath: '.' }) +await git.commit({ + fs, dir: tmpdir, + message: `Bootstrap: ${selectedOptionsSummary}\n\nFrom template ${BOOTSTRAP_TEMPLATE_REPO}@${templateVersion}`, + author: { name: user.github_username ?? 'Scrum4Me', email: 'bootstrap@scrum4me.dev' }, +}) + +// Create remote repo via Octokit: +const octokit = new Octokit({ auth: pat }) +const { data: repo } = await octokit.repos.createForAuthenticatedUser({ // of createInOrg + name: repoSlug, private: true, auto_init: false, +}) + +// Push via isomorphic-git met token in onAuth-callback (nooit in URL): +await git.addRemote({ fs, dir: tmpdir, remote: 'origin', url: repo.clone_url }) +await git.push({ + fs, http, dir: tmpdir, remote: 'origin', ref: 'main', + onAuth: () => ({ username: 'x-access-token', password: pat }), // header-only +}) +``` + +`pat` blijft binnen function-scope; `onAuth` returnt closure-scoped value zonder logging. + +--- + +## Domein-model (Prisma) — met `@@map` + +### `BootstrapCategory` → table `bootstrap_categories` +```prisma +model BootstrapCategory { + id String @id @default(cuid()) + slug String @unique + label String + description String? + selection_type BootstrapSelectionType // SINGLE | MULTI + display_order Int + is_required Boolean @default(false) + options BootstrapOption[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + @@map("bootstrap_categories") +} +``` + +### `BootstrapOption` → table `bootstrap_options` +**Action-permissions verplaatst naar `BootstrapAction`** (review-fix). `BootstrapOption` houdt alleen catalog-eigenschappen; risk/role is per action. Option-level "effective risk" wordt server-side afgeleid (`MAX(action.risk_level) FOR action IN option.actions`) maar niet als kolom opgeslagen — alleen runtime-computed waar nodig. + +```prisma +model BootstrapOption { + id String @id @default(cuid()) + category_id String + category BootstrapCategory @relation(fields: [category_id], references: [id], onDelete: Cascade) + slug String + label String + description String? + is_default Boolean @default(false) + display_order Int + archived Boolean @default(false) + enabled Boolean @default(true) + actions BootstrapAction[] + @@unique([category_id, slug]) + @@index([category_id, display_order]) + @@map("bootstrap_options") +} +``` + +### `BootstrapAction` → table `bootstrap_actions` +```prisma +model BootstrapAction { + id String @id @default(cuid()) + option_id String + option BootstrapOption @relation(fields: [option_id], references: [id], onDelete: Cascade) + kind BootstrapActionKind + params Json // Zod-validated per kind + execution_order Int + supports_dry_run Boolean @default(true) + side_effects SideEffect[] // FILESYSTEM | GITHUB_REPO | GITHUB_SETTINGS | NETWORK + risk_level RiskLevel @default(LOW) // verplaatst van Option + requires_role RoleRequired @default(ANY) // verplaatst van Option + @@index([option_id, execution_order]) + @@map("bootstrap_actions") +} +``` + +Action-permissions worden bij start-action gevalideerd: `RUN_BASH_TEMPLATE` action met `requires_role=ADMIN` blokkeert non-admin users in `startBootstrapAction`. Dry-run-validatie idem (Backstage-pattern). + +**`condition` veld weggelaten uit MVP** — mini-DSL in fase-2. + +### `BootstrapRun` → table `bootstrap_runs` +**GitHub-side-effect checkpoints toegevoegd** (review-fix) — durable record van externe mutaties zodat crash-recovery weet wat opgeruimd moet worden. + +```prisma +model BootstrapRun { + id String @id @default(cuid()) + product_id String + product Product @relation(name: "ProductBootstrapRuns", fields: [product_id], references: [id], onDelete: Cascade) + user_id String + user User @relation(fields: [user_id], references: [id]) + claude_job_id String? @unique + claude_job ClaudeJob? @relation(name: "BootstrapRunJob", fields: [claude_job_id], references: [id], onDelete: SetNull) + status BootstrapRunStatus // PENDING | RUNNING | SUCCEEDED | FAILED | CANCELLED | FAILED_NEEDS_CLEANUP + template_version String + template_source_sha String? + catalog_version String + recipe_hash String + action_schema_version String + repo_owner_snapshot String + repo_slug_snapshot String + selected_options Json + recipe_snapshot Json + dry_run_report Json? + + // GitHub side-effect checkpoints (durable mutaties) + github_repo_created_at DateTime? + github_repo_id BigInt? // GitHub's numeric repo id — JSON.stringify() gooit TypeError; cast via .toString() of Number() bij API-serialisatie + github_repo_full_name String? // 'owner/repo' + push_completed_at DateTime? + + repo_url String? // gevuld na push_completed_at + started_at DateTime? + finished_at DateTime? + error String? @db.VarChar(8192) + output_log String? + created_at DateTime @default(now()) + + @@index([product_id, status]) + @@index([user_id, created_at]) + @@index([status, finished_at]) // voor stale-recovery sweep + @@map("bootstrap_runs") +} + +enum BootstrapRunStatus { + PENDING + RUNNING + SUCCEEDED + FAILED // recoverable / no orphan side-effects + FAILED_NEEDS_CLEANUP // orphan GitHub-repo or partial push; manual or compensating + CANCELLED +} +``` + +**Partial unique index** (raw SQL toegevoegd in migration): +```sql +CREATE UNIQUE INDEX bootstrap_runs_one_active_per_product + ON bootstrap_runs (product_id) + WHERE status IN ('PENDING','RUNNING'); +``` + +### `Product` uitbreiding (table `products`, `@@map` blijft) +```prisma +model Product { + // ... bestaande velden ... + repo_owner String? + repo_slug String? + template_version String? + last_bootstrap_run_id String? + last_bootstrap_run BootstrapRun? @relation(name: "ProductLastBootstrapRun", fields: [last_bootstrap_run_id], references: [id], onDelete: SetNull) + bootstrap_runs BootstrapRun[] @relation(name: "ProductBootstrapRuns") // history + @@unique([repo_owner, repo_slug]) + // ... bestaande @@map("products") ... +} +``` + +Twee expliciete relaties met disjoint relation-names: `ProductLastBootstrapRun` voor de huidige pointer (SetNull bij delete van de run), `ProductBootstrapRuns` voor de history (Cascade bij delete van de product). Prisma vereist named relations bij meerdere relaties tussen dezelfde modellen. + +### `User` uitbreiding (table `users`) +```prisma +model User { + // ... bestaande velden ... + github_pat_encrypted String? // prefix 'v1:' + github_username String? + github_pat_verified_at DateTime? + github_pat_scopes String[] @default([]) // default-array voor migration-safety op bestaande users + github_pat_expires_at DateTime? + github_orgs Json? + bootstrap_runs BootstrapRun[] + // ... bestaande @@map("users") ... +} +``` + +`@default([])` zorgt dat de migration op een bestaande database met users geen backfill nodig heeft; bestaande rijen krijgen een lege array. + +### `ClaudeJob` uitbreiding (table `claude_jobs`) +```prisma +model ClaudeJob { + // ... bestaande velden, inclusief lease_until + claimed_by_token_id ... + claimed_by_worker_id String? // NIEUW: voor bootstrap-service (en toekomstige worker-types) + bootstrap_run BootstrapRun? @relation(name: "BootstrapRunJob") + // ... bestaande @@map("claude_jobs") ... +} +``` + +`claimed_by_worker_id` blijft naast `claimed_by_token_id` (bestaand): laatste is voor ApiToken-claim door de Claude-CLI runner, eerste is een free-form service-instance-identifier voor `bootstrap-service`. Geen FK; pure log/correlation-veld. + +`ClaudeJobKind` enum: `BOOTSTRAP_REPO` toegevoegd. + +### Env (`lib/env.ts`) +- App: `BOOTSTRAP_ENCRYPTION_KEY` (required, min 32), `BOOTSTRAP_TEMPLATE_REPO` (default `madhura68/nextjs-baseline`) + +--- + +## PAT-secret-boundary + +**`startBootstrapAction` decrypt nooit**: +- Bewaart geen plaintext PAT +- Geeft alleen `runId` mee aan executie + +**`bootstrap-service` decrypt per run** binnen execution-scope: +```ts +const run = await prisma.bootstrapRun.findUnique({ where:{ id: runId }, include:{ user: true }}) +let pat = decryptPat(run.user.github_pat_encrypted, env.BOOTSTRAP_ENCRYPTION_KEY) +try { await executeRecipe(run, pat) } +finally { pat = ''; /* GC */ } +``` + +**Test-flow voor PAT** (`saveGitHubPatAction`) — **classic PAT in MVP**: +```ts +const octokit = new Octokit({ auth: token }) +const { data: me, headers } = await octokit.rest.users.getAuthenticated() +const scopes = (headers['x-oauth-scopes'] ?? '').split(',').map(s => s.trim()).filter(Boolean) +if (!scopes.includes('repo')) { + throw new Error('Classic PAT met scope "repo" vereist. Fine-grained PATs nog niet ondersteund — zie open punten.') +} +// Encrypt + opslaan + verified_at/scopes +``` + +**Fine-grained PATs werken anders** — geen `x-oauth-scopes` header, wel `x-accepted-github-permissions` of repository-permission-set via `GET /user/installations`. Voor MVP **alleen classic PAT ondersteund**; settings-UI toont dit expliciet ("Vereist een classic PAT met `repo` scope — fine-grained tokens nog niet ondersteund."). Fine-grained support staat in open punten. + +--- + +## Dry-run / preview (eerste-klas) + +> **Implementatienoot**: `previewBootstrapAction` doet een git clone + recipe-run (~2-5s). Als dit te zwaar wordt voor een Next.js Server Action (Vercel function geheugendruk bij hoog volume), migreer naar een Route Handler (`POST /api/bootstrap/preview`) met streaming response. In MVP is Server Action acceptabel; monitor Vercel function-metrics. + +`previewBootstrapAction(productId, selections, repoOwner, repoSlug)`: +1. Auth + demo-check (403) +2. Zod-validate selections + GitHub-name regex +3. Resolve recipe + compute `recipe_hash` + `catalog_version` +4. Spin up tmpdir + clone template (geen cache in MVP — zie Template-cache-sectie) +5. Run alle handlers met `supports_dry_run=true` tegen tmpdir; `RUN_BASH_TEMPLATE` logged als "skipped" +6. Octokit preflight: `octokit.repos.get({ owner, repo })` om collision te detecteren +7. Octokit preflight: `octokit.orgs.list...` om owner-rechten te valideren +8. **File-tree filter en cap** (zie hieronder) +9. Retourneer `DryRunReport`: + ```ts + { fileTree: string[]; truncated: boolean; actionLog: Array<{ kind, summary, status }>; warnings: string[]; canProceed: boolean; collisions: { owner: string; slug: string } | null } + ``` + +Geen DB-write, geen GitHub-write. Wel telemetry. + +### Org-owner preflight — **best-effort discovery** (geen harde create-permission proof) + +De wizard heeft drie verschillende garanties op verschillende plekken; **alleen de service-side `octokit.repos.create*`-call is de finale autorisatie**. Eerdere checks zijn best-effort hints, niet bewijzen. + +**Stap 1: Best-effort owner-discovery** (in `RepoOwnerPicker`) + +```ts +// Voor user-owner: altijd tonen indien PAT geldig +await octokit.users.getAuthenticated() // moet slagen voor PAT-validiteit + +// Voor elke org uit github_orgs cache: best-effort metadata-fetch +try { + const { data: org } = await octokit.orgs.get({ org: orgLogin }) + return { + login: orgLogin, + member_status: 'visible', + members_can_create_private: org.members_can_create_private_repositories ?? null, // null = onbekend + members_creation_type: org.members_allowed_repository_creation_type ?? null, + } +} catch (err) { + // 404 / 403 = onbekend, niet automatisch verbergen + return { login: orgLogin, member_status: 'unknown' } +} +``` + +De UI toont **alle** zichtbare orgs (plus user) met een hint-badge: "✓ kan repos maken" / "⚠ org-policy onbekend" / "⚠ org-policy blokkeert private repos". Geen owner wordt **automatisch verborgen** op basis van twijfelachtige info — dat zou false negatives (legitieme orgs verborgen) creëren. + +**Stap 2: Collision-check** vóór wizard-submit + +```ts +try { + await octokit.repos.get({ owner, repo }) // 200 = collision; 404 = vrij + return { canProceed: false, collision: { owner, repo }} +} catch (err) { + if (err.status === 404) return { canProceed: true } + return { canProceed: false, reason: 'preflight-failed' } +} +``` + +**Stap 3: Finale autorisatie** = de werkelijke `octokit.repos.createForAuthenticatedUser`/`createInOrg` in `bootstrap-service`. GitHub-antwoord is de waarheid: +- `201 Created` → succes, ga door +- `403 Forbidden` → vertaal naar wizard-fout "Geen rechten om repo te maken in ``. Mogelijke oorzaken: SSO niet geautoriseerd, org-policy blokkeert, of PAT mist scope. [Wijzig owner] [Wijzig PAT]" +- `422 Unprocessable Entity` → typisch naam-conflict (race vs. stap 2); zelfde wizard-fout met "naam al in gebruik" +- Andere fouten → generieke "GitHub-fout: " + retry-knop + +**Documenteer dit expliciet in `RepoOwnerPicker`-tooltip**: "Owners worden best-effort gedetecteerd. Sommige org-policies (SSO, admin-only-repo-create) zijn niet vooraf zichtbaar; de daadwerkelijke create-actie is de finale check." + +Scope `repo` blijft een **noodzakelijke** voorwaarde (gecheckt bij `saveGitHubPatAction`), maar de UI gebruikt geen scope-alleen heuristiek om owners te verbergen. + +### File-tree scope (filter + cap) + +`fileTree` is **gefilterd** anders wordt het onleesbaar (~200+ files in een Next.js-template). + +Ignore-patterns (gitignore-stijl): +``` +.git/ +node_modules/ +.next/ +dist/ build/ out/ +*.log +.DS_Store +.env* +coverage/ +``` + +**Hard cap** van 500 entries; bij overschrijding `truncated: true` met indicator `[+12 more files omitted]`. MVP: flat `string[]`. Fase-2 kan migreren naar: +```ts +type DryRunFileTreeNode = { name: string; type: 'file' | 'dir'; children?: DryRunFileTreeNode[]; size?: number } +``` + +### Template-cache lifecycle + +**MVP: geen cache** — `git clone --depth=1` met `isomorphic-git` is snel (~2-3s op kleine templates); cache-complexity is YAGNI tot het pijn doet. + +**Fase-2 strategie** (als preview-latency UX-probleem wordt): +- Disk-cache in `/var/cache/bootstrap-service///` +- Persistent across service-restarts (snelle warm-start) +- **Niet** clearen op deploy (deploys frequent, cache-warmup duur) +- **Geen invalidation nodig** voor tag-pinned versies (semver-tags zijn immutable; re-tag is mis-use) +- TTL-sweep via `last_used.json` mark-file per cached version; cron verwijdert versies >30 dagen ongebruikt + +--- + +## Catalog/recipe versioning + +### Hash-input bepaling (determinisme) + +`recipe_hash` wordt berekend over **`recipe_snapshot`** (de resolved action-list), **niet** over `selected_options`. Reden: identieke `selected_options` met een andere catalog-versie produceert andere acties → andere uitkomst; de hash moet dat onderscheid tonen. `catalog_version` blijft een orthogonaal apart veld. + +Canonicalization-regels (in `packages/bootstrap-actions/recipe-hash.ts`): +```ts +function canonicalize(recipe: RecipeSnapshot): string { + const sorted = { + actions: recipe.actions + .sort((a, b) => a.execution_order - b.execution_order) + .map(a => ({ + kind: a.kind, + execution_order: a.execution_order, + params: sortObjectKeysRecursive(a.params), + })), + } + return JSON.stringify(sorted) +} +export function recipeHash(recipe: RecipeSnapshot): string { + return createHash('sha256').update(canonicalize(recipe)).digest('hex') +} +``` + +Geen timestamps, geen UUIDs in de hash-input. Identieke recipe ⇔ identieke hash, garandeert deterministische replay. + +### Velden op `BootstrapRun` + +- `recipe_hash` = sha256(canonicalize(recipe_snapshot)) +- `catalog_version` = sha256(canonicalize(catalog_snapshot)) — zelfde discipline als recipe_hash +- `action_schema_version` = hardcoded in shared package; bumped bij schema-breaks +- `template_source_sha` = git SHA na clone + +### Deterministische `catalog_version` berekening + +Niet `md5(string_agg(...))` — dat is zonder ordering niet deterministisch en mist categories/actions. Gebruik dezelfde canonical-JSON-aanpak als `recipe_hash`: + +```ts +// In packages/bootstrap-actions/catalog-hash.ts +export function catalogVersion(catalog: CatalogSnapshot): string { + const sorted = { + categories: catalog.categories + .sort((a, b) => a.display_order - b.display_order || a.slug.localeCompare(b.slug)) + .map(cat => ({ + slug: cat.slug, + selection_type: cat.selection_type, + is_required: cat.is_required, + display_order: cat.display_order, + options: cat.options + .sort((a, b) => a.display_order - b.display_order || a.slug.localeCompare(b.slug)) + .map(opt => ({ + slug: opt.slug, + is_default: opt.is_default, + enabled: opt.enabled, + archived: opt.archived, + display_order: opt.display_order, + actions: opt.actions + .sort((a, b) => a.execution_order - b.execution_order || a.id.localeCompare(b.id)) + .map(act => ({ + kind: act.kind, + execution_order: act.execution_order, + params: sortObjectKeysRecursive(act.params), + supports_dry_run: act.supports_dry_run, + side_effects: [...act.side_effects].sort(), + risk_level: act.risk_level, + requires_role: act.requires_role, + })), + })), + })), + } + return createHash('sha256').update(JSON.stringify(sorted)).digest('hex') +} +``` + +Wijzigingen in **elk** van deze velden (selection_type, required/default, enabled/archived, action kind, params, dry-run support, side effects, risk_level, requires_role) leveren een nieuwe `catalog_version`. Geen md5; sha256 voor consistentie met `recipe_hash`. SQL-loading: `SELECT * FROM bootstrap_categories WHERE archived=false ORDER BY display_order, slug` + nested fetches; transformatie in TypeScript via `catalogVersion()`. + +### `.scrum4me/bootstrap.json` in target-repo (na succes) +```json +{ "template_repo": "...", "template_version": "v1.0.0", "template_source_sha": "...", + "catalog_version": "...", "recipe_hash": "...", "action_schema_version": "1.0", + "generated_at": "...", "selected_options": {...} } +``` + +--- + +## UI-componenten (`app/(app)/...`) + +| Component | Locatie | +|---|---| +| `BootstrapWizardDialog` | `app/(app)/products/[id]/_components/bootstrap-wizard-dialog.tsx` | +| `BootstrapWizardStep` | idem (per-categorie) | +| `RepoOwnerPicker` | idem | +| `BootstrapPreviewPanel` | idem | +| `BootstrapStatusPanel` | idem (SSE-status) | +| `GitHubPatSettings` | `app/(app)/settings/_components/github-pat-settings.tsx` | +| `BootstrapAdminPage` (fase-2) | `app/(app)/admin/bootstrap/page.tsx` | +| Product-detail-knop | `app/(app)/products/[id]/page.tsx` | + +Wizard-flow: **Configure → Preview → Run**. + +--- + +## Server-actions (`actions/bootstrap.ts`) + +- `previewBootstrapAction` → `DryRunReport` (**Sprint 1c**) +- `startBootstrapAction` → `{ runId }`; gebruikt partial unique index voor concurrency (**Sprint 1c**) +- `cancelBootstrapAction(runId)` → markeert `ClaudeJob.status='CANCELLED'`; service detecteert per-action (**Fase 2** — stub in MVP, geen UI-knop) +- `retryBootstrapAction(failedRunId)` → nieuwe run met zelfde selections (**Fase 2**) +- `saveGitHubPatAction(token)` → encrypt + verify + scope-detect + opslaan (**Sprint 1c**) + +Alle vijf: demo-check (403) + Zod + rate-limit. In MVP worden `cancelBootstrapAction` en `retryBootstrapAction` als gated stub geïmplementeerd (403 of 501) maar nog niet aangeroepen via UI. + +Atomisch enqueue via **transaction callback** — Prisma genereert beide cuid's intern, geen externe `cuid2`-dependency nodig: + +```ts +import { jobStatusToApi } from '@/lib/job-status' + +const { jobId, runId } = await prisma.$transaction(async (tx) => { + const job = await tx.claudeJob.create({ + data: { + kind: 'BOOTSTRAP_REPO', + status: 'QUEUED', + user_id: userId, + product_id: productId, + // requested_* allemaal null (deterministic runtime) + }, + select: { id: true }, + }) + const run = await tx.bootstrapRun.create({ + data: { + product_id: productId, + user_id: userId, + claude_job_id: job.id, + status: 'PENDING', + template_version, + catalog_version, + recipe_hash, + action_schema_version, + repo_owner_snapshot, + repo_slug_snapshot, + selected_options, + recipe_snapshot, + }, + select: { id: true }, + }) + return { jobId: job.id, runId: run.id } +}) + +// NA commit (niet IN transaction): +await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: jobId, + user_id: userId, + kind: 'BOOTSTRAP_REPO', + status: jobStatusToApi('QUEUED'), // 'queued' lowercase + bootstrap_run_id: runId, + })}::text) +` + +return { runId } +``` + +Concurrency wordt afgedwongen door partial unique index — een tweede gelijktijdige insert mislukt met unique violation. Geen externe ID-library nodig; Prisma's built-in `cuid()` default doet het werk. + +--- + +## Shared action-package (in **Scrum4Me-repo**) + +**Locatie**: `packages/bootstrap-actions/` **binnen de Scrum4Me-repo** (review-fix). Reden: dit package bevat geen secrets, alleen Zod-schema's en pure-JS file-handlers. Het mag dus mee in de Scrum4Me build — een `file:` link naar een sibling-directory zou de app onbouwbaar maken in Vercel/CI (sibling zit niet in de checkout). + +**Inhoud**: +- `schema.ts` — `ActionSchema` (Zod discriminated union) +- `handlers/{copy-file,write-file,append-to-file,replace-string,create-adr-stub,add-dependency,run-bash}.ts` +- `allowed-commands.ts` — RUN_BASH_TEMPLATE regex-array +- `recipe-hash.ts` — canonicalize + sha256 +- `types.ts` — gedeelde DryRunReport, ActionContext, etc. +- `package.json` met `"name": "@scrum4me/bootstrap-actions"`, `"version": "0.1.0"` + +**Consumers**: +- Scrum4Me-app: `lib/bootstrap/dry-run.ts` importeert `@scrum4me/bootstrap-actions` via workspace-resolution +- `bootstrap-service/src/runner.ts`: idem; consumeert via release (zie hieronder) + +**Hoe Scrum4Me-app het package gebruikt**: +- Als de Scrum4Me-repo één package.json heeft (geen workspaces): bouw als pure TS-bibliotheek in `packages/bootstrap-actions/` en consumeer via path-alias of monorepo-light setup +- Als workspaces wel beschikbaar zijn: voeg `"workspaces": ["packages/*"]` toe aan root `package.json` (minimal pnpm/npm workspaces; geen Turborepo nodig) +- MVP-pragmatisch: TypeScript path-alias `@scrum4me/bootstrap-actions` → `./packages/bootstrap-actions/src` in `tsconfig.json`, bundlen via Next.js default. **Let op**: ook `vitest.config.ts` krijgt `resolve.alias: { '@scrum4me/bootstrap-actions': './packages/bootstrap-actions/src' }` anders falen unit-tests op dit package. + +**Hoe bootstrap-service het consumeert**: +- **Optie A (MVP)**: `bootstrap-service` doet bij iedere release een **vendor-copy** van het package via een sync-script (`scripts/sync-bootstrap-actions.sh` — vergelijkbaar met `sync-schema.sh`). Sources blijven in Scrum4Me-repo authoritative; service kopieert een snapshot bij CI-build. +- **Optie B (later)**: publiceer naar GitHub Packages (`@madhura68/bootstrap-actions`) zodra een derde consumer of coordination-pijn dit rechtvaardigt. + +**Upgrade-trigger naar B** (geen sprint-automatisme — pas wanneer): +1. Derde consumer verschijnt (CI-pipeline, tweede service, externe contributor) +2. Coordination-pijn: `ActionSchema`-wijziging die app/service apart-deploy verbreekt zonder version-pin +3. Externe gebruiker vraagt het package buiten lokale dev + +--- + +## Action-schema + path-safety + +```ts +// packages/bootstrap-actions/src/schema.ts +const SafeRelPath = z.string().min(1).max(256) + .regex(/^[A-Za-z0-9_./-]+$/) + .refine(p => !p.startsWith('/'), 'absolute denied') + .refine(p => !p.split('/').includes('..'), 'parent traversal denied') + .refine(p => !p.split('/').includes('.git'), '.git denied') + +export const ActionSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('COPY_FILE'), params: z.object({ source: SafeRelPath, dest: SafeRelPath })}), + z.object({ kind: z.literal('WRITE_FILE'), params: z.object({ path: SafeRelPath, content: z.string().max(65_536) })}), + z.object({ kind: z.literal('APPEND_TO_FILE'), params: z.object({ path: SafeRelPath, content: z.string().max(65_536), marker: z.string().min(1).max(256) })}), + z.object({ kind: z.literal('REPLACE_STRING'), params: z.object({ file: SafeRelPath, find: z.string().min(1).max(1024), replace: z.string().max(8192) })}), + z.object({ kind: z.literal('CREATE_ADR_STUB'), params: z.object({ number: z.number().int().min(1).max(99), title: z.string().min(1).max(160), template: z.enum(['nygard','madr']) })}), + // MVP: alleen exact/range semver (geen 'latest', 'workspace:*', 'npm:'-aliases, geen prerelease-labels). + // Fase-2: vervang regex door npm-package-arg + allowlist van toegestane spec-types. + z.object({ kind: z.literal('ADD_DEPENDENCY'), params: z.object({ name: z.string().regex(/^[@a-z0-9/_.-]+$/), version: z.string().regex(/^[\^~>=<.\d-]+$/), dev: z.boolean() })}), + z.object({ kind: z.literal('RUN_BASH_TEMPLATE'), params: z.object({ command: z.string().refine(c => allowedCommands.some(rx => rx.test(c))) })}), +]) +``` + +Path-resolution in handlers: `path.resolve(tmpdir, params.dest)` + assert `result.startsWith(tmpdir + path.sep)`. + +**Run-level caps**: +- ≤ 200 acties per recipe +- ≤ 256 KiB output_log +- 30-min runtime watchdog +- ≤ 64 KiB per WRITE/APPEND content +- ≤ 50 MB tmpdir total + +--- + +## Enum-uitbreiding — exhaustive scope + +`ClaudeJobKind.BOOTSTRAP_REPO` ripple: +- `components/jobs/job-card.tsx` (`Record`) +- `components/jobs/jobs-column.tsx` +- `lib/insights/agent-throughput.ts` +- SSE initial-payload + filter-set +- `lib/job-config.ts` + spiegel-MCP: discriminated union returns `runtime: 'deterministic'` +- Tests rond exhaustive switches + +--- + +## Bestanden te wijzigen / aan te maken + +### Database (Scrum4Me) +- `prisma/schema.prisma` — modellen + enums + `@@map` annotaties + Product/User uitbreidingen +- `prisma/migrations/_bootstrap_wizard/migration.sql` — incl. partial unique index (raw SQL) +- `prisma/seed.ts` — `seedBootstrapCatalog()` met 6 core + add-ons +- `scrum4me-mcp/prisma/schema.prisma` — gesynced via `sync-schema.sh` +- `~/Development/bootstrap-service/prisma/schema.prisma` — gesynced idem + +### App-side (Scrum4Me) +- `lib/env.ts` — `BOOTSTRAP_ENCRYPTION_KEY`, `BOOTSTRAP_TEMPLATE_REPO` +- `lib/job-config.ts` (+ MCP-spiegel) — discriminated union, BOOTSTRAP_REPO → deterministic +- `lib/crypto/pat.ts` — encrypt only (decrypt leeft in service) +- `actions/bootstrap.ts` — preview/start/cancel/retry/savePat +- `actions/products.ts` — `repo_owner`, `repo_slug` velden +- `lib/bootstrap/dry-run.ts` — gebruikt shared handlers +- `lib/bootstrap/recipe.ts` — recipe-resolver van selections naar action-list + +### Shared package (in deze repo) +- `packages/bootstrap-actions/` — schema, handlers, types, recipe-hash, allowed-commands. Path-alias `@scrum4me/bootstrap-actions` in `tsconfig.json` +- `scripts/sync-bootstrap-actions.sh` — sync-script (analoog aan `sync-schema.sh`) dat het package naar `~/Development/bootstrap-service/packages/bootstrap-actions/` vendor-copyt bij service-build + +### Jobs-board (Scrum4Me) +- `components/jobs/job-card.tsx`, `jobs-column.tsx` +- `lib/insights/agent-throughput.ts` +- SSE-routes (`app/api/realtime/jobs/route.ts` etc.) + +### UI (Scrum4Me) +- `app/(app)/products/[id]/page.tsx` + `_components/*` +- `app/(app)/settings/_components/github-pat-settings.tsx` +- `app/(app)/admin/bootstrap/page.tsx` (fase-2) + +### Worker (scrum4me-docker) +- `bin/run-one-job.ts` — skip-filter `AND kind <> 'BOOTSTRAP_REPO'` +- `docs/manual/05-docker.md` — herbevestig secret-boundary + +### Nieuwe sibling-repo (`~/Development/bootstrap-service/`) +``` +bootstrap-service/ +├─ package.json # deps: prisma, isomorphic-git, @octokit/rest, zod +├─ tsconfig.json +├─ env.ts # service-env Zod-schema +├─ bin/run.ts # daemon: LISTEN + claim-loop + lease-renewal +├─ src/ +│ ├─ claim.ts # tryClaim, releaseClaim, renewLease (lease_until) +│ ├─ runner.ts # executeRecipe(run, pat) +│ ├─ template-clone.ts # isomorphic-git clone --depth=1 --tag +│ ├─ github.ts # Octokit wrapper + isomorphic-git push (onAuth) +│ ├─ status-sync.ts # transactionele update + post-commit pg_notify +│ ├─ crypto/pat.ts # decrypt +│ ├─ telemetry.ts # log-helper met token-scrubbing +│ └─ stale-recovery.ts # lease_until < NOW recovery +├─ prisma/schema.prisma # gesynced +├─ packages/bootstrap-actions/ # shared package (zie boven) +├─ Dockerfile # multi-arch, Mac arm64 primary +├─ docker-compose.yml # default target arm64 lokale Mac dev +├─ sync-schema.sh # zelfde patroon als scrum4me-mcp +└─ README.md +``` + +**Deployment target** (volgt Scrum4Me-pattern uit memory: "Docker deploy = Mac default; scrum4me-docker arm64 native; NAS-flow opt-in"): +- `Dockerfile` start met `FROM --platform=$BUILDPLATFORM node:24-alpine` +- Build met `docker buildx build --platform linux/amd64,linux/arm64 --push` +- `docker-compose.yml` default target arm64 voor lokale Mac dev +- CI publiceert beide platforms naar GitHub Container Registry (`ghcr.io/madhura68/bootstrap-service`) +- Geen platform-specifieke deps — `isomorphic-git`, `@octokit/rest` zijn pure-JS; Prisma client multi-arch native +- NAS/Linux-deploy (Beelink server etc.) is opt-in via `--platform=linux/amd64` + +### ADR + docs (Scrum4Me) +- `docs/adr/0009-bootstrap-wizard.md` (Nygard, accepted) +- `docs/architecture/bootstrap-service.md` +- `docs/runbooks/bootstrap-wizard.md` — operatorpad incl. PAT-setup, troubleshooting, FAILED_NEEDS_CLEANUP handling +- `docs/INDEX.md` regenereren + +### Env + deployment docs +- `.env.example` (in Scrum4Me-repo) — voeg `BOOTSTRAP_ENCRYPTION_KEY` en `BOOTSTRAP_TEMPLATE_REPO` toe met instructies hoe te genereren (`openssl rand -base64 32`) +- `~/Development/bootstrap-service/.env.example` — `DATABASE_URL`, `DIRECT_URL`, `BOOTSTRAP_ENCRYPTION_KEY` (gedeeld met app) +- `~/Development/bootstrap-service/README.md` — setup-instructies (clone, sync-schema, npm install, env, npm run dev) +- `docs/manual/06-bootstrap-service.md` — deployment-runbook voor productie (Mac arm64 default, Linux opt-in) + +### Externe template-repo +- Apart traject: `madhura68/nextjs-baseline` v1.0.0 — geen wijzigingen in deze PR + +--- + +## Fasering + +**Sprint 1a — Contracten** +1. ADR-0009 +2. `JobConfig` discriminated union + tests +3. `scrum4me-docker/bin/run-one-job.ts` skip-filter + tests +4. Shared `bootstrap-actions` package scaffold (schema + handler-interfaces) +5. `lib/bootstrap/notify.ts` post-commit pg_notify helper + tests +6. **Schema-hash drift-check** (vendor-copy mitigatie): `scripts/check-bootstrap-actions-hash.sh` berekent `sha256` over `packages/bootstrap-actions/src/**` en vergelijkt met `~/Development/bootstrap-service/packages/bootstrap-actions/.hash`. CI faalt bij mismatch. Service logt bij startup geladen hash + `action_schema_version`. + +**Sprint 1b — Schema + seed + safety** +6. Prisma-modellen (incl. `@@map`) + migration (incl. partial unique index) +7. Action-handlers in shared package (idempotent, path-safe) +8. Seed met 6 core categorieën + minimale add-ons +9. Jobs-board enum-uitbreidingen + +**Sprint 1c — PAT + Dry-run** +10. `lib/crypto/pat.ts` (encrypt) + tests +11. `GitHubPatSettings` UI + `saveGitHubPatAction` (scope-detect + verify) +12. `previewBootstrapAction` + `lib/bootstrap/dry-run.ts` +13. Wizard: Configure + Preview steps + +**Sprint 1d — bootstrap-service + E2E** +14. Sibling-repo `bootstrap-service/` opzetten: package.json, env, sync-schema.sh, sync-bootstrap-actions.sh, Dockerfile +15. Claim-loop + LISTEN + lease-renewal (gebruikt bestaand `lease_until` + nieuw `claimed_by_worker_id`) +16. Execute-flow: isomorphic-git clone + recipe + Octokit createRepo + isomorphic-git push + side-effect checkpoints +17. Status-sync transactional + post-commit NOTIFY (lowercase status) +18. **Stale-recovery cron** `/api/cron/bootstrap-stale-recovery` met split-strategie (`FAILED` vs `FAILED_NEEDS_CLEANUP`) — MVP, niet fase-2 +19. **Service-startup logging**: print `action_schema_version`, schema-hash, en geladen catalog-version voor observability/drift-detectie +20. `BootstrapStatusPanel` realtime SSE +21. E2E: nieuw product → wizard → preview → run → SUCCEEDED → repo bestaat +22. **Drift-verificatie-stap**: CI-job die `check-bootstrap-actions-hash.sh` draait na elke bootstrap-service-image-build; release-pipeline faalt bij mismatch met Scrum4Me-bron + +**Fase 2**: +- Add-ons (sentry, web-push, realtime, demo-policy, MD3-theme) +- `RUN_BASH_TEMPLATE` met allowlist +- `condition` mini-DSL +- `retryBootstrapAction` + `cancelBootstrapAction` +- Admin-CRUD UI + catalog-publish met dry-run-gate +- Update-detection +- `FAILED_NEEDS_CLEANUP` admin-UI met handmatige "Cleanup orphan repo"-knop + +**Fase 3**: +- Sandbox-isolatie per run +- GitHub App (van per-user PAT naar app-installation) +- Multi-tenant cross-org owners +- GitHub-webhook back-channel + +--- + +## Verificatie + +```bash +# 1. Contracten +npm test -- lib/job-config +npm test -- lib/bootstrap/notify +npm test -- packages/bootstrap-actions/schema + +# 2. Schema (alle tables snake_case) +npx prisma migrate dev +npm run seed +psql "$DATABASE_URL" -c "\dt" # toont bootstrap_categories, bootstrap_options, bootstrap_actions, bootstrap_runs +psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM bootstrap_categories" # 7 +psql "$DATABASE_URL" -c "SELECT slug FROM bootstrap_categories WHERE is_required = true ORDER BY display_order" +# Expect: deploy, auth, database, ui-components, state-management, testing + +# 3. PAT +# - Settings → plak PAT met repo-scope → Test toont "✓ " + scopes +psql "$DATABASE_URL" -c "SELECT length(github_pat_encrypted), github_pat_scopes FROM users WHERE id=X" +# Expect: lengte > 100; scopes = ['repo'] + +# 4. Dry-run +# - Wizard configure → Preview-stap → DryRunReport file-tree zichtbaar +# - Geen DB-row bij bootstrap_runs aangemaakt +# - Geen GitHub-write-call (alleen GET /repos/{owner}/{repo}) + +# 5. End-to-end +# Maak product 'ops-dashboard'; wizard alle 6 core; repo_owner=madhura68; preview groen; Run +# Service claimt binnen 2s; lease_until wordt verlengd; status SUCCEEDED in <60s +psql "$DATABASE_URL" -c " + SELECT br.status, + br.repo_url, + br.recipe_hash, + cj.lease_until > NOW() AS lease_active + FROM bootstrap_runs br + JOIN claude_jobs cj ON cj.id = br.claude_job_id + ORDER BY br.started_at DESC NULLS LAST, br.created_at DESC + LIMIT 1 +" +# NB: lease_until staat op claude_jobs, niet bootstrap_runs — JOIN nodig +psql "$DATABASE_URL" -c " + SELECT status, finished_at FROM claude_jobs + WHERE kind = 'BOOTSTRAP_REPO' ORDER BY created_at DESC LIMIT 1 +" +# Expect: DONE, finished_at gevuld +gh api repos/madhura68/ops-dashboard/contents/.scrum4me/bootstrap.json | jq . +# Expect: { template_version: 'v1.0.0', recipe_hash: ..., selected_options: {...} } +gh api repos/madhura68/ops-dashboard/contents/docs/adr | jq '.[].name' +# Expect: 0000…0006-... (alle 6 stubs) + +# 6. Failure +# Invalid PAT → bootstrap → FAILED met "Bad credentials" — geen orphan repo +psql "$DATABASE_URL" -c "SELECT status, error FROM bootstrap_runs WHERE status='FAILED' ORDER BY finished_at DESC LIMIT 1" + +# 7. Concurrency +# Twee gelijktijdige starts → één gaat door, andere unique violation +psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM bootstrap_runs WHERE product_id=X AND status IN ('PENDING','RUNNING')" +# Expect: 1 + +# 8. Demo-policy +# Login als demo → Bootstrap-knop niet zichtbaar; direct API call → 403 + +# 9. Lease-renewal +# Tijdens RUNNING-fase: verifieer dat lease_until > NOW + 30s +psql "$DATABASE_URL" -c "SELECT lease_until - NOW() FROM claude_jobs WHERE status='RUNNING'" + +# 10. Stale-recovery (Sprint 1d — niet fase-2) +# Kill bootstrap-service tijdens RUNNING; cron picks up na lease_until < NOW +psql "$DATABASE_URL" -c "SELECT status FROM claude_jobs WHERE id=X" +# Expect na cleanup: FAILED (geen GitHub side-effects) of FAILED_NEEDS_CLEANUP (repo al aangemaakt) + +# 11. Project-acceptatie +npm run verify && npm run build +``` + +**Lokaal dev**: +```bash +# In Scrum4Me/: +npm run dev + +# In ~/Development/bootstrap-service/ (apart terminal): +npm run dev # of `docker compose up bootstrap-service` +``` + +--- + +## Deployment prerequisites (vóór Sprint 1d) + +- `madhura68/nextjs-baseline` repo **must be public** en tag `v1.0.0` aanwezig (anders falen alle clone-ops) +- `BOOTSTRAP_ENCRYPTION_KEY` genereren: `openssl rand -base64 32` — **zelfde waarde** in Scrum4Me-app en bootstrap-service +- Prisma migration geapplied + seed gedraaid + +--- + +## Accepted risks (bewuste keuzes) + +- **PAT in JS function-closure**: `pat = ''` helpt niet bij GC van de originele immutable string — in Node.js geen betere optie zonder `Buffer`-gebruik. Accepted: PAT leeft kort (functie-scope), geen logging, geen serialisatie buiten de closure. +- **previewBootstrapAction als Server Action**: kan zware Vercel function zijn bij hoog volume — monitor in productie; migreer naar Route Handler als nodig. +- **Vendor-copy sync-drift**: als `ActionSchema` in Scrum4Me-repo wijzigt maar sync-script niet gedraaid is, silent mismatch in bootstrap-service. Mitigatie: CI-check in bootstrap-service die de schema-hash vergelijkt. + +--- + +## Open punten (post-MVP) + +- **Idempotency** voor APPEND_TO_FILE (marker-based) en RUN_BASH_TEMPLATE +- **Key rotation** voor `BOOTSTRAP_ENCRYPTION_KEY` (`v1:` prefix maakt versionering mogelijk) +- **PAT-expiry honoring** — UI-prompt als `github_pat_expires_at < now + 7d` +- **Fine-grained PAT support** — vereist andere scope-detection-logica (`x-accepted-github-permissions` of `GET /user/installations`); UI moet kunnen kiezen tussen classic en fine-grained +- **GitHub App migratie** — fase-3 alternative voor PAT (granular permissions, geen user-token-management) +- **Shared package upgrade** — van vendor-copy naar gepubliceerd GitHub Packages-package als derde consumer komt +- **`FAILED_NEEDS_CLEANUP` recovery flow** — admin-UI knop voor handmatige `octokit.repos.delete` of "mark as resolved" +- **Bootstrap-service unit-tests** — claim.ts, runner.ts, status-sync.ts hebben geen test-strategie beschreven; overweeg integration-tests met een lokale Postgres-instance +- **`ADD_DEPENDENCY.version` spec-broadening** — `npm-package-arg`-parser ondersteuning voor `latest`, prerelease (`^1.2.3-beta.1`), `workspace:*`, `npm:`-aliases, git/tarball specs; per-spec-type allowlist + +--- + +## Plan-locatie noot + +Bij implementatie-go: hernoem naar `docs/plans/M8-bootstrap-wizard.md` voor zichtbaarheid binnen Scrum4Me + MCP-Milestone-koppeling. diff --git a/docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md b/docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md new file mode 100644 index 0000000..03a4c10 --- /dev/null +++ b/docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md @@ -0,0 +1,89 @@ +--- +title: "Review - Bootstrap-wizard plan" +status: draft +date: 2026-05-13 +source_plan: "/Users/janpetervisser/.claude/plans/als-ik-een-nieuwe-virtual-turtle.md" +--- + +# Review - Bootstrap-wizard plan + +## Korte conclusie + +Het plan is functioneel sterk, maar niet uitvoerbaar zoals het nu geschreven is. De hoofdblokkade is dat `ClaudeJob` wordt gebruikt als deterministische queue, terwijl de huidige runner-architectuur `ClaudeJob` nog behandelt als een Claude CLI job met een verplicht model/config-pad. Trek dat eerst recht, anders eindigt de feature in typefouten, jobs die nooit terminal worden, of een worker die toch Claude probeert te starten. + +## Bevindingen + +### P1 - `BOOTSTRAP_REPO` met `model: null` breekt het huidige job-config contract + +Het plan zet voor `BOOTSTRAP_REPO` expliciet `model: null` omdat er geen LLM draait (plan regels 101-106). In de huidige code is `JobConfig.model` niet nullable en beperkt tot `ClaudeModel`; `snapshotFromConfig` schrijft die waarde daarna naar `ClaudeJob.requested_model` als string (`lib/job-config.ts` regels 27-33 en 205-210). `getJobConfigSnapshot` is bovendien het bestaande enqueue-pad voor nieuwe jobs (`lib/job-config-snapshot.ts` regels 1-7 en 34-39). + +Fix: maak deterministische jobs een expliciet ander runtime-pad. Bijvoorbeeld een discriminated union `runtime: 'claude' | 'deterministic'`, of laat `BOOTSTRAP_REPO` de Claude config snapshot volledig overslaan. Alleen een `KIND_DEFAULTS` entry met `model: null` is onvoldoende. + +### P1 - De worker-eigenaar staat verkeerd of is te vaag + +Het plan plaatst de dispatch in `scrum4me-mcp/src/lib/job-runner.ts` en noemt worker-bestanden in `scrum4me-mcp` (plan regels 172 en 235-238). De actuele runner-architectuur zegt iets anders: `scrum4me-docker/bin/run-one-job.ts` claimt jobs, resolve't config, bouwt CLI flags en spawnt `claude`; MCP levert tools/schema (`docs/runbooks/worker-idempotency.md` regels 170-176 en `docs/runbooks/mcp-integration.md` regel 12). + +Als alleen Scrum4Me en `scrum4me-mcp` wijzigen, gaat de docker-runner de nieuwe kind nog steeds claimen en behandelen als Claude-job. Neem een expliciete wijziging op voor `scrum4me-docker`, of definieer een aparte bootstrap-executor. Let ook op: de huidige worker doet een Anthropic quota pre-flight voordat hij claimt (`docs/runbooks/mcp-integration.md` regels 80-93). Daardoor kan een no-LLM bootstrap-job onterecht wachten op quota. + +### P1 - De worker-flow sluit de `ClaudeJob` niet terminal af + +In de pseudo-flow wordt bij succes alleen `BootstrapRun` en `Product` bijgewerkt, gevolgd door een generieke `NOTIFY` (plan regels 185-186). Bij fouten noemt het plan eveneens vooral `BootstrapRun` (plan regel 187). Het bestaande queue-protocol verwacht dat de job zelf naar `DONE`, `FAILED` of `CANCELLED` gaat en dat een `claude_job_status` event wordt verstuurd (`docs/runbooks/mcp-integration.md` regels 44-49). + +Fix: maak `BootstrapRun.status` en `ClaudeJob.status` een transactionele status-sync. Bij succes: `BootstrapRun.SUCCEEDED`, `ClaudeJob.DONE`, `finished_at`, `summary`, `repo_url`/`template_version`. Bij failure/cancel: beide terminal, inclusief `error`, en een `claude_job_status` notify. Anders blijven jobs `CLAIMED` of `RUNNING` en grijpt stale recovery later fout in. + +### P1 - De enum-uitbreiding veroorzaakt build-fouten buiten de genoemde files + +Het plan noemt `ClaudeJobKind.BOOTSTRAP_REPO`, maar niet alle plekken die exhaustief over `ClaudeJobKind` heen lopen. `JobCard` en `JobsColumn` gebruiken bijvoorbeeld `Record` (`components/jobs/job-card.tsx` regels 28-34 en `components/jobs/jobs-column.tsx` regels 16-22). Na Prisma generate mist daar een key en faalt typecheck. + +Fix: voeg jobs board labels/filters, initial SSE payloads, job detail rendering, cost/insight aggregaties en tests toe aan de scope. Dit is geen nice-to-have; het is build-path. + +### P1 - `BootstrapRun` koppeling mist relationele details + +Het plan zet `BootstrapRun.claude_job_id` als nullable FK en laat de worker de run ophalen via `run_id` (plan regels 83-90 en 175), maar `ClaudeJob` heeft nu alleen task/idea/sprint koppelingen (`prisma/schema.prisma` regels 385-424). Zonder helder model blijft onduidelijk hoe de geclaimde job precies bij de run komt. + +Fix: maak `BootstrapRun.claude_job_id` `@unique`, voeg relation names en een reverse relation op `ClaudeJob` toe, en indexeer `product_id/status`. Leg ook vast dat `startBootstrapAction` atomair voorkomt dat er meerdere actieve `PENDING`/`RUNNING` runs voor hetzelfde product ontstaan. Dit staat nu als open punt (plan regel 323), maar hoort in MVP. + +### P1 - PAT-encryptie botst met de huidige worker-secret boundary + +Het plan staat encryptie met `SESSION_SECRET` of een optionele `BOOTSTRAP_ENCRYPTION_KEY` toe (plan regels 98-110), en laat de worker de PAT decrypten (plan regel 176). De docker-worker docs zeggen juist dat de worker geen `DATABASE_URL`, `SESSION_SECRET` of `CRON_SECRET` hoort te hebben (`docs/manual/05-docker.md` regels 52-64). + +Fix: kies een expliciete credential-boundary. Waarschijnlijk moet `BOOTSTRAP_ENCRYPTION_KEY` verplicht worden voor app plus deterministische executor, of moet GitHub-side werk in de app/MCP-service gebeuren waar decryptie toegestaan is. Specificeer ook minimale PAT scopes, owner/namespace-keuze en voorkom dat de bestaande worker-level `GITHUB_TOKEN` per ongeluk repos onder de verkeerde account aanmaakt. + +### P1 - `BootstrapAction.params` is te vrij voor filesystem-acties + +Het plan gebruikt `params Json` voor acties en noemt alleen een bash allowlist als securitymaatregel (plan regels 57-75 en 210-214). Maar `COPY_FILE`, `WRITE_FILE`, `APPEND_TO_FILE` en `REPLACE_STRING` kunnen ook schade doen: path traversal via `../`, schrijven naar `.git/config`, absolute paden, te grote bestanden/logs, of onbedoelde workflow-mutaties. + +Fix: valideer elke action-kind met een Zod-schema bij seed/admin-save en opnieuw bij uitvoering. Normaliseer paden en assert dat source/dest binnen de template root of output root blijven. Deny `.git/**`, absolute paden en parent traversal. Cap `output_log`, `content` en aantal acties per run. + +### P1 - MVP spreekt de verplichte zes ADR-stubs tegen + +Het plan noemt zes verplichte ADR-stubs voor deploy/auth/DB/styling/state/testing (plan regel 25), maar de MVP seed bevat alleen deploy/auth/database (plan regels 253-260). De verificatie checkt ook alleen ADR-0001 tot ADR-0003 (plan regels 287-294). + +Fix: genereer de zes core ADR-stubs onvoorwaardelijk in MVP, of neem alle zes categorieen op in Sprint 1. Anders is de MVP niet consistent met de eigen acceptatie. + +### P2 - Fysieke UI-paden kloppen niet met de App Router route groups + +Het plan noemt fysieke files onder `app/products`, `app/settings` en `app/admin` (plan regels 159-164 en 240-244). In deze codebase zitten desktop routes onder `app/(app)/...` (`docs/architecture/project-structure.md` regels 18-42), bijvoorbeeld `app/(app)/products/[id]/page.tsx`. + +Fix: corrigeer de filelijst naar `app/(app)/products/[id]/...`, `app/(app)/settings/...` en `app/(app)/admin/...`. De URL blijft hetzelfde; de fysieke implementatieplek niet. + +### P2 - Verificatie noemt een niet-bestaand worker-script + +De verificatie zegt "manual: `npm run worker`" (plan regel 291), maar `package.json` heeft geen `worker` script (`package.json` regels 5-26). Dat maakt de E2E-stap niet reproduceerbaar. + +Fix: verwijs naar het echte `scrum4me-docker` runnercommando of voeg bewust een dev-script toe als onderdeel van de feature. + +### P2 - Repo-slug en GitHub owner zijn nog onvoldoende gespecificeerd + +De flow gebruikt `` en `` (plan regels 180-184), maar `Product` heeft nu `name`, optionele `code` en `repo_url`; geen slugveld (`prisma/schema.prisma` regels 196-227). `code` is bovendien niet hetzelfde als GitHub repo-validatie. + +Fix: voeg `repo_slug` toe aan de wizard of maak een gesnapshotte derivatie met GitHub-regels, collision-check, owner-keuze en duidelijke foutmelding wanneer de repo al bestaat. + +## Aanbevolen aanpassing van de volgorde + +1. Ontwerp eerst het deterministic-job contract: status-sync, runner-eigenaar, quota-bypass, config-bypass en `BootstrapRun` relation. +2. Voeg daarna schema + seed toe met path/action validatie en zes minimale ADR-stubs. +3. Bouw PAT settings en GitHub token test met expliciete scopes en owner-keuze. +4. Bouw pas daarna de wizard UI en E2E runner. + +Met die volgorde blijft de UI dun en voorkom je dat het meeste risico pas in de worker-integratie zichtbaar wordt. diff --git a/docs/recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md b/docs/recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md new file mode 100644 index 0000000..a821dba --- /dev/null +++ b/docs/recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md @@ -0,0 +1,210 @@ +--- +title: "Review - Bootstrap-wizard plan v2 met webresearch" +status: draft +date: 2026-05-13 +source_plan: "/Users/janpetervisser/.claude/plans/als-ik-een-nieuwe-virtual-turtle.md" +previous_review: "docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md" +--- + +# Review - Bootstrap-wizard plan v2 met webresearch + +## Conclusie + +De eerdere aanbevelingen zijn grotendeels verwerkt, maar nog niet "goed" genoeg om dit plan direct naar implementatie te brengen. V2 lost de meeste oude schema-, enum-, status- en path-safety punten op papier op. De grootste resterende fout is dat het plan twee executor-modellen tegelijk beschrijft: eerst `scrum4me-docker` als deterministic runner, later de Next.js app als executor met een fire-and-forget background promise. Kies er een. + +Mijn advies: maak de app niet de lange-running executor. Gebruik voor MVP een aparte `bootstrap-service` of breid de bestaande docker-runner expliciet uit met een veilig secret-contract. Vercel/Next fire-and-forget is te broos voor clone, file mutation, GitHub repo-create en push. + +## Zijn de eerdere aanbevelingen verwerkt? + +| Reviewpunt | Status | Oordeel | +|---|---:|---| +| Deterministic runtime ipv `model: null` | Ja | Goed concept, maar nog te veel gekoppeld aan `JobConfig` als de app uiteindelijk executor wordt. | +| Worker-eigenaar expliciet maken | Deels | V2 spreekt zichzelf tegen: docker-runner dispatch versus app-orchestrator. | +| Transactionele `BootstrapRun` + `ClaudeJob` status-sync | Ja | Goed. Hou notify na commit, niet in de DB-transaction zelf. | +| `ClaudeJobKind` exhaustive consumers | Ja | Goed opgenomen. | +| `BootstrapRun.claude_job_id @unique` + reverse relation | Ja | Goed. | +| Concurrency guard | Ja | Goed, vooral met DB-level partial unique index. | +| PAT secret-boundary | Deels | Docker krijgt geen DB/secrets meer, maar PAT wordt nu in memory doorgegeven aan een background promise. Dat is niet duurzaam. | +| Action-param validatie/path-safety | Ja | Goed, maar `condition: String?` blijft een risico. | +| Zes ADR-stubs in MVP | Ja | Goed. | +| App Router paden | Ja | Goed. | +| Niet-bestaand `npm run worker` | Ja | Gecorrigeerd. | +| `Product.repo_slug` | Ja | Goed begin, maar uniekheid moet eigenlijk per GitHub owner + slug, niet per Scrum4Me user. | + +## Nieuwe bevindingen + +### P1 - V2 heeft nog twee executor-architecturen tegelijk + +Regels 49-66 beschrijven dispatch in `scrum4me-docker/bin/run-one-job.ts`, inclusief deterministic dispatch en ephemeral PAT op job-claim. Regels 200-212 kiezen daarna voor "App is executor" en laten docker `BOOTSTRAP_REPO` juist niet claimen. Regels 285-322 werken vervolgens app-side fire-and-forget uit. + +Dat is geen detail; dit bepaalt wie claimt, wie secrets heeft, wie retries doet en wie eigenaar is van leases/timeouts. Maak de keuze expliciet: + +- Optie A: `bootstrap-service` claimt alleen `BOOTSTRAP_REPO`, heeft `DATABASE_URL` + `BOOTSTRAP_ENCRYPTION_KEY`, decrypt zelf de PAT per run, en gebruikt dezelfde status-sync. +- Optie B: bestaande docker-runner claimt ook deterministic jobs, maar dan moet de secret-boundary worden aangepast en gedocumenteerd. +- Optie C: Next.js app voert inline uit, maar dan geen queue/claim-semantiek en geen 60 minuten timeout claimen. + +Voor Scrum4Me past Optie A het best: klein apart Node-proces, geen Claude quota, wel durable retries. + +### P1 - Fire-and-forget in de app is niet betrouwbaar genoeg + +Het plan kiest `runBootstrapInBackground(runId, pat)` na de server-action response. Vercel documenteert dat niet-geawait async werk in Functions kan blijven hangen in een bevroren execution context; helpers als `waitUntil()` zijn bovendien nog steeds gebonden aan de maximale function timeout. Vercel Functions hebben harde duration-limieten; het plan noemt zelf een 60-minuten watchdog, wat niet past bij normale serverless limits. + +Fix: vervang `fire-and-forget` door een echte worker: + +- `startBootstrapAction` maakt alleen `BootstrapRun` + `ClaudeJob`. +- `bootstrap-service` claimt atomair `BOOTSTRAP_REPO` runs. +- Service decrypt de PAT op basis van `run.user_id`, voert de recipe uit, en sync't terminal status. +- UI blijft exact hetzelfde via SSE. + +### P1 - PAT doorgeven aan een background promise is de verkeerde secret-shape + +Regel 197 zegt dat `startBootstrapAction` decrypt voor enqueue, en regel 298 geeft `pat` door aan de background runner. Als het proces wegvalt, is de job niet hervatbaar zonder opnieuw vanuit user context te starten. Als logs of closures uitlekken, zit de PAT in app-memory buiten een duidelijk lifecycle-contract. + +Fix: geef alleen `runId` door. De executor haalt `User.github_pat_encrypted` zelf op, decrypt binnen de execution boundary, zeroized daarna best-effort, en logt nooit token-materiaal. Voeg `github_pat_verified_at`, `github_pat_scopes` en `github_pat_expires_at` toe of overweeg later GitHub App/OAuth. + +### P1 - Gebruik geen lange-running local git push in een serverless function + +De v2-flow gebruikt `mkdtemp`, template clone, lokale git commit, repo create en push. Dat is prima voor een worker/service, maar kwetsbaar in serverless: tijdslimieten, file descriptor limieten, cleanup bij timeout, en onduidelijke rollback wanneer push half lukt. + +Fix: zet dit in `bootstrap-service` of Vercel Sandbox/Workflow. Als je toch app-side wilt blijven, maak de eerste versie veel kleiner: GitHub template endpoint aanroepen, geen lokale mutaties, geen push, geen `RUN_BASH_TEMPLATE`. + +### P2 - Voeg een dry-run/preview toe voor de wizard en admin-catalog + +Backstage Scaffolder heeft dry-run support en een Template Editor waarmee templates in een echte omgeving getest kunnen worden zonder externe mutaties. Scrum4Me mist dit nog. + +Aanbevolen toevoeging: + +- `previewBootstrapAction(productId, selections)` bouwt `recipe_snapshot`, valideert acties, draait alle non-mutating file handlers in tmpdir, en retourneert file tree + action log + warnings. +- UI toont "Review" voor "Create repo". +- Admin-UI mag een recipe pas activeren nadat dry-run groen is. +- Tests draaien per action ook in dry-run mode. + +Dit verlaagt het risico van DB-gedreven recipes sterk. + +### P2 - Maak repository owner/slug een echte picker, geen impliciete username + +Backstage gebruikt een repository picker met allowed hosts, owners en repos. Het plan heeft `repo_slug`, maar owner blijft impliciet `user.github_username` en staat zelfs nog als open punt. + +Fix voor MVP: + +- `Product.repo_owner` of `BootstrapRun.repo_owner_snapshot`. +- `repo_slug` uniqueness op `(repo_owner, repo_slug)`, niet op `(user_id, repo_slug)`. +- `saveGitHubPatAction` haalt beschikbare orgs op en bewaart geen owner zonder permissiecheck. +- Wizard laat owner + slug zien en doet preflight `GET /repos/{owner}/{repo}` of equivalente Octokit call. + +### P2 - Gebruik GitHub template API bewust, of leg uit waarom niet + +GitHub heeft een officieel endpoint om een repository uit een template te maken. Dat is eenvoudiger en veiliger dan zelf init/remote/push doen, maar het endpoint werkt met de template repo en repo-name/owner, niet met een willekeurige tag/ref zoals `template_version`. + +Aanbevolen beslissing: + +- Als `template_version` hard nodig is: blijf bij "download/clone tagged template, mutate, push", maar documenteer dat GitHub's template endpoint bewust niet gebruikt wordt. +- Als default-branch voldoende is: gebruik GitHub's template endpoint voor MVP en beperk v1 tot variabelen die later via follow-up commits kunnen. + +Voor dit plan zou ik tag-pinning behouden, maar de trade-off expliciet maken. + +### P2 - Voeg action-permissions toe, niet alleen admin CRUD + +Backstage kan parameters, steps en actions autoriseren. Scrum4Me v2 heeft alleen "admin-UI fase 2" en path-safety. Dat beschermt niet tegen een legitieme recipe die te veel doet. + +Voeg toe aan `BootstrapAction` of `BootstrapOption`: + +- `risk_level: LOW | MEDIUM | HIGH` +- `requires_role: ADMIN | PRODUCT_OWNER` +- `enabled: boolean` +- `supports_dry_run: boolean` +- `side_effects: FILESYSTEM | GITHUB_REPO | GITHUB_SETTINGS | NETWORK` + +`RUN_BASH_TEMPLATE` en GitHub-mutaties mogen standaard alleen admin-authored en dry-run getest zijn. + +### P2 - Vervang `condition: String?` door een getypte mini-DSL of haal hem uit MVP + +Een vrije condition string in DB is op termijn een tweede interpreter. Gebruik liever: + +```ts +condition: { + allOf?: Array<{ category: string; option: string }> + anyOf?: Array<{ category: string; option: string }> + not?: Array<{ category: string; option: string }> +} +``` + +Valideer met Zod en snapshot de resolved action list. Voor MVP: geen conditions, alleen expliciete selected options. + +### P2 - Maak template/catalog versioning scherper + +Het plan heeft `template_version` en `recipe_snapshot`, maar mist nog: + +- `template_source_sha` of release asset checksum. +- `catalog_version` of `recipe_hash`. +- `action_schema_version`. +- `generated_from` metadata in de nieuwe repo, bijvoorbeeld `.scrum4me/bootstrap.json`. + +Dat maakt update-detection en latere "rerun/update repo" veel simpeler. + +## Webresearch: vergelijkbare ideeen + +### GitHub template repositories + +GitHub ondersteunt "create repository using a template" via REST. Belangrijk: token scopes verschillen voor public/private repos; het endpoint accepteert `owner`, `name`, `include_all_branches` en `private`. Dit bevestigt dat owner/slug en token-scope preflight first-class moeten zijn. + +Bron: + +### Backstage Software Templates / Scaffolder + +Backstage is het dichtstbijzijnde patroon: skeleton code laden, variabelen templaten, en publishen naar GitHub/GitLab. Het heeft ook built-in actions voor fetch/publish, een template editor, dry-run, secrets, repository picker en permission controls. + +Relevante lessen: + +- Scrum4Me's `BootstrapActionKind` lijkt sterk op Backstage scaffolder actions. +- Dry-run en template editor horen vroeg in het plan, niet pas na MVP. +- Secrets moeten apart van gewone parameters blijven. +- Repository owner/host/repo hoort een picker met policy te zijn. +- Action-level permissions zijn belangrijk als recipes in DB/admin UI leven. + +Bronnen: + +- +- +- +- +- + +### Cookiecutter, Plop, Hygen + +Cookiecutter bevestigt het template-repo model met prompts/context/replay. Plop en Hygen bevestigen het action/generator model, maar zijn vooral lokaal/dev-tooling, niet server-side repo provisioning. + +Lessen voor Scrum4Me: + +- Houd de action-set klein en composable. +- Zorg voor replay: bewaar parameters, template versie en recipe hash. +- Maak custom actions code-owned, niet vrij definieerbaar vanuit DB. + +Bronnen: + +- +- +- + +### Vercel Functions + +Omdat het plan de app als executor overweegt, zijn Vercel limits relevant. Vercel Functions hebben maximale duur en background helpers zijn nog steeds aan die max duration gebonden. Dat maakt app-side fire-and-forget ongeschikt als robuuste bootstrap-queue. + +Bronnen: + +- +- + +## Aangepaste aanbeveling voor het plan + +Vervang de executor-sectie door deze keuze: + +1. `BOOTSTRAP_REPO` blijft een `ClaudeJobKind` alleen voor uniforme UI/SSE/status. +2. `scrum4me-docker` claimt `BOOTSTRAP_REPO` niet. +3. Nieuwe `bootstrap-service` claimt alleen `BOOTSTRAP_REPO` of `BootstrapRun(PENDING)`. +4. Service heeft `DATABASE_URL`, `DIRECT_URL`, `BOOTSTRAP_ENCRYPTION_KEY`, geen Anthropic key nodig. +5. Service decrypt PAT per run, voert recipe uit, en gebruikt dezelfde transactionele status-sync. +6. Voeg `previewBootstrapAction` dry-run toe voor wizard en admin. +7. Voeg owner picker, action permissions, catalog versioning en `.scrum4me/bootstrap.json` toe. + +Met die aanpassing wordt het plan duidelijker, veiliger en veel dichter bij bewezen scaffolder-patronen. diff --git a/docs/recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md b/docs/recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md new file mode 100644 index 0000000..f64c87b --- /dev/null +++ b/docs/recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md @@ -0,0 +1,109 @@ +--- +title: "Review - Bootstrap-wizard plan v3.2" +status: draft +date: 2026-05-14 +source_plan: "/Users/janpetervisser/.claude/plans/als-ik-een-nieuwe-virtual-turtle.md" +--- + +# Review - Bootstrap-wizard plan v3.2 + +## Conclusie + +V3.2 is een stevige verbetering. De grote architectuurfout uit v2 is opgelost: er is nu één executor-model met een aparte `bootstrap-service`, geen app-side fire-and-forget. Ook snake_case tables, het bestaande SSE payload-contract, `lease_until`, owner/slug en tag-pinning zijn goed verwerkt. + +Nog niet direct implementeren zonder de punten hieronder te verwerken. De belangrijkste resterende blokkades zitten in claim-identiteit, deploybaarheid van het gedeelde package, en recovery wanneer GitHub-repo-aanmaak/push half slaagt. + +## Bevindingen + +### P1 - Claim-query gebruikt een niet-bestaand `claimed_by` veld + +Het claim-protocol zet `claimed_by = ${WORKER_ID}` op `claude_jobs`. Het huidige `ClaudeJob`-model heeft `claimed_by_token_id`, `claimed_at` en `lease_until`, maar geen `claimed_by`. Dit faalt in SQL/migratie tenzij je een nieuw veld toevoegt. + +Fix: kies expliciet: + +- Re-use `claimed_by_token_id` met een dedicated service `ApiToken`, of +- voeg `claimed_by_worker_id String?` / `claimed_by_service String?` toe, of +- laat claim-identiteit weg en vertrouw op `lease_until`. + +Mijn voorkeur: voeg `claimed_by_worker_id String?` toe voor `bootstrap-service`, zodat je logs en recovery kunt correleren zonder `ApiToken`-semantiek te misbruiken. + +### P1 - `file:../bootstrap-service/...` dependency maakt de app niet deploybaar + +V3.2 kiest voor een shared package onder `~/Development/bootstrap-service/packages/bootstrap-actions/` en een lokale `file:` link vanuit de Scrum4Me-app. Dat werkt lokaal, maar niet in een normale Vercel/GitHub build van de Scrum4Me repo: de sibling-directory zit niet in de repository checkout. + +Fix voor MVP: + +- Zet `packages/bootstrap-actions/` in de Scrum4Me repo, want dit package bevat geen secrets. +- Laat `bootstrap-service` dit package consumeren via git/package release, of tijdelijk via copied source met een sync-script. +- Of publiceer meteen naar GitHub Packages en pin een versie. + +Niet doen: de app afhankelijk maken van een sibling path buiten de repo. + +### P1 - Crash-recovery na externe GitHub-mutaties is nog onvoldoende + +De happy path en catch-path verwijderen een aangemaakte repo bij errors, maar er is geen duurzaam checkpoint als de service crasht nadat de repo is aangemaakt en voordat `SUCCEEDED` is opgeslagen. Stale recovery markeert dan alleen DB-statussen `FAILED`; de GitHub repo kan blijven bestaan als orphan. + +Fix: voeg expliciete externe side-effect checkpoints toe op `BootstrapRun`: + +- `github_repo_created_at` +- `github_repo_id` +- `github_repo_full_name` +- `push_completed_at` + +Stale recovery kan dan beslissen: compensating delete proberen, of `FAILED_NEEDS_CLEANUP`/manual intervention markeren. Zonder dit is rollback niet betrouwbaar. + +### P1 - Stale recovery moet strikt op `BOOTSTRAP_REPO` filteren + +De stale-recovery beschrijving update `claude_jobs` waar status `CLAIMED/RUNNING` en `lease_until < NOW`. Dat mag niet generiek op alle job kinds draaien, want de bestaande Claude/sprint runner gebruikt dezelfde tabel. + +Fix: filter altijd `kind = 'BOOTSTRAP_REPO'`, en update alleen de bijbehorende `bootstrap_runs`. Laat bestaande cleanup voor andere job kinds ongemoeid. + +### P1 - Transaction-array kan geen generated `jobId` doorgeven aan `BootstrapRun` + +De atomische enqueue pseudo-code gebruikt `prisma.$transaction([claudeJob.create(...), bootstrapRun.create({ claude_job_id }))])`. Als `jobId` door Prisma wordt gegenereerd, is die waarde in array-form niet beschikbaar voor de tweede create. + +Fix: gebruik een transaction callback en pregenereer IDs, of maak eerst de job in de transaction en gebruik de returned ID voor de run. Bijvoorbeeld `const jobId = createId()` vooraf en beide records met expliciete IDs schrijven. + +### P2 - Cancel kan alsnog door succes worden overschreven + +`cancelBootstrapAction` zet `ClaudeJob.status='CANCELLED'`; de service "detecteert per-action". Dat is goed, maar `syncSuccess` moet ook conditioneel zijn. Anders kan een cancel tussen de laatste checkpoint en success-sync alsnog eindigen als `DONE/SUCCEEDED`. + +Fix: voor terminal transitions eerst current job/run status lezen of conditional `updateMany` gebruiken. Als `CANCELLED`, geen success meer schrijven. + +### P2 - `last_bootstrap_run_id` mist relationele details + +Het plan noemt `Product.last_bootstrap_run_id String?`, maar niet de Prisma relation naar `BootstrapRun` met `onDelete: SetNull`. Voeg die expliciet toe, inclusief relation name om ambiguiteit met `Product.bootstrap_runs` te voorkomen. + +### P2 - Action permissions staan op option-niveau, maar risico kan action-niveau zijn + +`risk_level` en `requires_role` staan nu op `BootstrapOption`, terwijl `RUN_BASH_TEMPLATE` een action-kind is. Als een optie meerdere acties bevat, moet de optie-risk altijd afgeleid worden uit de zwaarste action, of je hebt action-level permissions nodig. + +Fix: ofwel permissions verplaatsen naar `BootstrapAction`, of `BootstrapOption.risk_level`/`requires_role` server-side afleiden en niet handmatig laten driften. + +### P2 - Houd ID-strategie consistent met de codebase + +Nieuwe modellen gebruiken `@default(uuid())`, terwijl bestaande Scrum4Me-tabellen vrijwel overal `@default(cuid())` gebruiken. Technisch kan UUID, maar het wijkt af zonder duidelijke reden. + +Fix: gebruik `cuid()` tenzij er een externe reden is voor UUID. + +### P2 - Fine-grained GitHub PATs passen niet netjes in alleen `repo` scope + +De verificatie verwacht `repo` in `x-oauth-scopes`. Dat is prima voor classic PATs, maar fine-grained PATs werken met repository permissions en tonen niet altijd hetzelfde scope-model. + +Fix: maak MVP expliciet "classic PAT met `repo` scope" of ondersteun fine-grained tokens met aparte permission checks. Zet dit ook in de settings UI-copy. + +### P2 - `.env.example` en deployment docs ontbreken in de filelijst + +`BOOTSTRAP_ENCRYPTION_KEY` wordt verplicht in de app en service. Voeg `.env.example`, deployment runbook en bootstrap-service README setup toe aan de scope, anders breken lokale onboarding en CI/deploy snel. + +## Aanbevolen aanpassing + +Verwerk vóór implementatie minimaal: + +1. Vervang `claimed_by` door een bestaand of nieuw veld. +2. Verplaats het shared package naar de Scrum4Me repo of publiceer het. +3. Voeg GitHub side-effect checkpoints toe. +4. Filter stale recovery hard op `kind='BOOTSTRAP_REPO'`. +5. Maak enqueue transaction-ID handling concreet. + +Daarna is het plan implementatieklaar genoeg om naar `docs/plans/M8-bootstrap-wizard.md` te verplaatsen. diff --git a/docs/recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md b/docs/recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md new file mode 100644 index 0000000..2c784fe --- /dev/null +++ b/docs/recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md @@ -0,0 +1,73 @@ +--- +title: "Review - Bootstrap-wizard plan v3.3" +status: draft +date: 2026-05-14 +source_plan: "/Users/janpetervisser/.claude/plans/als-ik-een-nieuwe-virtual-turtle.md" +--- + +# Review - Bootstrap-wizard plan v3.3 + +## Conclusie + +V3.3 verwerkt de v3.2-review goed. De claim-identiteit, shared package locatie, GitHub side-effect checkpoints, stale-recovery filter, action-level permissions, classic PAT-keuze en env/docs zijn nu expliciet. Dit plan is dicht bij implementatieklaar. + +Nog verwerken vóór uitvoering: de status-sync voorbeeldcode is nog niet echt transactioneel, stale-recovery zet runs te breed op `FAILED_NEEDS_CLEANUP`, en er staat nog een niet-bestaande ID-generator in het enqueue-voorbeeld. + +## Bevindingen + +### P1 - Status-sync is nog niet transactioneel genoeg + +De sectie heet "transactional + post-commit NOTIFY", maar `syncSuccess` doet eerst `bootstrapRun.updateMany(...)` buiten een transaction en daarna pas een transaction met `claudeJob.updateMany(...)` en `product.update(...)`. Als de tweede transaction faalt, staat de run al op `SUCCEEDED`. Als de job-update `count=0` oplevert, wordt het product alsnog bijgewerkt en wordt alsnog `DONE` genotify'd. + +Fix: doe run-update, job-update en product-update in één `prisma.$transaction(async tx => ...)`, check beide `updateMany.count` waarden, en notify pas na een volledig geslaagde commit. Zet ook `lease_until` en `claimed_by_worker_id` terminal op `null`. + +### P1 - Stale recovery zet alle verlopen runs op `FAILED_NEEDS_CLEANUP` + +De SQL zet alle bijbehorende `bootstrap_runs` op `FAILED_NEEDS_CLEANUP`, terwijl de tekst zegt dat dit alleen moet wanneer `github_repo_full_name IS NOT NULL`. Voor runs zonder externe side effects hoort status `FAILED` te zijn. + +Fix: split recovery in twee updates: + +- `FAILED_NEEDS_CLEANUP` alleen waar `github_repo_full_name IS NOT NULL` of `github_repo_created_at IS NOT NULL`. +- `FAILED` waar beide leeg zijn. + +Hou de `kind='BOOTSTRAP_REPO'` filter; die is goed. + +### P1 - Enqueue gebruikt `@paralleldrive/cuid2`, maar die dependency bestaat niet + +Het plan importeert `createId` uit `@paralleldrive/cuid2`, maar deze repo heeft die dependency niet. De bestaande schema's gebruiken Prisma `cuid()` defaults; applicatiecode genereert die IDs nu niet zelf. + +Fix: gebruik de transaction callback-vorm en laat Prisma de IDs genereren, of voeg expliciet een dependency toe en leg vast dat alle nieuwe ID-validatie `z.string().cuid()` blijft accepteren. Mijn voorkeur: transaction callback, geen nieuwe ID-library. + +### P2 - Nieuwe non-null arrayvelden op `User` hebben defaults nodig + +`github_pat_scopes String[]` is niet nullable en heeft geen default. Op een bestaande database met users maakt dat de migration lastig of onmogelijk zonder backfill. + +Fix: maak dit `github_pat_scopes String[] @default([])` of gebruik `Json?` als je fine-grained tokenmetadata later flexibeler wilt opslaan. + +### P2 - NOTIFY-status casing moet expliciet API-lowercase zijn + +De voorbeelden sturen `status: 'DONE'` en `status: 'QUEUED'`. Bestaande helpers mappen jobstatussen naar lowercase API-strings (`done`, `queued`, etc.). Sommige bestaande paden sturen al lowercase via `jobStatusToApi`. + +Fix: spreek af dat NOTIFY payloads API-lowercase gebruiken, en DB-writes UPPER_SNAKE houden. Dus `status: 'done'` in payload, `status: 'DONE'` in DB. + +### P2 - Stale recovery hoort niet pas fase 2 te zijn + +De service gebruikt leases in MVP, maar de verificatie noemt stale recovery "in fase-2". Zonder recovery kan een crash een job langdurig in `CLAIMED`/`RUNNING` laten hangen. + +Fix: neem minimale stale recovery op in Sprint 1d: markeer verlopen `BOOTSTRAP_REPO` jobs en runs correct als `FAILED` of `FAILED_NEEDS_CLEANUP`. + +### P2 - Org-owner preflight moet endpoint-gedreven zijn + +Voor classic PAT MVP is `repo` scope helder, maar repo creation in een org hangt ook af van de daadwerkelijke org-permissions. Scope-check alleen is niet genoeg. + +Fix: laat `RepoOwnerPicker` alleen owners tonen waarvoor de concrete Octokit preflight slaagt, en behandel de response als authority. Documenteer dat org-eigenaarschap/permissies via GitHub worden gevalideerd, niet afgeleid uit alleen scopes. + +## Aanbevolen minimale patch op het plan + +1. Herschrijf `syncSuccess/syncFailed/syncRunning` als één transaction callback met count-checks. +2. Split stale recovery in `FAILED` vs `FAILED_NEEDS_CLEANUP`. +3. Vervang pre-generated `createId()` door een transaction callback of voeg de dependency expliciet toe. +4. Voeg `@default([])` toe aan `github_pat_scopes`. +5. Maak NOTIFY statuswaarden lowercase. + +Daarna is v3.3 goed genoeg om naar `docs/plans/M8-bootstrap-wizard.md` te promoveren. diff --git a/docs/recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md b/docs/recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md new file mode 100644 index 0000000..2467b5f --- /dev/null +++ b/docs/recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md @@ -0,0 +1,121 @@ +# Review — M8 bootstrap-wizard plan v3.4 + +Datum: 2026-05-14 +Bronplan: `docs/plans/M8-bootstrap-wizard.md` +Scope: plan-review, geen implementatie uitgevoerd. Ik heb ook kort vergeleken met bestaande repo-contracten zoals `prisma/schema.prisma`, `lib/job-status.ts`, `tsconfig.json` en `package.json`. + +## Conclusie + +De aanbevelingen uit de vorige review zijn grotendeels goed verwerkt. Ik zie geen P1-blocker meer in de laatste versie. De belangrijkste restpunten zitten in GitHub owner-permissies, catalog-hash determinisme en acceptatie-tests. + +## Findings + +### [P2] Org-owner preflight belooft meer zekerheid dan de beschreven checks kunnen leveren + +Referentie: `docs/plans/M8-bootstrap-wizard.md:50`, `docs/plans/M8-bootstrap-wizard.md:540-567` + +Het plan zegt dat `RepoOwnerPicker` alleen owners toont waarvoor een concrete repo-create-preflight slaagt. De uitgewerkte check doet echter `GET /orgs/{org}` plus membership-check. Dat bewijst lidmaatschap/zichtbaarheid, niet dat de PAT daadwerkelijk een private repo in die org mag maken. + +GitHub documenteert voor org-repo creation dat de authenticated user org-lid moet zijn en dat classic PATs `repo` nodig hebben voor private repositories. Daarnaast kunnen org-instellingen repo creation beperken; de org API exposeert velden zoals `members_can_create_repositories` en `members_allowed_repository_creation_type`. De huidige plan-check gebruikt die velden niet en kan daardoor false positives of false negatives geven. + +Aanbevolen wijziging: + +- Noem dit expliciet een best-effort owner discovery, niet een harde create-permission proof. +- Valideer collision met `GET /repos/{owner}/{repo}`. +- Laat de echte create-call in de service de finale autorisatie zijn en vertaal `403/422` naar een duidelijke wizard-fout. +- Als je org-policy vooraf wilt meenemen: lees org creation settings waar beschikbaar, maar behandel ontbrekende rechten/SSO/admin-scope als onbekend in plaats van owner automatisch te verbergen. + +Bronnen: GitHub REST docs voor [repositories](https://docs.github.com/en/rest/repos/repos) en [organizations](https://docs.github.com/en/rest/orgs/orgs). + +### [P2] `syncRunning` mist expliciete timestamp-contracten + +Referentie: `docs/plans/M8-bootstrap-wizard.md:230`, `docs/plans/M8-bootstrap-wizard.md:418-420`, `docs/plans/M8-bootstrap-wizard.md:965-968` + +Het plan specificeert voor `syncRunning` alleen de status-overgang `PENDING -> RUNNING` en `CLAIMED -> RUNNING`. De modellen hebben `started_at`, en de verificatie sorteert later op `started_at`. Als `syncRunning` die velden niet atomair vult, worden metrics, UI-sortering en acceptatiequeries onbetrouwbaar. + +Aanbevolen wijziging: + +- Zet in dezelfde transaction `bootstrap_runs.started_at = now` en `claude_jobs.started_at = now`. +- Gebruik dezelfde `now`-waarde voor run en job. +- Voeg een unit/integration-test toe voor `CLAIMED/PENDING -> RUNNING` inclusief `started_at`. + +### [P2] `catalog_version` is nog niet deterministisch genoeg gespecificeerd + +Referentie: `docs/plans/M8-bootstrap-wizard.md:603-634` + +`recipe_hash` is goed uitgewerkt, maar `catalog_version` blijft te vaag: `SELECT md5(string_agg(...)) FROM bootstrap_options ...` is zonder expliciete ordering niet deterministisch en lijkt alleen options te hashen. Catalog changes in categories, actions, params, roles, risk levels, `enabled`, `archived` of `supports_dry_run` kunnen dan gemist worden. + +Aanbevolen wijziging: + +- Gebruik dezelfde canonical JSON-aanpak als `recipe_hash`. +- Hash categories, options en actions samen. +- Sorteer expliciet op category `display_order/slug`, option `display_order/slug`, action `execution_order/id`. +- Include minstens: selection type, required/default flags, enabled/archived, action kind, action params, dry-run support, side effects, risk level en required role. +- Gebruik `sha256`, niet ad-hoc `md5(string_agg(...))`. + +### [P2] De E2E-verificatiequery leest `lease_until` uit de verkeerde tabel + +Referentie: `docs/plans/M8-bootstrap-wizard.md:965-968` + +De query selecteert `lease_until > NOW()` uit `bootstrap_runs`, maar `lease_until` staat op `claude_jobs`. Deze acceptatiestap faalt zodra iemand het letterlijk uitvoert en kan lease-regressies maskeren. + +Aanbevolen wijziging: + +```sql +SELECT br.status, + br.repo_url, + br.recipe_hash, + cj.lease_until > NOW() AS lease_active +FROM bootstrap_runs br +JOIN claude_jobs cj ON cj.id = br.claude_job_id +ORDER BY br.started_at DESC NULLS LAST, br.created_at DESC +LIMIT 1; +``` + +### [P3] Startup stale-recovery uitleg is inconsistent met de worker-id definitie + +Referentie: `docs/plans/M8-bootstrap-wizard.md:93`, `docs/plans/M8-bootstrap-wizard.md:149-151` + +De worker-id bevat hostname, pid en start timestamp. Een herstartende service heeft dus niet dezelfde `claimed_by_worker_id`. De SQL in het plan is gelukkig globaal en kind-gefilterd, maar de uitleg zegt dat dezelfde service-instance zichzelf herkent via de oude hostname. + +Aanbevolen wijziging: + +- Beschrijf startup recovery als globale recovery voor verlopen `BOOTSTRAP_REPO` leases. +- Niet filteren op `claimed_by_worker_id` bij stale recovery. +- Bewaar `claimed_by_worker_id` alleen voor renewal/observability. + +### [P3] Vendor-copy drift-mitigatie staat alleen als risico, niet als concrete sprint-taak + +Referentie: `docs/plans/M8-bootstrap-wizard.md:749-751`, `docs/plans/M8-bootstrap-wizard.md:1023-1028` + +Het plan erkent terecht dat vendor-copy drift tussen Scrum4Me en `bootstrap-service` gevaarlijk is. De mitigatie, een schema-hash CI-check, staat alleen bij accepted risks en niet bij fasering of verificatie. + +Aanbevolen wijziging: + +- Maak de hash-check onderdeel van Sprint 1a of Sprint 1d. +- Laat `bootstrap-service` bij startup loggen welke `ActionSchema` versie/hash geladen is. +- Voeg een verificatiestap toe die faalt als `packages/bootstrap-actions` in de service niet overeenkomt met de Scrum4Me-bron. + +### [P3] `ADD_DEPENDENCY.version` regex is te smal voor normale npm specs + +Referentie: `docs/plans/M8-bootstrap-wizard.md:770-778` + +De regex accepteert alleen cijfers en operators. Geldige npm-versies zoals `latest`, prerelease labels (`^1.2.3-beta.1`), `workspace:*`, `npm:` aliases of git/tarball specs worden afgewezen. Voor MVP kan dit acceptabel zijn als seed-data alleen simpele semver gebruikt, maar het moet expliciet zijn. + +Aanbevolen wijziging: + +- Documenteer MVP als "alleen exact/range semver". +- Of gebruik een echte parser zoals `npm-package-arg`/`semver` en allowlist de toegestane spec-types. + +## Wat goed verwerkt is + +- Transactionele status-sync staat nu in één `prisma.$transaction` met post-commit NOTIFY. +- `FAILED_NEEDS_CLEANUP` wordt alleen gebruikt bij bekende GitHub side-effects. +- `claimed_by_worker_id` is terecht apart gehouden van `claimed_by_token_id`. +- De `@paralleldrive/cuid2` afhankelijkheid is verdwenen; Prisma `cuid()` blijft consistent met het bestaande schema. +- Lowercase SSE-status via `jobStatusToApi` matcht het bestaande contract. +- Stale recovery staat nu in Sprint 1d en is dus onderdeel van MVP. + +## Go/no-go + +Go na verwerking van de P2-punten. De P3-punten kunnen mee in dezelfde planupdate, maar hoeven geen implementatie te blokkeren zolang ze expliciet als MVP-beperking of verificatietaak worden vastgelegd.