ADR-0018: Workflow, migration & reporting¶
Status: Proposed Date: 2026-05-31 Deciders: Ramón Kamibayashi + Claude Opus 4.7 Related: ADR-0010, ADR-0013, ADR-0014, ADR-0015, ADR-0016, ADR-0017
Story: Three small clusters that round out the operator experience: - Workflow (J) — staged changes, scheduled apply, approvals, git-style branches. - Migration (L) — bring sites IN (from ZIP / fresh install / DNS auto-setup). - Reporting (M) — generate the customer-facing artifacts (PDF report, public status page, feedback inbox).
J — Workflow¶
| # | Feature | UX |
|---|---|---|
| J1 | Staged changes viewer | The pending-changes modal (from the unified flow) already lists changes per category. Promote it to a standalone page /sites/[slug]/staging that's the operator's "review before apply" surface. Show diffs for files (unified diff per file), DB before/after for option/post changes, plugin/theme deltas. Same content the modal shows, but full-screen with searchable file list and key bindings. |
| J2 | Scheduled apply | Operator picks a time ("apply pending at 02:00 tonight" or "every Monday 03:00"). Cron-fired job runs the apply at that time. Useful for maintenance windows. |
| J3 | Approval workflow | Optional toggle per site: "Require approval before apply to prod". When ON, the apply request creates an approvals row → operator (or client) must approve from a magic-link email/Telegram → only then runs. Slack/Telegram integration reuses ADR-0010 alert wiring. |
| J4 | Git-style branches | The worktree is already a git repo. Expose multiple "branches" — operator works on an experimental branch without touching main. On confidence, cherry-pick or merge to main then apply. Requires lightweight branch UI + branch-switching that preserves the docker stack mount. |
Data model (alembic 025)¶
CREATE TABLE scheduled_applies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
scheduled_for timestamptz NOT NULL,
cron_expr text, -- NULL = one-shot
categories jsonb DEFAULT '["files","quarantine","db"]'::jsonb,
created_by text,
status text NOT NULL, -- "pending|fired|cancelled"
created_at timestamptz DEFAULT now(),
fired_at timestamptz,
result jsonb -- apply result for telemetry
);
CREATE TABLE approvals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
apply_request jsonb NOT NULL, -- pending categories + summary
approver text, -- email / telegram username
token text UNIQUE NOT NULL, -- magic-link
status text NOT NULL, -- "pending|approved|rejected|expired"
expires_at timestamptz NOT NULL,
decided_at timestamptz,
apply_push_ids jsonb -- populated on approval
);
CREATE TABLE site_branches (
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
name text NOT NULL, -- "main", "exp-update-yoast", ...
base_commit text, -- git rev
created_at timestamptz DEFAULT now(),
PRIMARY KEY (site_id, name)
);
Also sites.require_approval boolean default false, sites.current_branch text default 'main'.
API¶
Scheduled¶
POST /api/v1/sites/{slug}/scheduled-appliesbody{scheduled_for, cron_expr?, categories?}→ 201 with id.GET /api/v1/sites/{slug}/scheduled-applies→ list pending + fired.DELETE /api/v1/sites/{slug}/scheduled-applies/{id}→ cancel.- Cron
scheduled_applies_tick(extendhealth_cron_tick) fires due rows.
Approval¶
PATCH /api/v1/sites/{slug}body{require_approval: bool}.- When
apply-all-to-prodhits a site with require_approval=true: creates approval row, sends magic-link via Telegram/email, returns 202 with approval_id. - Public
GET /api/v1/approvals/{token}(no auth) → renders a small page with summary + Approve/Reject buttons. POST /api/v1/approvals/{token}/decidebody{decision}→ executes the apply on approval.
Branches¶
GET /api/v1/sites/{slug}/branches→ list.POST /api/v1/sites/{slug}/branchesbody{name, from_branch?}→ creates branch.POST /api/v1/sites/{slug}/branches/{name}/switch→ checks out, updatessites.current_branch, restarts docker stack against the worktree at the new branch.POST /api/v1/sites/{slug}/branches/{name}/merge-to-main→ fast-forward (or 3-way) merge.
UI¶
- New page
/sites/[slug]/staging(J1). - Section in SiteHero showing "Scheduled apply: tonight 02:00 [Cancel]" (J2).
- Settings toggle "Require approval before apply" + history of approvals (J3).
- Branch switcher in TopBar (when on a site page) showing current branch + dropdown (J4).
- Components:
StagingPage.tsx,ScheduledApplyCard.tsx,ApprovalsPage.tsx,ApprovalDecidePage.tsx(public, no chrome),BranchSwitcher.tsx.
Edge cases¶
- Scheduled apply fires but worktree has been mutated since scheduling → ABORT, log to result, alert operator. Don't apply blindly.
- Approval expires before decide → auto-reject.
- Branch switch with dirty worktree → refuse OR stash. v1: refuse with clear error.
L — Migration¶
| # | Feature | UX |
|---|---|---|
| L1 | Migration import from ZIP | Operator uploads a Duplicator / All-in-One Migration / UpdraftPlus ZIP → WP-Pulse extracts → creates a new site + worktree pre-populated. Final step: operator points the prod hostname at WP-Pulse-managed hosting and pushes. |
| L2 | Empty WP install wizard | Provision a fresh WP install in a new local stack (without pulling from anywhere). Operator picks WP version, theme, starter plugins. Use case: developer starting a new project locally before pushing to a chosen host. |
| L3 | DNS auto-setup | Operator gives a domain → WP-Pulse hits Cloudflare API → creates A record + tunnel + Caddy snippet automatically. Saves the 4 manual steps it takes today. Same flow during L1 import + L2 fresh install. |
Data model¶
Reuse existing sites table. New columns:
- sites.import_status text — "imported|fresh|enrolled" (legacy default "enrolled")
- sites.import_metadata jsonb — source ZIP filename, size, plugin counts, etc.
(alembic 026)
API¶
POST /api/v1/sites/import-zipmultipart{file}→ 202 + job_id. Background job extracts, validates (must contain a SQL dump + wp-content), creates the site row + worktree.GET /api/v1/sites/imports/{job_id}→ status.POST /api/v1/sites/create-freshbody{slug, wp_version, theme, plugins: [], domain?}→ provisions.POST /api/v1/dns/setupbody{domain, target_ip?}→ CF API call.
UI¶
- New page
/onboard(extend existing) with three tabs: - "Enroll existing" (today's flow)
- "Import ZIP" (L1)
- "Fresh install" (L2)
- DNS step in each that calls L3.
- Components:
ImportZipDropzone.tsx,ImportProgressPanel.tsx,FreshInstallWizard.tsx,DnsSetupStep.tsx.
Edge cases¶
- Importer must validate plugin list against the agent compatibility (some legacy plugins won't run on PHP 8.x — flag).
- Duplicator ZIP format vs AIO Migration format — different layouts. Detect by file presence.
- DNS setup: refuse if domain already exists in CF; show conflict.
M — Reporting¶
| # | Feature | UX |
|---|---|---|
| M1 | Monthly client report PDF | First of each month, cron generates one PDF per site covering: uptime %, push count, plugin updates applied, security patches, performance trend (Lighthouse), backup count. Auto-emails to the client contact OR offers download. |
| M2 | Public status page per site | status.client-domain.com rendered by WP-Pulse with: uptime stat, last maintenance, next scheduled apply. Read-only, no auth. Branded with client logo. |
| M3 | Customer feedback inbox | Optional JS widget that sites can embed (<script src="https://wp-pulse-app.monxas.casa/widget.js?slug=...">) — shows a small "Report a bug" button. Submissions land in operator's WP-Pulse dashboard, tagged to the site. |
Data model (alembic 027)¶
CREATE TABLE site_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
period_start date NOT NULL,
period_end date NOT NULL,
format text NOT NULL, -- "pdf|html"
artifact_path text NOT NULL, -- on disk
generated_at timestamptz DEFAULT now(),
emailed_to text
);
CREATE TABLE site_feedback (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
submitted_at timestamptz DEFAULT now(),
url text,
user_agent text,
message text NOT NULL,
screenshot_b64 text,
resolved boolean DEFAULT false
);
CREATE TABLE site_status_pages (
site_id uuid PRIMARY KEY REFERENCES sites(id) ON DELETE CASCADE,
enabled boolean DEFAULT false,
hostname text UNIQUE, -- "status.client.com"
brand_logo_url text,
brand_primary_color text
);
API¶
POST /api/v1/sites/{slug}/reports/generate?month=YYYY-MM→ 202 + job.GET /api/v1/sites/{slug}/reports→ list.GET /api/v1/sites/{slug}/reports/{id}/download→ PDF file.POST /api/v1/sites/{slug}/status-pagebody{enabled, hostname, brand_logo_url, brand_primary_color}→ enables.- Public
GET /api/v1/public/status/{hostname}→ status JSON (used by the status page's frontend). POST /api/v1/widget/feedbackbody{slug, url, ua, message, screenshot_b64?}→ CORS-allowed from*.{enrolled hostname}.GET /api/v1/sites/{slug}/feedback→ operator-facing inbox.
UI¶
/sites/[slug]/reports→ list + generate-now button./sites/[slug]/feedback→ inbox./sites/[slug]/status-page→ config + preview.- Public route
/public/status/[hostname]/page.tsx→ renders the status page (different layout, no app chrome). - Components:
ReportsList.tsx,FeedbackInbox.tsx,StatusPageConfig.tsx,PublicStatusPage.tsx.
PDF generation¶
Use weasyprint (Python) or puppeteer for HTML→PDF. Existing infra has Playwright (used by F4 visual diff) — reuse it for HTML→PDF render. Template via Jinja2 in templates/jinja/client_report.html.j2.
Cron¶
reports_monthly_generate — fires 1st of each month 06:00 for every site with reports enabled (sites.report_enabled boolean default false).
Edge cases¶
- Status page hostname needs DNS + CF tunnel route. Lean on ADR-0018 L3 DNS auto-setup if available; otherwise document manual setup.
- Feedback widget CORS: only accept submissions from a hostname enrolled with that slug.
- PDF size: cap at ~5 MB. If report would exceed, paginate or strip charts.
Migration consolidation¶
Alembic 025 (workflow), 026 (migration columns), 027 (reporting). Independent chains; can be applied in any order.
Rollout — 3 sub-agents in parallel¶
- Sub-agent J: workflow features (staging page, scheduled, approvals, branches).
- Sub-agent L: migration (import-ZIP, fresh-install, DNS setup).
- Sub-agent M: reporting (PDF, status page, feedback inbox).
No shared file ownership across the three. Each one owns its own alembic revision + routers + UI pages.
Open questions¶
- Branches vs worktree complexity: J4 is the riskiest because switching branches mid-stack is non-trivial (docker mount + DB state). v1 could limit to file-only branches; DB branches (snapshot + restore) deferred.
- PDF report ML/AI augmentation: e.g. AI summary of the month. Out of scope (ADR-N section deferred).
- Status page CDN caching: serve from CF cache with 1-min TTL.
- Migration ZIP parsing fragility: Duplicator ZIPs vary. v1: support AIO Migration format (most common) + UpdraftPlus. Duplicator deferred.