- 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.
55 KiB
| status | author | version | created_at | reviewed_by | |||
|---|---|---|---|---|---|---|---|
| reviewed | Claude | 6 | 2026-05-14 |
|
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):
- Snake-case DB-tables via
@@map - NOTIFY payload contract:
type: 'claude_job_status',user_idverplicht lease_untilveld (bestaand) voor lease-renewal- Git push via
isomorphic-git(in-process credentials) bootstrap-serviceals sibling-directory
- Snake-case DB-tables via
- Pre-implementatie clarificaties (v3.2):
- Shared package npm-publicatie: trigger op derde consumer of coordination-pijn
- Recipe-hash determinisme: hash over
recipe_snapshot(nietselected_options), canonicalized - Dry-run file-tree: gefilterd ignore-set + cap 500 entries
- Template cache: geen cache in MVP; fase-2 disk-cache met TTL-sweep
- Deployment target: multi-arch Dockerfile, Mac arm64 als primary
- Plan v3.3 verwerkt review-v3.2 (5 P1 + 6 P2):
- Claim-identiteit:
claimed_by_worker_id String?toegevoegd aan ClaudeJob (niet het bestaandeclaimed_by_token_idmisbruiken) - Shared package in Scrum4Me-repo:
packages/bootstrap-actions/binnen deze repo (geen secrets, deploybaar); bootstrap-service consumeert via release - GitHub-side-effect checkpoints:
github_repo_created_at/github_repo_id/github_repo_full_name/push_completed_at+ statusFAILED_NEEDS_CLEANUP - Stale-recovery kind-filter: SQL altijd
AND kind='BOOTSTRAP_REPO' - Atomic enqueue: pre-generated cuid IDs binnen transaction-array
- Cancel-safe terminal sync: conditional
updateManyvoor success/failure last_bootstrap_run_idmet expliciete Prisma-relation + relation-name (om ambiguïteit metProduct.bootstrap_runste voorkomen) +onDelete: SetNull- Action-permissions naar action-niveau:
risk_level/requires_roleopBootstrapAction; option-level derived van max - ID-strategie: alle nieuwe modellen gebruiken
@default(cuid())(consistent met 22 bestaande modellen) - Classic PAT MVP: scope-detectie via
x-oauth-scopeswerkt alleen voor classic; fine-grained PATs als open punt .env.example+ deployment docs in filelijst
- Claim-identiteit:
- Plan v3.4 verwerkt review-v3.3 (3 P1 + 4 P2):
- 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 - Stale-recovery split:
FAILED_NEEDS_CLEANUPalleen bijgithub_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL; restFAILED - Geen
@paralleldrive/cuid2dep: transaction-callback vorm met door Prisma gegenereerde cuid's User.github_pat_scopeskrijgt@default([])voor migration-safety- NOTIFY-payload
statusis lowercase (jobStatusToApi-output); DB blijft UPPER_SNAKE - Stale-recovery komt naar Sprint 1d (MVP), niet pas fase-2
- Org-owner preflight via Octokit-call:
RepoOwnerPickertoont alleen owners waarvooroctokit.repos.create…-preflight slaagt; scope alleen is niet genoeg
- Status-sync echt transactioneel: één
- Plan v3.5 verwerkt review-v3.4 (4 P2 + 3 P3 — geen P1's; go-signaal na P2-verwerking):
- 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 viamembers_can_create_repositorieswaar beschikbaar; ontbrekende info = "unknown" (niet automatisch verbergen) syncRunningtimestamp-contract: bootstrap_runs.started_at én claude_jobs.started_at in dezelfde transaction met dezelfdenow-waarde; unit-test voor PENDING/CLAIMED → RUNNINGcatalog_versiondeterministisch: canonical JSON over categories+options+actions, gesorteerd op display_order/slug/execution_order, alle relevante velden geïncludeerd, sha256 (niet md5)- E2E verification-query JOIN naar
claude_jobsvoorlease_until - Stale-recovery globaal (geen
claimed_by_worker_id-filter);claimed_by_worker_idalleen voor renewal/observability - Vendor-copy drift CI-check als concrete sprint-taak in Sprint 1a + verificatie-stap; service logt geladen
ActionSchema-hash bij startup ADD_DEPENDENCY.versionregex: MVP expliciet "alleen exact/range semver"; fase-2npm-package-arg-parser voorlatest/prerelease/workspace:*/npm:-aliases
- Org-owner preflight expliciet best-effort: discovery toont owners; collision via
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)
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}).
// 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.
-- 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-servicedraait dezelfde SQL bij startup als globale recovery voor alle verlopenBOOTSTRAP_REPO-leases (van wie dan ook). Niet filteren opclaimed_by_worker_id— een herstartende service heeft een nieuwworker_id(hostname + pid + start-timestamp) en zou zijn eigen oude leases anders niet matchen.claimed_by_worker_idis dus puur voor lease-renewal-only-mine-guard en observability/log-correlatie; stale-recovery iskind- enlease_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)
// 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
wherezorgt dat eenCANCELLED-overgang die tussendoor gebeurde niet door late terminal-write wordt overschreven. - Lease-cleanup terminal:
lease_untilenclaimed_by_worker_idworden explicietnullgezet 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:
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.
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):
// 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:
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 (
onAuthcallback), nooit in URL of shell-argv - Geen credential-helper-state op disk
- Werkt op alle platforms zonder git-binary-dependency
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
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.
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
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.
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):
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)
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)
model User {
// ... bestaande velden ...
github_pat_encrypted String? // prefix 'v1:<base64>'
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)
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(defaultmadhura68/nextjs-baseline)
PAT-secret-boundary
startBootstrapAction decrypt nooit:
- Bewaart geen plaintext PAT
- Geeft alleen
runIdmee aan executie
bootstrap-service decrypt per run binnen execution-scope:
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:
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:
previewBootstrapActiondoet 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):
- Auth + demo-check (403)
- Zod-validate selections + GitHub-name regex
- Resolve recipe + compute
recipe_hash+catalog_version - Spin up tmpdir + clone template (geen cache in MVP — zie Template-cache-sectie)
- Run alle handlers met
supports_dry_run=truetegen tmpdir;RUN_BASH_TEMPLATElogged als "skipped" - Octokit preflight:
octokit.repos.get({ owner, repo })om collision te detecteren - Octokit preflight:
octokit.orgs.list...om owner-rechten te valideren - File-tree filter en cap (zie hieronder)
- Retourneer
DryRunReport:{ 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)
// 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
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 door403 Forbidden→ vertaal naar wizard-fout "Geen rechten om repo te maken in<owner>. 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:
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/<template_repo_slug>/<template_version>/ - 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.jsonmark-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):
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_hashaction_schema_version= hardcoded in shared package; bumped bij schema-breakstemplate_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:
// 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)
{ "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)→ markeertClaudeJob.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:
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}.tsallowed-commands.ts— RUN_BASH_TEMPLATE regex-arrayrecipe-hash.ts— canonicalize + sha256types.ts— gedeelde DryRunReport, ActionContext, etc.package.jsonmet"name": "@scrum4me/bootstrap-actions","version": "0.1.0"
Consumers:
- Scrum4Me-app:
lib/bootstrap/dry-run.tsimporteert@scrum4me/bootstrap-actionsvia 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 rootpackage.json(minimal pnpm/npm workspaces; geen Turborepo nodig) - MVP-pragmatisch: TypeScript path-alias
@scrum4me/bootstrap-actions→./packages/bootstrap-actions/srcintsconfig.json, bundlen via Next.js default. Let op: ookvitest.config.tskrijgtresolve.alias: { '@scrum4me/bootstrap-actions': './packages/bootstrap-actions/src' }anders falen unit-tests op dit package.
Hoe bootstrap-service het consumeert:
- Optie A (MVP):
bootstrap-servicedoet bij iedere release een vendor-copy van het package via een sync-script (scripts/sync-bootstrap-actions.sh— vergelijkbaar metsync-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):
- Derde consumer verschijnt (CI-pipeline, tweede service, externe contributor)
- Coordination-pijn:
ActionSchema-wijziging die app/service apart-deploy verbreekt zonder version-pin - Externe gebruiker vraagt het package buiten lokale dev
Action-schema + path-safety
// 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<ClaudeJobKind, …>)components/jobs/jobs-column.tsxlib/insights/agent-throughput.ts- SSE initial-payload + filter-set
lib/job-config.ts+ spiegel-MCP: discriminated union returnsruntime: 'deterministic'- Tests rond exhaustive switches
Bestanden te wijzigen / aan te maken
Database (Scrum4Me)
prisma/schema.prisma— modellen + enums +@@mapannotaties + Product/User uitbreidingenprisma/migrations/<ts>_bootstrap_wizard/migration.sql— incl. partial unique index (raw SQL)prisma/seed.ts—seedBootstrapCatalog()met 6 core + add-onsscrum4me-mcp/prisma/schema.prisma— gesynced viasync-schema.sh~/Development/bootstrap-service/prisma/schema.prisma— gesynced idem
App-side (Scrum4Me)
lib/env.ts—BOOTSTRAP_ENCRYPTION_KEY,BOOTSTRAP_TEMPLATE_REPOlib/job-config.ts(+ MCP-spiegel) — discriminated union, BOOTSTRAP_REPO → deterministiclib/crypto/pat.ts— encrypt only (decrypt leeft in service)actions/bootstrap.ts— preview/start/cancel/retry/savePatactions/products.ts—repo_owner,repo_slugveldenlib/bootstrap/dry-run.ts— gebruikt shared handlerslib/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-actionsintsconfig.jsonscripts/sync-bootstrap-actions.sh— sync-script (analoog aansync-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.tsxlib/insights/agent-throughput.ts- SSE-routes (
app/api/realtime/jobs/route.tsetc.)
UI (Scrum4Me)
app/(app)/products/[id]/page.tsx+_components/*app/(app)/settings/_components/github-pat-settings.tsxapp/(app)/admin/bootstrap/page.tsx(fase-2)
Worker (scrum4me-docker)
bin/run-one-job.ts— skip-filterAND 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"):
Dockerfilestart metFROM --platform=$BUILDPLATFORM node:24-alpine- Build met
docker buildx build --platform linux/amd64,linux/arm64 --push docker-compose.ymldefault 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/restzijn 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.mddocs/runbooks/bootstrap-wizard.md— operatorpad incl. PAT-setup, troubleshooting, FAILED_NEEDS_CLEANUP handlingdocs/INDEX.mdregenereren
Env + deployment docs
.env.example(in Scrum4Me-repo) — voegBOOTSTRAP_ENCRYPTION_KEYenBOOTSTRAP_TEMPLATE_REPOtoe 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-baselinev1.0.0 — geen wijzigingen in deze PR
Fasering
Sprint 1a — Contracten
- ADR-0009
JobConfigdiscriminated union + testsscrum4me-docker/bin/run-one-job.tsskip-filter + tests- Shared
bootstrap-actionspackage scaffold (schema + handler-interfaces) lib/bootstrap/notify.tspost-commit pg_notify helper + tests- Schema-hash drift-check (vendor-copy mitigatie):
scripts/check-bootstrap-actions-hash.shberekentsha256overpackages/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_TEMPLATEmet allowlistconditionmini-DSLretryBootstrapAction+cancelBootstrapAction- Admin-CRUD UI + catalog-publish met dry-run-gate
- Update-detection
FAILED_NEEDS_CLEANUPadmin-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
# 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 "✓ <username>" + 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:
# 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-baselinerepo must be public en tagv1.0.0aanwezig (anders falen alle clone-ops)BOOTSTRAP_ENCRYPTION_KEYgenereren: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 zonderBuffer-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
ActionSchemain 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-permissionsofGET /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_CLEANUPrecovery flow — admin-UI knop voor handmatigeoctokit.repos.deleteof "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.versionspec-broadening —npm-package-arg-parser ondersteuning voorlatest, 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.