* feat(ideas): upload-plan knop — short-circuit van Make-Plan AI-flow Voegt een 'Upload plan' knop toe in idea-row-actions (verschijnt in zowel list als idea-detail). Klik → file picker → kies .md → server-side parse + opslaan; idea-status springt naar PLAN_READY. Vandaaruit de bestaande 'Maak PBI' knop voor materialize. Server (uploadPlanMdAction): - Toegestaan vanuit DRAFT, GRILLED, PLAN_FAILED, PLAN_READY - DRAFT → skip-grill: status gaat direct naar PLAN_READY - PLAN_READY overschrijft het bestaande plan (consistent met updatePlanMdAction, geen confirmation) - Geblokkeerd in GRILLING/PLANNING (job loopt), PLANNED (al gematerialiseerd) - Parse-failure → 422 + details (NIET opslaan, zodat een onparseerbaar plan nooit in de DB belandt) - Empty / >100k chars → 422 - Schrijft IdeaLog NOTE met from_status + length - Rate-limit + demo-guard + ownership-check via loadOwnedIdea (zelfde patroon als updatePlanMdAction) UI (idea-row-actions.tsx): - Hidden <input type=file accept=".md,.markdown,text/markdown,text/plain"> - FileReader → text → action - Toast bij success + router.refresh() - Blocked-tooltip in andere statussen Tests: 10 nieuwe in __tests__/actions/ideas-crud.test.ts dekkend voor: happy paths (DRAFT/GRILLED/PLAN_READY-overwrite/PLAN_FAILED), blocks (PLANNED/GRILLING), validation (empty/oversized/parse-fail), 404. Full suite groen: 849/849. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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(plans): M8 bootstrap-wizard upload-variant v1.4 — backtick-paden Upload-variant van het volledige technische plan (docs/plans/M8-bootstrap-wizard.md), bedoeld voor de "Upload plan"-functie. Genereert 1 PBI + 4 Stories + 22 Tasks via materializeIdeaPlanAction. v1.4-aanpassingen tov eerdere generatie-iteratie: - Alle bestandspaden in implementation_plan in backticks (path-extractor matchen) - Expliciete "Bestanden:" blok per task vóór de stappen - Alle tasks op verify_required: ALIGNED_OR_PARTIAL (was deels ALIGNED — te strict voor ADR-stubs en multi-file edits) Fixt forward-only: T-963 cancelled_by_self door DIVERGENT verifier-verdict. Re-upload van dit bestand produceert tasks die door verify_task_against_plan als ALIGNED of PARTIAL geclassificeerd kunnen worden. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PBI-67: Add review-plan support to Idea model and job config - Add plan_review_log and reviewed_at fields to Idea model - Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum - Add IDEA_REVIEW_PLAN to ClaudeJobKind enum - Add IDEA_REVIEW_PLAN config to job-config.ts with model=opus, thinking_budget=6000 - Create migration record for schema changes (applied via db push) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * PBI-67 Phase 2: Add update-idea-plan-reviewed MCP tool - Create src/tools/update-idea-plan-reviewed.ts: saves review-log and transitions idea status to PLAN_REVIEWED - Add PLAN_REVIEW_RESULT to IdeaLogType enum (both repos) - Register tool in src/index.ts - Update Prisma schemas (both repos): add plan_review_log and reviewed_at fields to Idea model - Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum (MCP schema) - Add IDEA_REVIEW_PLAN to ClaudeJobKind enum (MCP schema) - Tool includes transaction safety and convergence metrics logging Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * feat(PBI-67): IDEA_REVIEW_PLAN Phases 3-6 — server actions, UI components, prompt & tests - Phase 3: startReviewPlanJobAction, cancelIdeaJobAction, status transitions (REVIEWING_PLAN / PLAN_REVIEWED / PLAN_REVIEW_FAILED), status colors, job-card/jobs-column filters, idea-list status tabs - Phase 4: review-plan-job.md prompt (multi-model orchestration with codex injection + active plan revision via update_idea_plan_md after each round), runbook, 13 unit tests - Phase 5: ReviewLogViewer component (rounds, convergence, approval, issues), idea-detail integration, proper ReviewLog TypeScript types exported from component - Phase 6.1: wait-for-job discriminator wired (IDEA_REVIEW_PLAN), plan-revision step made mandatory in prompt (was previously optional/missing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1170 lines
55 KiB
Markdown
1170 lines
55 KiB
Markdown
---
|
|
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:<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`)
|
|
```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 `<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: <message>" + 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/<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.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<ClaudeJobKind, …>`)
|
|
- `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/<ts>_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 "✓ <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**:
|
|
```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.
|