Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
# NOTE: automatic triggering is disabled — see DEPLOYMENT.md "Marketing site".
# The ghost-app Vercel project (VERCEL_PROJECT_ID below) currently has its Root
# Directory overridden to cloud/apps/web, which now serves the live cloud SaaS
# app at ghost.muharafiq.com. Because that override applies regardless of what
# changed in the push, running this workflow as-is would redeploy cloud/apps/web
# again, not public/, no matter what the steps below claim. Do not re-enable the
# push trigger or run this via workflow_dispatch until public/ has its own
# Vercel project (or domain) pointed at this directory, or is retired.
name: Deploy Website

on:
push:
branches: [main, master]
paths:
- "public/**"
- ".github/workflows/deploy-website.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
deploy:
name: Deploy to Vercel (ghost.muharafiq.com)
name: Deploy to Vercel (public/ — target currently misconfigured, see note above)
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -62,11 +65,10 @@ jobs:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
run: |
echo "::warning::VERCEL_PROJECT_ID's Root Directory is overridden to cloud/apps/web, so this will deploy the cloud app, not public/. See the note at the top of this workflow file."
echo "Deploying public/ to Vercel…"
vercel deploy --prod --token="${VERCEL_TOKEN}" --yes
echo "Live: https://ghost.muharafiq.com"

- name: Deployment summary
run: |
echo "Deployed $(find public -type f | wc -l) files from public/"
echo "Site: https://ghost.muharafiq.com"
echo "Deployed $(find public -type f | wc -l) files from public/ (see the warning above about where this actually landed)"
52 changes: 49 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,11 @@ Build the five-part MVP in `cloud/`, in order:
Built so far (Phase 1): the execution engine, the deterministic approval gate,
per-step screenshots + verification, the hash-chained audit log, the run →
approval → verify UI, and the agent HTTP/MCP surface (agents propose; humans
approve — `cloud/docs/AGENT_PLUGIN.md`). Recording (steps 1–2) is next. See
`cloud/docs/PHASE_1_PLAN.md` and `cloud/docs/CURSOR_HANDOFF.md`.
approve — `cloud/docs/AGENT_PLUGIN.md`). Phase 2 (recording → editable steps) is
in progress: the convert side (upload → deterministic compile → review) is
built and off by default; a Chrome extension (`cloud/apps/extension`) captures
the browser session. See `cloud/docs/PHASE_1_PLAN.md` and
`cloud/docs/CURSOR_HANDOFF.md` for current status.

Required behavior (unchanged in spirit from the desktop trust pipeline):

Expand All @@ -117,6 +120,49 @@ Required behavior (unchanged in spirit from the desktop trust pipeline):
- verify each step's outcome;
- write audit events (hash-chained) for every run and step.

### Cloud workspace layout & commands

`cloud/` is a self-contained pnpm + Turborepo workspace — it does not share
tooling with the repo root. `cd cloud` before running anything below.

```text
cloud/
apps/
web/ Next.js 15 (App Router) — UI + API + /api/agent/* → deployed to Vercel
worker/ Node worker: BullMQ consumers + Playwright execution → deployed as a container
mcp/ Stdio MCP bridge for Cursor/Claude (no approve tools)
extension/ Chrome extension — records a browser session for Phase 2 capture
packages/
core/ Prisma schema, Zod step types, classifyStep (approval gate), audit chain, agent catalog
```

Quickstart: `cd cloud && pnpm demo` (writes `.env`, brings up Postgres/Redis,
migrates, installs Chromium, starts web + worker — idempotent, safe to rerun).
Manual steps and the two load-bearing env vars (`GHOST_ARTIFACT_DIR` must be an
absolute path shared by web+worker; `GHOST_SESSION_KEY` must decode to 32 bytes)
are in `cloud/README.md`.

Validation from inside `cloud/`:

```bash
pnpm typecheck # the real static gate — run this even if lint is clean
pnpm lint # apps/web only; worker/mcp/core have no lint script yet
pnpm test # ~90 of ~239 tests need DATABASE_URL set or they skip silently
pnpm build
```

`pnpm test` also needs `REDIS_URL` and `GHOST_SESSION_KEY` set (not just
`DATABASE_URL`) or the DB-gated tests run instead of skipping and fail on
status rather than on anything naming the missing var: without `REDIS_URL`,
rate-limited routes (e.g. invite acceptance) fail closed with 429; without
`GHOST_SESSION_KEY`, the worker skips session capture at an approval gate, so
every gated run refuses to resume and ends `INCIDENT`. Both read like product
bugs and are actually a missing local env var — `.github/workflows/cloud.yml`
sets all three for exactly this reason.

Don't reach for the root-level `cargo`/`make` commands when working in `cloud/`
— they build the unrelated legacy desktop app.

## Engineering rules

Every meaningful operation should pass through:
Expand Down Expand Up @@ -211,7 +257,7 @@ Current structure:
src/ # Tauri desktop frontend (ES-module JS/HTML/CSS, bundled by Vite; main.js holds most UI logic; compression-review.js/.css is the split-out event-review timeline; src/public/ holds pass-through static assets)
apps/macos/ # Ghost 2.0 native macOS app (SwiftUI): App/, Views/, Features/, Services/, RustBridge/, AppKitBridge/ — UI only; all trust decisions stay in the Rust core over a JSON stdin/stdout bridge (docs/legacy/native-macos-preview.md)
native/macos/ # GhostAXHelper.swift — read-only macOS Accessibility helper (list_matches op)
public/ # marketing/download site (static vanilla JS with in-browser demos; ships Ghost.dmg / Ghost_Setup.exe under downloads/; auto-deployed to Vercel by deploy-website.yml)
public/ # marketing/download site for the legacy desktop app (static vanilla JS with in-browser demos; ships Ghost.dmg / Ghost_Setup.exe under downloads/); NOT currently deployed anywhere — deploy-website.yml's auto-trigger is disabled because the Vercel project it targets now serves cloud/apps/web at ghost.muharafiq.com instead (see DEPLOYMENT.md)
src-tauri/ # Rust backend
docs/ # planning and technical docs
.github/workflows/ # CI (rust.yml), release (release.yml), site deploy (deploy-website.yml)
Expand Down
24 changes: 20 additions & 4 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,28 @@ Production sketch:
Cloud CI workflow is staged at `cloud/ci/cloud.yml` until installed under
`.github/workflows/`.

Details: `cloud/README.md`, `cloud/docs/CURSOR_HANDOFF.md`.
**Live today:** the production domain **`ghost.muharafiq.com`** points at the
`ghost-app` Vercel project with its Root Directory set to `cloud/apps/web` — it
serves the cloud SaaS app directly (deployed manually today via
`vercel --prod --scope muharafiq --cwd cloud/apps/web`, not by a checked-in CI
workflow). This is a deliberate change from the domain's original use as the
static marketing site — see "Marketing site" below.

## Marketing site
Details: `cloud/README.md`, `cloud/docs/CURSOR_HANDOFF.md`.

Static files in `public/` deploy via `.github/workflows/deploy-website.yml` to Vercel.
This is the public marketing surface — keep it aligned with the cloud product.
## Marketing site (currently not deployed anywhere)

`public/` is a separate static site (vanilla JS, ships the legacy desktop
Ghost.dmg/Ghost_Setup.exe installers) for the superseded desktop product, not
the cloud SaaS. `.github/workflows/deploy-website.yml` still exists to deploy
it, but it targets the same `ghost-app` Vercel project that now serves
`cloud/apps/web` above — because Vercel's Root Directory override applies
regardless of which files actually changed, running this workflow today would
redeploy the cloud app again, not `public/`, despite its build log claiming
otherwise. Its automatic trigger is disabled for that reason (see the workflow
file). Before re-enabling it: either point it at a separate Vercel
project/domain for the legacy site, or retire `public/` outright if the legacy
desktop product no longer needs a public download page.

## Legacy desktop

Expand Down
19 changes: 18 additions & 1 deletion cloud/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
# Redis with the defaults below out of the box.
# ---------------------------------------------------------------------------

# Postgres (matches cloud/docker-compose.yml)
# Postgres (matches cloud/docker-compose.yml).
#
# Both apps load THIS file — the workspace root one — via packages/core/src/env.ts.
#
# If 5432 is already taken by an unrelated Postgres (Homebrew, Postgres.app,
# another project), `pnpm demo` moves Ghost's to 55432 and rewrites this line.
# It probes by running a query as the ghost user, because a port that merely
# accepts connections is not evidence: a foreign Postgres answers the socket and
# then denies every query, which surfaces much later as unexplained 500s.
# `pnpm check` reports the same thing without changing anything.
DATABASE_URL="postgresql://ghost:ghost@localhost:5432/ghost?schema=public"

# Redis / BullMQ (matches cloud/docker-compose.yml)
Expand Down Expand Up @@ -46,6 +55,14 @@ AUTH_GITHUB_SECRET=""
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""

# Email magic-link sign-in via Resend (optional in dev, same reason). Needs
# the sending domain verified in Resend (SPF/DKIM DNS records) before real
# emails will deliver — see docs/DEPLOY.md. Setting this also activates the
# Prisma adapter (see auth.ts), which GitHub/Google reuse for account linking.
# Install: https://vercel.com/marketplace/resend, or https://resend.com directly.
RESEND_API_KEY=""
RESEND_EMAIL_DOMAIN=""

# S3-compatible object storage for run screenshots + recording traces.
# Works with AWS S3, Cloudflare R2, or MinIO. Not required for local dev.
#
Expand Down
32 changes: 31 additions & 1 deletion cloud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ cp .env.example .env # a working local config as-is
# GHOST_ARTIFACT_DIR -> e.g. $PWD/.artifacts
pnpm install # runs `prisma generate` via core postinstall
docker compose up -d # Postgres :5432, Redis :6379
# (set GHOST_PG_PORT / GHOST_REDIS_PORT if
# those ports are already taken)
pnpm db:migrate # apply the Prisma schema
pnpm --filter @ghost/worker exec playwright install chromium
pnpm dev # web on http://localhost:3000 + worker
Expand All @@ -116,6 +118,34 @@ pnpm --filter @ghost/web dev
pnpm --filter @ghost/worker dev
```

Both read `cloud/.env` directly (`packages/core/src/env.ts`), so either one
works on its own. A real environment variable always wins over the file, and in
deployment there is no `.env` at all.

## When something is wrong

```bash
pnpm check
```

Read-only; it names the problem rather than leaving you to infer it. Ghost
fails locally in two ways that look like nothing at all:

- **No worker running.** The UI is fine, "Run" appears to work, and the run sits
there forever, because the process that executes runs is not up. `pnpm dev`
starts both; `pnpm --filter @ghost/worker dev` starts just the worker.
- **`DATABASE_URL` pointing at the wrong Postgres.** A Postgres that is merely
*listening* on 5432 is not Ghost's — Homebrew's, Postgres.app's, another
project's container will all accept the connection and deny the user. `pnpm
demo` probes with real credentials, moves to a free port if it must, and
repairs `.env`.

A stalled run is no longer permanent either: the worker reclaims runs whose
lease expired (`apps/worker/src/jobs/reclaimRuns.ts`) on boot and every minute,
so a crash or a redeploy mid-run resumes from the journal instead of leaving a
row `RUNNING` forever. After five failed restarts it becomes an `INCIDENT` for a
human, rather than looping.

## Smoke test (Phase 1)

1. Open http://localhost:3000 and sign in (dev-credentials accepts any email;
Expand Down Expand Up @@ -144,7 +174,7 @@ and `turbo run lint` skips packages that define no `lint` script — silently, a
with a green summary. Treat `typecheck` as the real static gate until the other
three packages have configs.

A full green run is **424 tests**. Roughly 90 of them are gated on
A full green run is **430 tests**. Roughly 90 of them are gated on
`Boolean(process.env.DATABASE_URL)` and **skip silently** without it — so a
green run with no database covers none of the execution engine. If the worker
suite reports 45 tests rather than 90, the database is not being reached.
Expand Down
6 changes: 6 additions & 0 deletions cloud/apps/web/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
.vercel
.env*

# Agent-skill docs auto-fetched by `vercel integration add` (Resend usage
# reference for AI assistants). Not application code; safe to re-fetch.
.agents/
.claude/
skills-lock.json
4 changes: 4 additions & 0 deletions cloud/apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Loads cloud/.env before Next reads anything. Next only looks for .env in its
// own project directory (apps/web), so the workspace-root file the README tells
// people to create was never picked up — see packages/core/src/env.ts.
import "@ghost/core/env";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
Expand Down
1 change: 1 addition & 0 deletions cloud/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"test": "vitest run"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.3",
"@ghost/core": "workspace:*",
"bullmq": "^5.34.4",
"clsx": "^2.1.1",
Expand Down
6 changes: 3 additions & 3 deletions cloud/apps/web/src/app/(app)/recordings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default async function RecordingsPage() {

return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-start justify-between">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">Recordings</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Expand Down Expand Up @@ -61,8 +61,8 @@ export default async function RecordingsPage() {
<div className="space-y-2">
{recordings.map((r) => (
<Card key={r.id}>
<CardBody className="flex items-center justify-between gap-4">
<div className="min-w-0">
<CardBody className="flex flex-wrap items-center justify-between gap-4">
<div className="max-w-md truncate">
<Link href={`/recordings/${r.id}`} className="text-sm font-medium hover:underline">
{r.rawTraceFilename ?? r.id}
</Link>
Expand Down
10 changes: 5 additions & 5 deletions cloud/apps/web/src/app/(app)/workflows/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default async function WorkflowsPage() {

return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-start justify-between">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">Workflows</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Expand Down Expand Up @@ -57,18 +57,18 @@ export default async function WorkflowsPage() {
<div className="space-y-2">
{workflows.map((w) => (
<Card key={w.id}>
<CardBody className="flex items-center justify-between gap-4">
<div className="min-w-0">
<CardBody className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
<div>
<Link href={`/workflows/${w.id}`} className="text-sm font-medium hover:underline">
{w.name}
</Link>
{w.description && (
<div className="truncate text-sm text-[var(--color-muted)]">
<div className="max-w-md truncate text-sm text-[var(--color-muted)]">
{w.description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="flex flex-wrap shrink-0 items-center gap-3">
<span className="text-xs text-[var(--color-muted)]">
{w._count.versions} version{w._count.versions === 1 ? "" : "s"}
</span>
Expand Down
44 changes: 37 additions & 7 deletions cloud/apps/web/src/app/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import { SOURCE_URL } from "@/lib/source-url";

const githubEnabled = Boolean(process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET);
const googleEnabled = Boolean(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
const resendEnabled = Boolean(process.env.RESEND_API_KEY && process.env.RESEND_EMAIL_DOMAIN);
const devEnabled = process.env.NODE_ENV !== "production";
// All three false means production with no OAuth provider configured: no
// All four false means production with no sign-in method configured: no
// form below has anything to render, and a card with a title and no buttons
// looks like a bug rather than a missing deploy step. Whoever hits this is
// more likely to be the person standing up the deployment than an end user,
// so name the exact fix rather than failing silently. See docs/DEPLOY.md's
// "sign-in trap".
const misconfigured = !githubEnabled && !googleEnabled && !devEnabled;
const misconfigured = !githubEnabled && !googleEnabled && !resendEnabled && !devEnabled;

export default async function SignInPage({
searchParams,
Expand Down Expand Up @@ -44,11 +45,11 @@ export default async function SignInPage({
No sign-in method is configured
</p>
<p className="text-[var(--color-muted)]">
This deployment has <code>NODE_ENV=production</code> and no OAuth app
configured, so there is no way to sign in. Set either{" "}
<code>AUTH_GITHUB_ID</code>/<code>AUTH_GITHUB_SECRET</code> or{" "}
<code>AUTH_GOOGLE_ID</code>/<code>AUTH_GOOGLE_SECRET</code>, with the
app&apos;s callback at{" "}
This deployment has <code>NODE_ENV=production</code> and no sign-in method
configured. Set <code>AUTH_GITHUB_ID</code>/<code>AUTH_GITHUB_SECRET</code>,{" "}
<code>AUTH_GOOGLE_ID</code>/<code>AUTH_GOOGLE_SECRET</code>, or{" "}
<code>RESEND_API_KEY</code>/<code>RESEND_EMAIL_DOMAIN</code> for email
magic links. OAuth apps need their callback at{" "}
<code>https://&lt;this-domain&gt;/api/auth/callback/&lt;github|google&gt;</code>.
See docs/DEPLOY.md.
</p>
Expand Down Expand Up @@ -81,6 +82,35 @@ export default async function SignInPage({
</form>
)}

{resendEnabled && (
<form
action={async (formData: FormData) => {
"use server";
await signIn("resend", {
email: String(formData.get("email") ?? ""),
redirectTo,
});
}}
className="space-y-3"
>
<label className="block space-y-1">
<span className="text-xs font-medium text-[var(--color-muted)]">
Email me a sign-in link
</span>
<input
name="email"
type="email"
required
placeholder="you@example.com"
className="h-10 w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 text-sm outline-none focus-visible:border-[var(--color-accent)]"
/>
</label>
<Button type="submit" variant="secondary" className="w-full">
Continue with email
</Button>
</form>
)}

{devEnabled && (
<form
action={async (formData: FormData) => {
Expand Down
Loading
Loading