diff --git a/.pi/extensions/calvana-shiplog.ts b/.pi/extensions/calvana-shiplog.ts index d5c5fd2..f33cca5 100644 --- a/.pi/extensions/calvana-shiplog.ts +++ b/.pi/extensions/calvana-shiplog.ts @@ -118,10 +118,79 @@ export default function (pi: ExtensionAPI) { lastDeployed: null, }; - // ── State reconstruction from session ── - const reconstructState = (ctx: ExtensionContext) => { + // ── State reconstruction: DB first, session fallback ── + const reconstructFromDb = async () => { + try { + const sshBase = `ssh -o ConnectTimeout=5 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`; + const pgContainer = "$(docker ps --format '{{.Names}}' | grep dokploy-postgres)"; + + const shipsResult = await pi.exec("bash", ["-c", + `${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec ${pgContainer} psql -U dokploy -d calvana -t -A -F \\\"|||\\\" -c \\\"SELECT id, title, status, COALESCE(metric, chr(45)), created_at::text FROM ships ORDER BY id\\\"'" 2>/dev/null` + ], { timeout: 15000 }); + + if (shipsResult.code === 0 && shipsResult.stdout.trim()) { + state.ships = []; + let maxId = 0; + for (const line of shipsResult.stdout.trim().split("\n")) { + if (!line.trim()) continue; + const parts = line.split("|||"); + if (parts.length >= 5) { + const id = parseInt(parts[0]); + if (id > maxId) maxId = id; + state.ships.push({ + id, + title: parts[1], + status: parts[2] as ShipStatus, + timestamp: parts[4], + metric: parts[3], + prLink: "#pr", + deployLink: "#deploy", + loomLink: "#loomclip", + }); + } + } + state.nextShipId = maxId + 1; + } + + const oopsResult = await pi.exec("bash", ["-c", + `${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec ${pgContainer} psql -U dokploy -d calvana -t -A -F \\\"|||\\\" -c \\\"SELECT id, description, COALESCE(fix_time, chr(45)), COALESCE(commit_link, chr(35)), created_at::text FROM oops ORDER BY id\\\"'" 2>/dev/null` + ], { timeout: 15000 }); + + if (oopsResult.code === 0 && oopsResult.stdout.trim()) { + state.oops = []; + let maxOopsId = 0; + for (const line of oopsResult.stdout.trim().split("\n")) { + if (!line.trim()) continue; + const parts = line.split("|||"); + if (parts.length >= 4) { + const id = parseInt(parts[0]); + if (id > maxOopsId) maxOopsId = id; + state.oops.push({ + id, + description: parts[1], + fixTime: parts[2], + commitLink: parts[3], + timestamp: parts[4] || "", + }); + } + } + state.nextOopsId = maxOopsId + 1; + } + } catch { + // DB unavailable — fall through to session reconstruction + } + }; + + const reconstructState = async (ctx: ExtensionContext) => { state = { ships: [], oops: [], nextShipId: 1, nextOopsId: 1, lastDeployed: null }; + // Always try DB first — this is the source of truth + await reconstructFromDb(); + + // If DB returned data, we're done + if (state.ships.length > 0) return; + + // Fallback: reconstruct from session history (when DB is unreachable) for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; @@ -136,34 +205,38 @@ export default function (pi: ExtensionAPI) { }; pi.on("session_start", async (_event, ctx) => { - reconstructState(ctx); + await reconstructState(ctx); if (ctx.hasUI) { const theme = ctx.ui.theme; const shipCount = state.ships.length; const shipped = state.ships.filter(s => s.status === "shipped").length; - ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped`)); + ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped (DB)`)); } }); - pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); - pi.on("session_fork", async (_event, ctx) => reconstructState(ctx)); - pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_switch", async (_event, ctx) => await reconstructState(ctx)); + pi.on("session_fork", async (_event, ctx) => await reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => await reconstructState(ctx)); // ── Inject context so LLM knows about ship tracking ── pi.on("before_agent_start", async (event, _ctx) => { const shipContext = ` -[Calvana Ship Log Extension Active] -You have access to these tools for tracking work: -- calvana_ship: Track shipping progress (add/update/list entries) -- calvana_oops: Log mistakes and fixes -- calvana_deploy: Push updates to the live site at https://${DEPLOY_CONFIG.domain}/live +[Calvana Ship Log Extension Active — DB-backed] +Ship log is persisted in PostgreSQL (calvana DB on dokploy-postgres). +State survives across sessions — the DB is the source of truth, not session history. -When you START working on a task, use calvana_ship to add or update it to "shipping". -When you COMPLETE a task, update it to "shipped" with a metric. -If something BREAKS, log it with calvana_oops. -After significant changes, use calvana_deploy to push updates live. +Tools: +- calvana_ship: Track shipping progress (add/update/list). Writes to DB. +- calvana_oops: Log mistakes and fixes. Writes to DB. +- calvana_deploy: Queries DB for ALL historical entries, generates HTML, deploys to https://${DEPLOY_CONFIG.domain}/live -Current ships: ${state.ships.length} (${state.ships.filter(s => s.status === "shipped").length} shipped) -Current oops: ${state.oops.length} +Rules: +- When you START working on a task, use calvana_ship to add or update it to "shipping". +- When you COMPLETE a task, update it to "shipped" with a metric. +- If something BREAKS, log it with calvana_oops. +- After significant changes, use calvana_deploy to push updates live. +- calvana_deploy reads from the DATABASE — it shows ALL ships ever, not just this session. + +Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.status === "shipped").length} shipped), ${state.oops.length} oops `; return { systemPrompt: event.systemPrompt + shipContext, @@ -358,6 +431,19 @@ Current oops: ${state.oops.length} timestamp: now, }; state.oops.push(entry); + + // Persist to PostgreSQL + const oopsDesc = entry.description.replace(/'/g, "''"); + const oopsTime = (entry.fixTime || "-").replace(/'/g, "''"); + const oopsCommit = (entry.commitLink || "#commit").replace(/'/g, "''"); + try { + const dbResult = await pi.exec("bash", ["-c", + `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec \$(docker ps --format {{.Names}} | grep dokploy-postgres) psql -U dokploy -d calvana -t -c \\\"INSERT INTO oops (description, fix_time, commit_link) VALUES (\\x27${oopsDesc}\\x27, \\x27${oopsTime}\\x27, \\x27${oopsCommit}\\x27) RETURNING id\\\"'"` + ], { timeout: 15000 }); + const dbId = parseInt((dbResult.stdout || "").trim()); + if (dbId > 0) entry.id = dbId; + } catch { /* DB write failed, local state still updated */ } + return { content: [{ type: "text", text: `Oops #${entry.id}: "${entry.description}" (fixed in ${entry.fixTime})` }], details: { state: { ...state, oops: [...state.oops] } }, @@ -840,5 +926,15 @@ function escapeHtml(str: string): string { .replace(//g, ">") .replace(/"/g, """) - .replace(/'/g, "'"); + .replace(/'/g, "'") + .replace(/\u2014/g, "—") // em dash + .replace(/\u2013/g, "–") // en dash + .replace(/\u2019/g, "’") // right single quote + .replace(/\u2018/g, "‘") // left single quote + .replace(/\u201c/g, "“") // left double quote + .replace(/\u201d/g, "”") // right double quote + .replace(/\u2026/g, "…") // ellipsis + .replace(/\u2192/g, "→") // right arrow + .replace(/\u00a3/g, "£") // pound sign + .replace(/\u00d7/g, "×"); // multiplication sign } diff --git a/pledge-now-pay-later/Dockerfile b/pledge-now-pay-later/Dockerfile index 393cc54..4626feb 100644 --- a/pledge-now-pay-later/Dockerfile +++ b/pledge-now-pay-later/Dockerfile @@ -1,21 +1,21 @@ +# syntax=docker/dockerfile:1 FROM node:20-alpine AS base FROM base AS deps WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci +RUN --mount=type=cache,target=/root/.npm npm ci FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate -RUN npm run build +RUN --mount=type=cache,target=/app/.next/cache npm run build FROM base AS runner WORKDIR /app ENV NODE_ENV=production -# sharp needs these native libs for image optimization RUN apk add --no-cache libc6-compat RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs @@ -23,7 +23,6 @@ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma -# sharp is bundled in standalone output when installed ENV NEXT_SHARP_PATH=/app/node_modules/sharp USER nextjs EXPOSE 3000 diff --git a/pledge-now-pay-later/deploy.sh b/pledge-now-pay-later/deploy.sh new file mode 100644 index 0000000..ccfc548 --- /dev/null +++ b/pledge-now-pay-later/deploy.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Fast deploy — BuildKit cache + persistent build dir +# Source-only changes: ~40-60s | With package changes: ~90s +set -e + +SERVER=root@159.195.60.33 +CONTAINER=qc-server-new +START=$(date +%s) + +step() { printf "\n\033[1;34m>>>\033[0m [%s] %s\n" "$1" "$2"; } + +# ── Pack source (exclude heavy/unchanged stuff) ── +step "1/3" "Packing source..." +tar czf /tmp/pnpl.tar.gz \ + --exclude=node_modules \ + --exclude=.next \ + --exclude=.git \ + --exclude=brand/ \ + --exclude=shots/ \ + . +SIZE=$(wc -c < /tmp/pnpl.tar.gz | awk '{printf "%.1fM", $1/1048576}') +echo " ${SIZE} tarball" + +# ── Upload ── +step "2/3" "Uploading..." +scp -o ConnectTimeout=20 -q /tmp/pnpl.tar.gz ${SERVER}:/tmp/pnpl.tar.gz + +# ── Build + deploy with full cache ── +step "3/3" "Building + deploying..." +ssh -o ConnectTimeout=20 ${SERVER} " +incus file push /tmp/pnpl.tar.gz ${CONTAINER}/tmp/pnpl.tar.gz +incus exec ${CONTAINER} -- bash -c ' + cd /opt/pnpl + tar xzf /tmp/pnpl.tar.gz + rm /tmp/pnpl.tar.gz + + # BuildKit build with npm + Next.js cache mounts + docker buildx build -t pnpl-app:latest --load . 2>&1 | tail -8 + + # Rolling restart + docker service update --force --image pnpl-app:latest pnpl_app 2>&1 | tail -2 + + # Clean dangling only (preserve build cache!) + docker image prune -f 2>&1 | tail -1 +' +rm -f /tmp/pnpl.tar.gz +" + +ELAPSED=$(( $(date +%s) - START )) +echo "" +echo "=== Deployed in ${ELAPSED}s ===" diff --git a/pledge-now-pay-later/src/app/page.tsx b/pledge-now-pay-later/src/app/page.tsx index ee29df6..24c875f 100644 --- a/pledge-now-pay-later/src/app/page.tsx +++ b/pledge-now-pay-later/src/app/page.tsx @@ -49,8 +49,8 @@ export default function HomePage() { {/* ━━ HERO (dark) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
-
-
+
+
{/* ── Text column ── */}
@@ -63,7 +63,7 @@ export default function HomePage() { {/* Headline — Display scale */}

- Turn “I'll donate” into money in the bank. + Turn “I'll donate”
into money
in the bank.

{/* Sub */} @@ -117,7 +117,7 @@ export default function HomePage() { {/* Stat strip — gap-px pattern (signature pattern 2) */}
-
+
{HERO_STATS.map((s) => (
diff --git a/screenshots/cr-cloudflare.png b/screenshots/cr-cloudflare.png new file mode 100644 index 0000000..3a988ef Binary files /dev/null and b/screenshots/cr-cloudflare.png differ