Compare commits

...
Author SHA1 Message Date
robbond 707fe1b3c0 fix(confidence-engine): sanitize start reasoning outage 2026-09-09 18:02:29 +01:00
robbond ed033e71d5 fix(confidence-engine): sanitize focused reasoning outage 2026-09-09 17:34:56 +01:00
robbond 6974b710de fix(confidence-engine): refine landing page layout 2026-09-09 16:33:06 +01:00
robbond b93aad9667 fix(confidence-engine): simplify responsive login layout 2026-09-09 16:15:55 +01:00
robbond 5f423d2f5f fix(login): correct desktop layout — sections 1+3 form left column, sign-in card right at 420px; mobile order: proposition → sign-in → HOW IT WORKS 2026-09-09 15:21:26 +01:00
robbond 4d78c81773 fix(confidence-engine): prioritise mobile sign-in 2026-09-09 14:20:27 +01:00
robbond efdb0a29d1 docs(confidence-engine): record first-time framing increment 2026-09-09 14:04:36 +01:00
robbond 3bfc1c5c4b feat(confidence-engine): add first-time product framing 2026-09-09 14:04:08 +01:00
robbond fdb6c86ee2 docs(confidence-engine): checkpoint deployed product platform 2026-09-09 13:37:03 +01:00
robbond e18de817b4 build(confidence-engine): ignore deployment environment 2026-09-09 13:30:22 +01:00
robbond 607a2ad98b fix(confidence-engine): use deployment host address in quotes 2026-09-09 13:23:22 +01:00
robbond 3e473dcf55 fix(confidence-engine): use deployment host address 2026-09-09 13:21:47 +01:00
robbond dafb9ae9b7 fix(confidence-engine): bind Jenkins deployment SSH context 2026-09-09 13:19:21 +01:00
robbond b964f2f172 fix(confidence-engine): give Jenkins pipeline node context 2026-09-09 13:02:19 +01:00
robbond 2c535080d2 build(confidence-engine): add manual Jenkins deployment 2026-09-09 12:44:55 +01:00
robbond bb62a65174 fix(confidence-engine): preserve external auth callback origin 2026-09-09 11:26:57 +01:00
robbond 0125975ccb fix(confidence-engine): sanitize public health response 2026-09-09 10:33:54 +01:00
robbond f557175bfb fix(confidence-engine): keep health endpoint public 2026-09-09 10:01:10 +01:00
robbond 896851e68d build(confidence-engine) remove copy line from dockerfile 2026-09-09 09:52:55 +01:00
robbond 2079be690f build(confidence-engine) fix dockerfile env var 2026-09-09 09:47:45 +01:00
18 changed files with 815 additions and 66 deletions
+3
View File
@@ -46,3 +46,6 @@ tests-results/
# Evidence/temp directories from live experiments
.evidence-temp/
# Jenkins env
deploy.env
+10 -10
View File
@@ -3,26 +3,27 @@ FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_PUBLIC_SUPABASE_URL="" \
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN corepack enable && \
if [ -f pnpm-lock.yaml ]; then \
corepack prepare pnpm@latest --activate; \
pnpm install --frozen-lockfile; \
corepack prepare pnpm@latest --activate; \
pnpm install --frozen-lockfile; \
elif [ -f yarn.lock ]; then \
yarn install --frozen-lockfile; \
yarn install --frozen-lockfile; \
else \
npm ci; \
npm ci; \
fi
COPY . .
RUN NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} \
next build
RUN npm run build
# ── Stage 2: Production runtime ────────────────────────────────────────
FROM node:22-alpine AS runner
@@ -34,7 +35,6 @@ ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME="0.0.0.0"
COPY --from=builder /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
Vendored
+114
View File
@@ -0,0 +1,114 @@
// Confidence Engine — Manual Jenkins Deployment Pipeline
//
// Trigger: manually, via "Build with Parameters"
// Parameter: GIT_REF (string) — the Git ref to deploy
//
// Pre-requisites in Jenkins:
// 1. SSH credential of type "SSH Username with private key"
// named 'confidence-engine-deploy-ssh' that can reach
// CT 112 (confidence-engine / 192.168.68.73).
// The username from the credential is used for SSH login.
// 2. The Gitea repository configured in the job SCM section.
pipeline {
agent any
parameters {
string(
name: 'GIT_REF',
defaultValue: '',
description: 'Git ref to deploy (branch name, tag, or full commit SHA). Leave blank to fail.'
)
}
environment {
TARGET_HOST = '192.168.68.73'
DEPLOY_DIR = '/opt/confidence-engine'
HEALTH_URL = 'http://127.0.0.1:3000/api/health'
}
stages {
stage('Resolve') {
steps {
script {
def ref = params.GIT_REF.trim()
if (!ref || ref.isEmpty()) {
error 'GIT_REF parameter is blank or empty. Provide a Git ref to deploy.'
}
echo "Requested ref: ${ref}"
// Resolve the ref to an exact SHA via Gitea remote.
// If ref is already a 40-char hex SHA, use it directly.
def shaPattern = ~/^[0-9a-fA-F]{40}$/
def resolvedSha
if (ref ==~ shaPattern) {
resolvedSha = ref
echo "Provided ref is a full commit SHA: ${resolvedSha}"
} else {
// For branches/tags, look up on origin
resolvedSha = sh(
script: "git ls-remote origin refs/heads/${ref} refs/tags/${ref} 2>/dev/null | awk '/^[0-9a-f]/{print \$1; exit}'",
returnStdout: true
).trim()
if (!resolvedSha || resolvedSha.length() != 40) {
// Broader fallback — might match partial SHA or ref prefix
resolvedSha = sh(
script: "git ls-remote origin ${ref} 2>/dev/null | awk '/^[0-9a-f]/{print \$1; exit}'",
returnStdout: true
).trim()
if (!resolvedSha || resolvedSha.length() != 40) {
error "Cannot resolve '${ref}' to a commit SHA on origin. Check the ref and repository configuration."
}
}
}
echo "Resolved SHA: ${resolvedSha}"
env.DEPLOY_SHA = resolvedSha
}
}
}
stage('Deploy') {
steps {
script {
// Run the deployment script on CT 112 via SSH
withCredentials([sshUserPrivateKey(
credentialsId: 'confidence-engine-deploy-ssh',
keyFileVariable: 'SSH_KEY',
usernameVariable: 'SSH_USER'
)]) {
sh '''
ssh \
-i "$SSH_KEY" \
-o StrictHostKeyChecking=yes \
"$SSH_USER@$TARGET_HOST" \
bash -s -- "$DEPLOY_SHA" "$DEPLOY_DIR" "$HEALTH_URL" \
< "$WORKSPACE/scripts/deploy-production.sh"
'''
}
}
}
}
stage('Verify/result') {
steps {
script {
echo "Deployment stages completed. Check the Deploy stage output above for success/failure."
}
}
}
}
post {
failure {
echo 'DEPLOYMENT FAILED — check the Deploy stage logs for details.'
}
success {
echo "DEPLOYMENT SUCCEEDED — deployed SHA: ${env.DEPLOY_SHA}"
}
}
}
+11
View File
@@ -10,6 +10,17 @@ async function post(request) {
return Response.json(result, { status: 200 });
}
if (result.code === "PROVIDER_UNAVAILABLE") {
console.error("[api/cases/start] provider unavailable", {
error: result.error ?? "Start case failed",
providerApiPath: result.providerApiPath,
});
return Response.json(
{ success: false, error: "Reasoning service is temporarily unavailable." },
{ status: 503 },
);
}
const status =
result.statusCode === 400
? 400
@@ -144,9 +144,15 @@ async function post(request) {
});
console.info("[api/focused-investigation/deconstruct] end", {
targetNodeId,
status: 500,
status: e?.code === "PROVIDER_UNAVAILABLE" ? 503 : 500,
elapsedMs,
});
if (e?.code === "PROVIDER_UNAVAILABLE") {
return Response.json(
{ error: "Reasoning service is temporarily unavailable." },
{ status: 503 },
);
}
return Response.json(
{ error: e.message || "Unknown server error" },
{ status: 500 },
+11 -27
View File
@@ -3,47 +3,31 @@ import { getConfig } from "@/lib/config";
export async function GET() {
try {
const result = getConfig();
if (!result.ok) {
return Response.json({
configPresent: false,
baseUrl: null,
model: null,
reachable: false,
error: "Missing or invalid environment configuration",
}, { status: 500 });
return Response.json({ healthy: false }, { status: 500 });
}
const { OLLAMA_BASE_URL, OLLAMA_MODEL } = result.config;
// Test reachability with a short timeout
let reachable = false;
let reachError = null;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
signal: controller.signal
const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
signal: controller.signal
});
clearTimeout(timeout);
reachable = res.ok;
} catch (e) {
reachError = e.message || "Connection failed";
} catch {
reachable = false;
}
return Response.json({
configPresent: true,
baseUrl: OLLAMA_BASE_URL,
model: OLLAMA_MODEL,
reachable,
error: reachable ? null : (`Could not reach Ollama at ${OLLAMA_BASE_URL}: ${reachError || "timeout"}`),
});
} catch (e) {
return Response.json(
{ configPresent: false, error: e.message },
{ status: 500 }
);
return Response.json({ healthy: reachable }, { status: reachable ? 200 : 503 });
} catch {
return Response.json({ healthy: false }, { status: 500 });
}
}
+16 -1
View File
@@ -4,7 +4,22 @@ import { NextResponse } from "next/server";
export async function GET(request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const response = NextResponse.redirect(new URL("/", requestUrl.origin));
// Derive redirect origin from proxy-forwarded headers when present,
// falling back to the direct request origin for local/direct access.
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto");
let origin;
if (forwardedHost && forwardedProto) {
// Nginx Proxy Manager (and similar proxies) set these headers.
// x-forwarded-host may contain host:port or just hostname; use as-is.
origin = `${forwardedProto}://${forwardedHost}`;
} else {
origin = requestUrl.origin;
}
const response = NextResponse.redirect(new URL("/", origin));
if (code) {
const supabase = createServerClient(
+101 -2
View File
@@ -1,5 +1,104 @@
import LoginForm from "@/components/login-form";
export default function LoginPage() {
return <LoginForm />;
}
return (
<main className="mx-auto min-h-[calc(100vh-57px)] max-w-[1200px] px-6 py-16">
<div className="mx-auto flex flex-col gap-y-8 md:grid md:grid-cols-[minmax(0,1fr)_420px] md:gap-x-8 md:items-start">
{/* Left column wrapper — contents on mobile for flex ordering, block on desktop as one grid cell */}
<div className="contents md:block">
<section>
<h1 className="text-3xl font-bold tracking-tight text-teal-700">
Confidence Engine
</h1>
<div className="space-y-4 text-base leading-relaxed text-gray-700">
<h2 className="text-xl font-semibold tracking-tight text-gray-900">
Get clearer about what{"'"}s really going on.
</h2>
<p>
Confidence Engine helps you work through situations that feel
uncertain, complicated or difficult to act on.
</p>
<p>
Describe the situation in your own words. Confidence Engine will
help reconstruct what is known, what may be happening, and what
is still unclear then let you decide what to explore further.
</p>
<p className="text-gray-600">
It doesn{"'"}t try to make the decision for you. The aim is to
help you reach a clearer understanding so you can decide what to
do with more confidence.
</p>
</div>
</section>
<section className="space-y-4 mt-8 md:mt-8">
<h2 className="text-sm font-semibold uppercase tracking-[.18em] text-teal-700/70">
How it works
</h2>
<ol className="space-y-3 text-base leading-relaxed text-gray-700">
<li className="grid grid-cols-[1.5rem_1fr]">
<span className="font-bold">1.</span>
<div>
<strong>Describe your situation</strong>
<br />
<span className="text-gray-600">
As much or as little as you currently know.
</span>
</div>
</li>
<li className="grid grid-cols-[1.5rem_1fr]">
<span className="font-bold">2.</span>
<div>
<strong>Explore what{"'"}s unclear</strong>
<br />
<span className="text-gray-600">
Answer the questions that feel useful; skip the ones that
don{"'"}t.
</span>
</div>
</li>
<li className="grid grid-cols-[1.5rem_1fr]">
<span className="font-bold">3.</span>
<div>
<strong>Build your Current Understanding</strong>
<br />
<span className="text-gray-600">
Your picture of the situation develops as you learn more.
</span>
</div>
</li>
<li className="grid grid-cols-[1.5rem_1fr]">
<span className="font-bold">4.</span>
<div>
<strong>Stop when you have enough</strong>
<br />
<span className="text-gray-600">
You don{"'"}t have to answer everything. Your investigation
is saved so you can return later.
</span>
</div>
</li>
</ol>
<p className="text-sm italic text-gray-500">
Try it with something real.
<br />A decision you{"'"}re unsure about. A problem that doesn
{"'"}t quite make sense. A situation where you feel you may be
missing something.
</p>
</section>
</div>
{/* Right column — login card, explicitly positioned to top-right on desktop */}
<section className="md:col-start-2 md:row-start-1">
<LoginForm />
</section>
</div>
</main>
);
}
+11 -4
View File
@@ -25,8 +25,8 @@ export default function LoginForm() {
}
return (
<main className="mx-auto flex min-h-[calc(100vh-57px)] max-w-[640px] items-center px-6 py-16">
<section className="w-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-9 shadow-sm">
<section className="w-full shrink-0 space-y-6">
<div className="rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-9 shadow-sm">
<p className="mb-3 text-[11px] font-bold uppercase tracking-[.18em] text-teal-700/70">Welcome</p>
<h1 className="text-3xl font-bold tracking-tight">Confidence Engine</h1>
<p className="mt-3 text-sm leading-relaxed text-gray-600">Enter your email and we will send you a secure link to continue.</p>
@@ -39,7 +39,14 @@ export default function LoginForm() {
</form>
{status === "sent" && <p className="mt-5 text-sm text-green-700" role="status">Check your email for your magic link.</p>}
{error && <p className="mt-5 text-sm text-red-700" role="alert">{error}</p>}
</section>
</main>
</div>
<div className="pt-3 space-y-3">
<p className="text-sm font-medium text-gray-700">Sign in to continue</p>
<p className="text-sm leading-relaxed text-gray-600">Enter your email and we{'\''}ll send you a secure sign-in link.</p>
</div>
<p className="text-xs text-gray-500">Your investigations are private to your account and saved so you can return to them later.</p>
</section>
);
}
+12
View File
@@ -0,0 +1,12 @@
# Confidence Engine — Deploy Environment (example)
#
# Copy to /opt/confidence-engine/deploy.env on CT 112.
# This file is NOT tracked by Git. It is owned/administered on the host.
#
# Build-time (baked into Docker image via --build-arg):
NEXT_PUBLIC_SUPABASE_URL=https://supabase.rdbcloud.co.uk
NEXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-supabase-anon-key
# Runtime (passed to container at start):
OLLAMA_BASE_URL=http://192.168.x.x:11434
OLLAMA_MODEL=replace-with-model-name
+138 -9
View File
@@ -3,19 +3,113 @@
> **Role:** Concise operational snapshot for resuming work today. Not a historical diary.
> The design evolution archive index at `docs/design-evolution/README.md` provides progressive loading of experiment history; load the relevant chapter only when a specific historical question requires it.
## v0.62d production Docker packaging established
## Focused-investigation provider outage boundary
- Focused-investigation outage handling now sanitizes provider failure at the API boundary: unavailable focused reasoning returns a controlled HTTP 503, and raw provider/Ollama diagnostics no longer leave that boundary.
- The existing generic Retry UX remains unchanged. A live deployed outage had already proven investigation preservation.
- `/api/cases/start` and `/api/cases/update` outage sanitization remain separately unverified; this change does not claim those routes are fixed.
## v0.62d production Docker packaging — LIVE PROVEN
- `Dockerfile` — minimal multi-stage Alpine build (Node 22), Next.js standalone output mode
- `.dockerignore` — excludes dev artefacts, secrets, docs from build context
- `next.config.mjs` — added `output: 'standalone'` (required for lean production container)
- `.env.example` — reorganized: Supabase → Ollama → Mock sections; Ollama vars now tracked as deployment-relevant
- **npm production build: PASS** ✓
- **Docker image build: BLOCKED** — no Docker runtime on development machine (apparatus limitation, not product defect)
- Production container starts / `/api/health` smoke test: pending Docker runtime availability
- No persistent application volume required
- Supabase remains external, Ollama remains private and server-reachable
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` as established by the implementation (baked into browser bundle)
- **No deployment performed yet**
- **Docker image build on CT 112: PROVEN** — production Docker image builds successfully on CT 112, standalone Next.js container starts successfully.
- Production container `/api/health`: **PROVEN**`{"healthy":true}`, no private Ollama endpoint / model name / raw internal error exposed.
- No persistent application volume required.
- Supabase remains external, Ollama remains private and server-reachable from the deployment host.
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` as established by the implementation (baked into browser bundle).
- **Deployment: LIVE PROVEN** — manual Jenkins pipeline succeeded end-to-end (SHA-tagged image, container replaced, health check passed).
### v0.62d External Deployment (LIVE)
Confidence Engine is externally reachable at **https://confidence.rdbcloud.co.uk**.
Production topology:
```
Internet → HTTPS → Nginx Proxy Manager → CT 112 / Confidence Engine Docker container
```
CT 112 deployment details:
```
hostname: confidence-engine
LAN address: 192.168.68.73
repo: /opt/confidence-engine
container: confidence-engine
port: 3000
```
Docker-in-LXC has been proven. Docker image builds successfully on CT 112.
### v0.62d Magic-Link Login (LIVE)
External magic-link login has been proven through the deployed application at `https://confidence.rdbcloud.co.uk`. The reverse-proxy callback-origin defect was corrected; successful login now returns to the external Confidence Engine Portfolio rather than `0.0.0.0:3000`.
Auth email branding configured on external Supabase infrastructure (outside this repository):
- Confidence Engine sender name / subject / branded HTML body
- SPF PASS, DKIM PASS, DMARC PASS — recipient-side confirmed
- Missing original Message-ID / junk-folder observation remains a non-blocking external mail-deliverability note
### v0.62d Multi-user Boundary (LIVE)
Application-level isolation proven on deployed instance:
```
User A sees User A investigations
User B does not see User A investigations
```
### v0.62d Real Deployed Product Journey (LIVE)
Manually proved through the externally deployed application:
```
authenticate → Portfolio → create new investigation → reason through Qwen
→ server persistence → return to Portfolio → leave/return → investigation restored
```
Server-backed persistence remains authoritative. Legacy localStorage investigations remain ignored.
### v0.62d Manual Jenkins Deployment Pipeline (LIVE PROVEN)
Manual end-to-end pipeline proven:
```
manual Jenkins Build with Parameters → GIT_REF accepted
→ remote ref resolved to immutable SHA → Jenkins SSH credential bound
→ SSH to CT 112 → deployment script executed → exact SHA fetched/checked out
→ Docker image built and tagged by SHA → existing CE container replaced
→ /api/health polled → healthy response observed → DEPLOYMENT SUCCEEDED
```
Pipeline design:
- Manual trigger, runtime-selectable `GIT_REF`, immutable SHA deployment
- CT 112 as Docker build/runtime host, target-owned `/opt/confidence-engine/deploy.env`
- SHA-tagged Docker images, health-gated success, bounded rollback support
- No Docker registry, no automatic webhook deploy
**Path status:** success path = LIVE PROVEN. rollback path = IMPLEMENTED, NOT LIVE PROVEN.
Jenkins SCM branch used to load the Jenkinsfile is conceptually separate from the `GIT_REF` chosen for deployment.
## v0.63 First-Time Unauthenticated Product Framing
- login page now explains product purpose and investigation flow to first-time visitors without prior explanation
- user judgement/choice explicitly preserved ("It doesn't try to make the decision for you")
- existing magic-link authentication unchanged
- no broader onboarding/tutorial system introduced
### Mobile-first sign-in order (responsive layout fix)
- v0.63f corrected mobile reading order: proposition → sign-in card → supporting explanation
- CSS Grid with responsive column placement replaces original flexbox; three DOM sections ensure correct mobile stacking without duplicating content
- desktop two-column presentation (explanation left / sign-in right) preserved unchanged at `md` breakpoint and above
## CURRENT MVP DIRECTION
@@ -75,11 +169,46 @@ Does the complete investigation process leave real people materially clearer abo
- The full apply-proposal owner suite remains known-red in independent pre-existing 60B.43 tests, so the changed Done-for-now boundary was verified through exact isolated owner tests. Zero live calls occurred during closeout. Next boundary: reuse the existing investigation and click Done for now once, observing cases/update and subsequent synthesis.
- Live UI proved Done for now briefly clarified the question, then server graph replacement reopened it: the client sends `preDoneGraph`, and the server previously had no deterministic closure owner. Episode-mode update now adds the selected `targetNodeId` to `resolvedNodeIds` only after successful semantic application; semantic no-ops and meaningful mutations remain valid, while failed episodes do not resolve the target and ordinary updates are unchanged. Zero live calls occurred during implementation. Next boundary: one live Done-for-now check on the existing investigation.
## Deployment automation (LIVE PROVEN)
Manual Jenkins deployment pipeline established, version-controlled, and LIVE PROVEN end-to-end.
- `Jenkinsfile` in repository root — Declarative Pipeline, three stages: Resolve → Deploy → Verify/result.
- `scripts/deploy-production.sh` — executes on CT 112; validates SHA, fetches Git ref, checks out exact commit, builds Docker image tagged by SHA, replaces container, polls `/api/health`, one-step rollback to prior image on failure.
- `deploy.env.example` — example of required runtime/build environment file (NOT tracked).
- Production env owned by `/opt/confidence-engine/deploy.env` on CT 112 (not in Git).
- `GIT_REF` is runtime-selectable (Jenkins parameter); resolves to immutable SHA before deployment.
- No Docker registry introduced. SHA-tagged images retained for rollback support.
- Jenkins job remains manually triggered — no automatic webhook deployment.
- No product behaviour changed by deployment automation.
**Success path: LIVE PROVEN.** **Rollback path: IMPLEMENTED, NOT LIVE PROVEN.**
## Repository checkpoint
- **Branch:** `feature/product-platform-foundation-v0.62`
- **HEAD:** `6dd447e` — feat(confidence-engine): add authenticated investigation persistence
- **Working tree:** dirty with completed v0.62c cutover (server-authoritative persistence, async seam, 404→null correction)
- **HEAD:** *(checkpoint commit — see git log for actual SHA)*
- **Working tree:** clean
## Architectural Conclusion
**The original v0.62 product-platform objective is achieved:**
- Authenticated identity (magic-link via self-hosted Supabase)
- Authenticated reasoning APIs (private Ollama/Qwen, server-reachable)
- User-owned server persistence (Supabase `investigations`, RLS-scoped)
- Multi-user application behaviour (application-level isolation proven)
- Portable Docker runtime (Docker-in-LXC on CT 112)
- External HTTPS deployment (Nginx Proxy Manager → CE container)
- External magic-link authentication (callback fix, email branding proven)
- Manual one-touch Jenkins deployment (SHA-tagged, health-gated)
**The following are NOT required and NOT justified before testing:**
- Legacy localStorage migration — not required. Four development/test investigations will not be migrated; legacy data remains invisible to normal product flow.
- Additional SaaS/platform machinery — not justified before real user testing.
> **Platform/deployment foundation work is complete enough to disappear into the background. The next Confidence Engine work should return to product/reasoning/user-learning priorities rather than continuing infrastructure expansion unless a real operational failure demands it.**
## Persistence
+58 -7
View File
@@ -1,18 +1,40 @@
# Current Project State — Confidence Engine
## v0.62d Production Docker Packaging
## Focused-Investigation Provider Outage Boundary
- Focused-investigation outage handling now sanitizes provider failure at the API boundary: unavailable focused reasoning returns a controlled HTTP 503, and raw provider/Ollama diagnostics no longer leave that boundary.
- The existing generic Retry UX remains unchanged. A live deployed outage had already proven investigation preservation.
- `/api/cases/start` and `/api/cases/update` outage sanitization remain separately unverified; this change does not claim those routes are fixed.
## v0.62d Production Docker Packaging — LIVE PROVEN
- `Dockerfile` created: multi-stage Alpine build (Node 22), Next.js standalone output, configurable port 3000, health-check boundary via `/api/health`
- `.dockerignore` created: excludes `node_modules`, `.next`, secrets (`env.*.local`), docs, IDE, OS artefacts
- `next.config.mjs`: added `output: 'standalone'` for lean production container (only config change required)
- `.env.example`: reorganized; Ollama variables now tracked as deployment-relevant env vars
- npm production build: PASS ✓
- Docker image build: blocked — no Docker runtime on development machine (apparatus limitation, not product defect)
- Production container start / `/api/health` smoke test: pending Docker availability
- No persistent application volume required
- Supabase remains external; Ollama remains private and server-reachable
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` (baked into browser bundle)
- **No deployment performed**
- Docker image build on CT 112: **PROVEN** — builds successfully, standalone container starts successfully.
- Production container `/api/health`: **PROVEN**`{"healthy":true}`, no private Ollama model name / raw internal error exposed.
- No persistent application volume required.
- Supabase remains external; Ollama remains private and server-reachable from deployment host.
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` (baked into browser bundle).
- **Deployment: LIVE PROVEN** — manual Jenkins pipeline succeeded end-to-end.
### External Deployment (LIVE)
Confidence Engine is externally reachable at **https://confidence.rdbcloud.co.uk**.
Topology: Internet → HTTPS → Nginx Proxy Manager → CT 112 / Confidence Engine Docker container.
CT 112: hostname `confidence-engine`, LAN `192.168.68.73`, repo `/opt/confidence-engine`, container `confidence-engine`, port `3000`.
### External Magic-Link Login (LIVE)
Magic-link authentication proven through deployed instance. Callback-origin defect corrected; login returns to external portfolio rather than `0.0.0.0:3000`. Email branding configured on external Supabase (sender name, subject, branded HTML body). SPF/DKIM/DMARC confirmed recipient-side.
### Multi-user Boundary (LIVE)
Application-level isolation proven on deployed instance — User A does not see User B investigations and vice versa.
> Created by Experiment 27. This document is the starting point for any fresh session working on the Confidence Engine. Read this first, then follow the routing table below to task-specific references.
@@ -188,6 +210,35 @@ First document to read: **`docs/current-handoff.md`** (methodology continuity +
Implementation status last checked against source: Experiment 43 + v0.61 apparatus (tsx helper, reconstruction-only seam, focused-deconstruction schema fix). Multi-investigation architecture verified at v0.60g2+ and structurally complete. The current-state document was verified as accurate by focused code inspection of API routes, orchestrator imports/calls, and cross-module traces for all passive classifiers. No corrections were required.
## Deployment Automation — LIVE PROVEN
A manual Jenkins deployment pipeline has been established, version-controlled, and LIVE PROVEN end-to-end.
- **Jenkinsfile** (root) — Declarative Pipeline with three stages: `Resolve``Deploy``Verify/result`.
- **Parameter:** `GIT_REF` (string) — user-supplied Git ref (branch, tag, or SHA). Blank value fails clearly.
- **Resolution:** Jenkins resolves the ref to an exact commit SHA via `git ls-remote origin` before deployment. The SHA is deployed immutably.
- **Target host:** CT 112 (`confidence-engine`, 192.168.68.73) at `/opt/confidence-engine`.
- **Docker image:** tagged `confidence-engine:<sha>`, built on CT 112, no registry required.
- **Environment:** production values in `/opt/confidence-engine/deploy.env` on CT 112 (not in Git). `deploy.env.example` provided as reference.
- **Health check:** polls `http://127.0.0.1:3000/api/health`; requires `{"healthy":true}` within 60s.
- **Rollback:** one-step rollback to the previous container image on failure (if available). **IMPLEMENTED, NOT LIVE PROVEN.**
- **Jenkins job:** configured and operational. First successful deployment recorded with explicit "DEPLOYMENT SUCCEEDED" output.
- **Jenkins SCM branch** used to load the Jenkinsfile is conceptually separate from the `GIT_REF` chosen for deployment.
### Pipeline design notes
- Manual trigger only — no automatic webhook deploy.
- No Docker registry introduced; SHA-tagged images retained on CT 112 for rollback support.
- No product behaviour changed by deployment automation.
## 9. First-Time Unauthenticated Landing/Login Framing
- login page now explains product purpose and investigation flow to first-time visitors without prior explanation
- user judgement/choice explicitly preserved ("It doesn't try to make the decision for you")
- existing magic-link authentication unchanged
- no broader onboarding/tutorial system introduced
- verified via Playwright semantic locators (heading, text content, form controls) and responsive viewport
## 10. Post-v0.8 Methodology Learning
### Durable methodology principles (2026-08-19)
+3
View File
@@ -439,6 +439,9 @@ class OllamaLlmProvider {
`- Use a smaller model (e.g., llama3.1 instead of llama3.1:70b)\n` +
`- Check Ollama logs: \`ollama serve\` or look at your system logs`
);
if (e?.name === "AbortError" || e instanceof TypeError) {
error.code = "PROVIDER_UNAVAILABLE";
}
error.providerApiPath = apiUsed;
error.providerExecution = providerExecution;
throw error;
+1 -1
View File
@@ -1,7 +1,7 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";
const PUBLIC_PATHS = ["/login", "/auth"];
const PUBLIC_PATHS = ["/login", "/auth", "/api/health"];
export async function middleware(request) {
const pathname = request.nextUrl.pathname;
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Confidence Engine — Production Deployment Script
#
# Executes on CT 112 at /opt/confidence-engine.
# Input: $1 = exact commit SHA (validated)
#
# Environment:
# DEPLOY_DIR - target repository path (defaults /opt/confidence-engine)
# HEALTH_URL - health check endpoint (defaults http://127.0.0.1:3000/api/health)
#
# Reads deploy.env from $DEPLOY_DIR/deploy.env for runtime values.
set -euo pipefail
# ── Parameters ──────────────────────────────────────────────────────
DEPLOY_SHA="${1:?Error: missing commit SHA argument}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/confidence-engine}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/api/health}"
# ── Validation ──────────────────────────────────────────────────────
if [[ ${#DEPLOY_SHA} -lt 7 ]]; then
echo "ERROR: SHA must be at least 7 hex characters."
exit 1
fi
if ! echo "$DEPLOY_SHA" | grep -qE '^[0-9a-fA-F]+$'; then
echo "ERROR: SHA contains non-hex characters."
exit 1
fi
echo "Deploying SHA: $DEPLOY_SHA"
cd "$DEPLOY_DIR" || { echo "ERROR: Cannot cd to $DEPLOY_DIR"; exit 1; }
# ── Safety checks ───────────────────────────────────────────────────
# deploy.env must exist on the host (not tracked by Git)
if [[ ! -f deploy.env ]]; then
echo "DEPLOYMENT BLOCKED - deploy.env not found at deploy.env"
exit 1
fi
# Check for uncommitted changes that would block safe checkout
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
echo "DEPLOYMENT BLOCKED - target working tree has uncommitted changes"
exit 1
fi
if [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
echo "DEPLOYMENT BLOCKED - target working tree has untracked files"
exit 1
fi
# ── Fetch and verify SHA ────────────────────────────────────────────
echo "Fetching latest refs from origin..."
git fetch origin >/dev/null 2>&1 || {
echo "ERROR: git fetch origin failed."
exit 1
}
if ! git rev-parse --verify "$DEPLOY_SHA" >/dev/null 2>&1; then
echo "ERROR: SHA $DEPLOY_SHA not found in repository after fetch."
exit 1
fi
# ── Detached checkout (never modifies working tree or remote) ───────
echo "Checking out exactly $DEPLOY_SHA..."
git checkout --detach "$DEPLOY_SHA" >/dev/null 2>&1 || {
echo "ERROR: Could not checkout SHA $DEPLOY_SHA."
exit 1
}
# Verify the detached HEAD matches our deploy SHA
CURRENT_SHA="$(git rev-parse HEAD)"
if [[ "${CURRENT_SHA%"${CURRENT_SHA#?}"}" != "${DEPLOY_SHA%"${DEPLOY_SHA#?}"}" ]] || \
[[ "$CURRENT_SHA" != "$DEPLOY_SHA" && "${CURRENT_SHA:0:7}" != "${DEPLOY_SHA:0:7}" ]]; then
echo "ERROR: Checked out SHA $CURRENT_SHA does not match requested $DEPLOY_SHA."
exit 1
fi
# ── Load runtime environment ────────────────────────────────────────
if [[ -f deploy.env ]]; then
set -a
# shellcheck disable=SC1091
source deploy.env
set +a
else
echo "ERROR: deploy.env not found at $DEPLOY_DIR/deploy.env"
exit 1
fi
# ── Build Docker image ──────────────────────────────────────────────
IMAGE_TAG="confidence-engine:${DEPLOY_SHA}"
echo "Building Docker image ${IMAGE_TAG}..."
docker build \
--build-arg NEXT_PUBLIC_SUPABASE_URL="${NEXT_PUBLIC_SUPABASE_URL}" \
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="${NEXT_PUBLIC_SUPABASE_ANON_KEY}" \
-t "${IMAGE_TAG}" \
.
echo "Image ${IMAGE_TAG} built successfully."
# ── Container replacement ───────────────────────────────────────────
CONTAINER_NAME="confidence-engine"
# Record previous image before replacing container
PREV_IMAGE=""
if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
PREV_IMAGE="$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "")"
fi
echo "Stopping existing container (if running)..."
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
echo "Starting new container from ${IMAGE_TAG}..."
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 3000:3000 \
-e OLLAMA_BASE_URL="${OLLAMA_BASE_URL}" \
-e OLLAMA_MODEL="${OLLAMA_MODEL}" \
"${IMAGE_TAG}"
echo "Container $CONTAINER_NAME started."
# ── Health check polling ────────────────────────────────────────────
HEALTH_CHECK_TIMEOUT=60
HEALTH_CHECK_INTERVAL=3
ELAPSED=0
echo "Waiting for health check at ${HEALTH_URL}..."
while [[ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]]; do
if curl -sf "$HEALTH_URL" | grep -q '"healthy"[[:space:]]*:[[:space:]]*true'; then
echo ""
echo "============================================="
echo "DEPLOYMENT SUCCEEDED"
echo "Deployed SHA: $DEPLOY_SHA"
echo "Current image: $(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo 'unknown')"
echo "============================================="
exit 0
fi
sleep "$HEALTH_CHECK_INTERVAL"
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
done
echo ""
echo "============================================="
echo "DEPLOYMENT FAILED - Health check timed out after ${HEALTH_CHECK_TIMEOUT}s"
echo "============================================="
# ── Rollback (one attempt) ──────────────────────────────────────────
if [[ -n "$PREV_IMAGE" ]]; then
echo ""
echo "Attempting rollback to previous image: $PREV_IMAGE"
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 3000:3000 \
-e OLLAMA_BASE_URL="${OLLAMA_BASE_URL}" \
-e OLLAMA_MODEL="${OLLAMA_MODEL}" \
"$PREV_IMAGE"
if [[ $? -eq 0 ]]; then
echo "DEPLOYMENT FAILED - ROLLED BACK to $PREV_IMAGE"
else
echo "DEPLOYMENT FAILED - ROLLBACK ALSO FAILED"
fi
else
echo "No previous image available for rollback."
fi
echo "DEPLOYMENT FAILED - MANUAL RECOVERY REQUIRED"
exit 1
+31
View File
@@ -8,6 +8,10 @@ vi.mock("@/lib/graph/orchestrator.js", () => ({
startCase: (...args) => mockStartCase(...args),
}));
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
describe("app/api/cases/start route", () => {
beforeEach(() => {
vi.resetModules();
@@ -123,6 +127,33 @@ describe("app/api/cases/start route", () => {
expect(errorSpy).not.toHaveBeenCalled();
});
it("returns a sanitized 503 when the provider is unavailable", async () => {
mockStartCase.mockResolvedValue({
success: false,
code: "PROVIDER_UNAVAILABLE",
error: "Ollama /api/generate request timed out after 5 minutes",
providerApiPath: "/api/generate",
providerExecution: { generateRequestAttempted: true },
});
const { POST } = await import("@/app/api/cases/start/route.js");
const response = await POST(
new Request("http://localhost/api/cases/start", {
method: "POST",
body: JSON.stringify({ scenario: "Scenario text" }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(503);
const body = await response.json();
expect(body).toEqual({
success: false,
error: "Reasoning service is temporarily unavailable.",
});
expect(JSON.stringify(body)).not.toMatch(/ollama|generate|timed out/i);
});
it("returns provider/internal failures as 5xx without stack traces", async () => {
const rawResponse = `{"reconstruction":{"observedStates":[{"id":"obs-1"${"x".repeat(2500)}}]}}`;
mockStartCase.mockResolvedValue({
+65 -4
View File
@@ -19,9 +19,47 @@ vi.mock("@/lib/config", () => ({
}));
vi.mock("@supabase/ssr", () => ({
createServerClient: () => ({ auth: { getUser: () => mockGetUser() } }),
createServerClient: () => ({
auth: {
getUser: () => mockGetUser(),
exchangeCodeForSession: vi.fn().mockResolvedValue(undefined),
},
}),
}));
describe("auth callback redirect origin", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses forwarded host/proto for redirect when behind proxy", async () => {
const { GET } = await import("@/app/auth/callback/route.js");
const request = new Request("http://0.0.0.0:3000/auth/callback?code=abc123", {
headers: {
"x-forwarded-host": "confidence.rdbcloud.co.uk",
"x-forwarded-proto": "https",
},
});
const response = await GET(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://confidence.rdbcloud.co.uk/");
});
it("falls back to request origin when no forwarded headers", async () => {
const { GET } = await import("@/app/auth/callback/route.js");
const request = new Request("http://localhost:3000/auth/callback?code=xyz");
const response = await GET(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost:3000/");
});
});
describe("authenticated product boundary", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -58,15 +96,38 @@ describe("authenticated product boundary", () => {
expect(magicLinkRedirectTo("http://localhost:3000")).toBe("http://localhost:3000/auth/callback");
});
it("keeps infrastructure health public", async () => {
it("keeps infrastructure health public and does not leak config details", async () => {
mockGetConfig.mockReturnValue({ ok: true, config: {} });
const { GET } = await import("@/app/api/health/route.js");
const response = await GET();
expect(mockGetAuthenticatedUser).not.toHaveBeenCalled();
const body = await response.json();
expect(body).toHaveProperty("healthy");
// Must not expose private config details in the public response
expect(JSON.stringify(body)).not.toContain("baseUrl");
expect(JSON.stringify(body)).not.toContain("model");
expect(JSON.stringify(body)).not.toContain("ollama");
});
it("reports unhealthy generic state when config is missing", async () => {
mockGetConfig.mockReturnValue({ ok: false });
const { GET } = await import("@/app/api/health/route.js");
const response = await GET();
expect(response.status).toBe(500);
await expect(response.json()).resolves.toMatchObject({ configPresent: false });
expect(mockGetAuthenticatedUser).not.toHaveBeenCalled();
const body = await response.json();
expect(body).toEqual({ healthy: false });
});
it("does not convert /api/health to 401 via middleware when unauthenticated", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } });
const { middleware } = await import("@/middleware.js");
const response = await middleware(new NextRequest("http://localhost:3000/api/health"));
expect(response.status).toBe(200);
});
it("redirects unauthenticated product access to the login surface", async () => {
@@ -9,6 +9,10 @@
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { focusedDeconstructJsonSchema, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation";
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
// ── helpers ──────────────────────────────────────────────────────────────
function makeMockProvider(inventedTargetNodeId) {
@@ -389,6 +393,36 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
}
});
it("returns a sanitized 503 when the provider is unavailable", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockRejectedValue(Object.assign(new Error(
"Ollama /api/generate request timed out after 5 minutes",
), { code: "PROVIDER_UNAVAILABLE", providerApiPath: "/api/generate" })),
}),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
try {
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
centralStatement: "central", question: "question?", answer: "answer.",
}),
}));
expect(response.status).toBe(503);
const json = await response.json();
expect(json).toEqual({ error: "Reasoning service is temporarily unavailable." });
expect(JSON.stringify(json)).not.toMatch(/ollama|generate|timed out/i);
} finally {
errorSpy.mockRestore();
}
});
it("preserves the 502 validation-failure contract with diagnostics", async () => {
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({