# Phase 2: Git Webhook + SSE Realtime — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add realtime updates to Project-Manager via git webhook (commit/push from each repo POSTs to PM) and SSE (Server-Sent Events) push channel from PM → connected browsers.

**Architecture:** Webhook endpoint receives signed POST → updates Project + inserts CommitLog → broadcasts SSE event. UI subscribes to `/api/sse` and re-fetches affected project on event. In-memory event bus (single PM instance — fine for our scale).

**Tech Stack:** Reuse Phase 1 stack. New: `EventEmitter` (Node built-in) for in-memory pub/sub. `bash` script installs git hook into existing repos.

**Working Artifact After Phase 2:**
- POST `/api/webhook/git` accepts payload + token, updates DB, fires SSE event
- GET `/api/sse` streams events to connected clients
- Dashboard auto-refreshes when commit lands
- Project detail page shows last 20 commits in a feed
- Bash installer copies `.git/hooks/post-commit` + `post-receive` into each scanned repo

---

## File Structure

```
project-manager/
├── prisma/schema.prisma                     # MODIFY: add CommitLog model
├── prisma/migrations/<ts>_phase2_commitlog/
├── src/
│   ├── lib/
│   │   ├── events/bus.ts                    # CREATE: in-process EventEmitter pub/sub
│   ├── app/
│   │   ├── api/
│   │   │   ├── sse/route.ts                 # CREATE: GET stream
│   │   │   └── webhook/git/route.ts         # CREATE: POST receive
│   │   ├── page.tsx                         # MODIFY: subscribe to SSE
│   │   └── projects/[id]/page.tsx           # MODIFY: render CommitLog list
│   └── components/
│       ├── live-refresh.tsx                 # CREATE: client component subscribing SSE
│       └── commit-log.tsx                   # CREATE: render commit rows
├── scripts/
│   └── install-git-hook.sh                  # CREATE: bash installer
└── tests/
    ├── unit/events-bus.test.ts              # CREATE
    └── integration/webhook.api.test.ts      # CREATE
```

---

## Task 1: Prisma migration — CommitLog model

**Files:**
- Modify: `prisma/schema.prisma`
- Create: migration

- [ ] **Step 1: Add CommitLog model + relation to Project**

Edit `prisma/schema.prisma`. Inside `model Project { ... }`, add this line before `createdAt`:
```prisma
  commits           CommitLog[]
```

Append at end of file:
```prisma
model CommitLog {
  id         String   @id @default(cuid())
  projectId  String
  project    Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
  hash       String
  message    String
  author     String?
  branch     String?
  timestamp  DateTime @default(now())
  @@index([projectId, timestamp])
}
```

- [ ] **Step 2: Generate migration**

```bash
npx prisma migrate dev --name phase2_commitlog
```

Expected: creates `prisma/migrations/<ts>_phase2_commitlog/migration.sql`, regenerates client.

- [ ] **Step 3: Verify migration applied**

```bash
sqlite3 prisma/dev.db ".schema CommitLog"
```

Expected: shows CREATE TABLE CommitLog with FK to Project.

- [ ] **Step 4: Commit**

```bash
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat(phase2): commitLog model + migration"
```

---

## Task 2: SSE Event Bus (TDD)

**Files:**
- Create: `src/lib/events/bus.ts`
- Test: `tests/unit/events-bus.test.ts`

- [ ] **Step 1: Write failing test**

```ts
// tests/unit/events-bus.test.ts
import { describe, it, expect } from "vitest";
import { eventBus, type PMEvent } from "@/lib/events/bus";

describe("eventBus", () => {
  it("delivers an emitted event to subscribers", async () => {
    const received: PMEvent[] = [];
    const unsub = eventBus.subscribe((e) => received.push(e));
    eventBus.emit({ type: "project.updated", projectId: "p1" });
    eventBus.emit({ type: "commit.new", projectId: "p1", hash: "abc" });
    unsub();
    expect(received).toEqual([
      { type: "project.updated", projectId: "p1" },
      { type: "commit.new", projectId: "p1", hash: "abc" },
    ]);
  });

  it("unsubscribed listener stops receiving events", () => {
    const received: PMEvent[] = [];
    const unsub = eventBus.subscribe((e) => received.push(e));
    unsub();
    eventBus.emit({ type: "project.updated", projectId: "p2" });
    expect(received).toHaveLength(0);
  });

  it("supports multiple concurrent subscribers", () => {
    const a: PMEvent[] = [];
    const b: PMEvent[] = [];
    const ua = eventBus.subscribe((e) => a.push(e));
    const ub = eventBus.subscribe((e) => b.push(e));
    eventBus.emit({ type: "project.updated", projectId: "p3" });
    ua(); ub();
    expect(a).toHaveLength(1);
    expect(b).toHaveLength(1);
  });
});
```

- [ ] **Step 2: Run test, verify FAIL**

```bash
npm test -- events-bus
```

- [ ] **Step 3: Write implementation**

```ts
// src/lib/events/bus.ts
import { EventEmitter } from "node:events";

export type PMEvent =
  | { type: "project.updated"; projectId: string }
  | { type: "commit.new"; projectId: string; hash: string }
  | { type: "scan.complete"; added: number; updated: number };

class Bus {
  private emitter = new EventEmitter();
  private static globalKey = Symbol.for("pm.eventBus.singleton");

  static instance(): Bus {
    const g = globalThis as unknown as Record<symbol, Bus>;
    if (!g[Bus.globalKey]) g[Bus.globalKey] = new Bus();
    return g[Bus.globalKey];
  }

  emit(event: PMEvent): void {
    this.emitter.emit("pm", event);
  }

  subscribe(handler: (event: PMEvent) => void): () => void {
    this.emitter.on("pm", handler);
    return () => this.emitter.off("pm", handler);
  }
}

export const eventBus = Bus.instance();
```

- [ ] **Step 4: Run test, verify PASS** — 3 passed.

```bash
npm test -- events-bus
```

- [ ] **Step 5: Commit**

```bash
git add src/lib/events/ tests/unit/events-bus.test.ts
git commit -m "feat(phase2): in-process event bus pub/sub"
```

---

## Task 3: SSE Endpoint `/api/sse`

**Files:**
- Create: `src/app/api/sse/route.ts`

- [ ] **Step 1: Write the route**

```ts
// src/app/api/sse/route.ts
import { eventBus, type PMEvent } from "@/lib/events/bus";
import { requireAuth } from "@/lib/auth/middleware";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const auth = await requireAuth(req);
  if (!auth.ok) return new Response("unauthorized", { status: auth.status });

  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();
      const send = (event: PMEvent) => {
        controller.enqueue(encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`));
      };
      const heartbeat = setInterval(() => {
        controller.enqueue(encoder.encode(`: heartbeat\n\n`));
      }, 25_000);
      const unsub = eventBus.subscribe(send);
      controller.enqueue(encoder.encode(`event: hello\ndata: {"ok":true}\n\n`));

      // Close handling
      const abort = () => {
        clearInterval(heartbeat);
        unsub();
        try { controller.close(); } catch { /* already closed */ }
      };
      req.signal.addEventListener("abort", abort);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  });
}
```

- [ ] **Step 2: Smoke build**

```bash
npm run build 2>&1 | tail -10
```

Expected: route `/api/sse` appears in output, build succeeds.

- [ ] **Step 3: Commit**

```bash
git add src/app/api/sse/
git commit -m "feat(phase2): SSE endpoint for realtime updates"
```

---

## Task 4: Webhook Endpoint (TDD)

**Files:**
- Create: `src/app/api/webhook/git/route.ts`
- Test: `tests/integration/webhook.api.test.ts`

- [ ] **Step 1: Write FAILING test**

```ts
// tests/integration/webhook.api.test.ts
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { POST } from "@/app/api/webhook/git/route";
import { prisma } from "@/lib/db";
import { eventBus, type PMEvent } from "@/lib/events/bus";

const TOKEN = "test-webhook-token-xyz";

beforeAll(() => {
  process.env.PM_WEBHOOK_TOKEN = TOKEN;
});

afterEach(async () => {
  await prisma.commitLog.deleteMany({});
  await prisma.project.deleteMany({});
});

afterAll(async () => {
  await prisma.$disconnect();
});

function req(body: unknown, token = TOKEN): Request {
  return new Request("http://localhost/api/webhook/git", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-PM-Token": token },
    body: JSON.stringify(body),
  });
}

describe("POST /api/webhook/git", () => {
  it("rejects with 401 when token missing", async () => {
    const r = await POST(new Request("http://localhost/api/webhook/git", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    }));
    expect(r.status).toBe(401);
  });

  it("rejects with 401 on wrong token", async () => {
    const r = await POST(req({ project: "x", hash: "y", message: "z", branch: "main" }, "bad"));
    expect(r.status).toBe(401);
  });

  it("returns 404 if project name not found", async () => {
    const r = await POST(req({ project: "ghost", hash: "0".repeat(40), message: "m", branch: "main" }));
    expect(r.status).toBe(404);
  });

  it("updates project + inserts CommitLog + emits event when valid", async () => {
    const p = await prisma.project.create({
      data: { name: "alpha", path: "/tmp/alpha", source: "manual" },
    });
    const events: PMEvent[] = [];
    const unsub = eventBus.subscribe((e) => events.push(e));

    const r = await POST(req({
      project: "alpha",
      hash: "a".repeat(40),
      message: "feat: hello",
      branch: "main",
      author: "tester",
    }));

    unsub();
    expect(r.status).toBe(200);
    const body = await r.json();
    expect(body.ok).toBe(true);

    const updated = await prisma.project.findUnique({ where: { id: p.id } });
    expect(updated?.lastCommitHash).toBe("a".repeat(40));
    expect(updated?.lastCommitMessage).toBe("feat: hello");
    expect(updated?.branch).toBe("main");

    const commits = await prisma.commitLog.findMany({ where: { projectId: p.id } });
    expect(commits).toHaveLength(1);
    expect(commits[0].author).toBe("tester");

    const types = events.map((e) => e.type);
    expect(types).toContain("commit.new");
    expect(types).toContain("project.updated");
  });
});
```

- [ ] **Step 2: Run test, verify FAIL**

```bash
npm test -- webhook.api
```

- [ ] **Step 3: Write implementation**

```ts
// src/app/api/webhook/git/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { eventBus } from "@/lib/events/bus";

const Body = z.object({
  project: z.string().min(1),
  hash: z.string().regex(/^[0-9a-f]{7,40}$/),
  message: z.string().default(""),
  branch: z.string().optional().nullable(),
  author: z.string().optional().nullable(),
});

export async function POST(req: Request) {
  const expected = process.env.PM_WEBHOOK_TOKEN;
  const provided = req.headers.get("x-pm-token");
  if (!expected || !provided || provided !== expected) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }

  let parsed;
  try {
    parsed = Body.safeParse(await req.json());
  } catch {
    return NextResponse.json({ error: "invalid_json" }, { status: 400 });
  }
  if (!parsed.success) {
    return NextResponse.json({ error: "invalid_body", details: parsed.error.format() }, { status: 400 });
  }
  const { project, hash, message, branch, author } = parsed.data;

  const proj = await prisma.project.findUnique({ where: { name: project } });
  if (!proj) return NextResponse.json({ error: "project_not_found" }, { status: 404 });

  await prisma.project.update({
    where: { id: proj.id },
    data: {
      lastCommitHash: hash,
      lastCommitMessage: message,
      lastCommitTime: new Date(),
      branch: branch ?? proj.branch,
    },
  });

  await prisma.commitLog.create({
    data: { projectId: proj.id, hash, message, author: author ?? null, branch: branch ?? null },
  });

  eventBus.emit({ type: "commit.new", projectId: proj.id, hash });
  eventBus.emit({ type: "project.updated", projectId: proj.id });

  return NextResponse.json({ ok: true });
}
```

- [ ] **Step 4: Add `/api/webhook` to PUBLIC_PATHS in `src/middleware.ts`**

Edit `src/middleware.ts`. Find:
```ts
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/logout", "/_next", "/favicon.ico"];
```
Replace with:
```ts
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/logout", "/api/webhook", "/_next", "/favicon.ico"];
```

(Webhook authenticates via `X-PM-Token` header, not cookie.)

- [ ] **Step 5: Run test, verify PASS** — 4 passed.

```bash
npm test -- webhook.api
```

- [ ] **Step 6: Commit**

```bash
git add src/app/api/webhook/ src/middleware.ts tests/integration/webhook.api.test.ts
git commit -m "feat(phase2): git webhook receiver + event emit"
```

---

## Task 5: Git Hook Installer Script

**Files:**
- Create: `scripts/install-git-hook.sh`

- [ ] **Step 1: Write installer**

```bash
#!/usr/bin/env bash
# scripts/install-git-hook.sh
# Usage: ./scripts/install-git-hook.sh <repo-path>
# Installs post-commit + post-receive hooks that POST to PM webhook.

set -euo pipefail

REPO="${1:-}"
if [[ -z "$REPO" || ! -d "$REPO/.git" ]]; then
  echo "Usage: $0 <repo-path-with-.git>"
  exit 1
fi

PM_URL="${PM_URL:-http://192.168.40.20:3000}"
TOKEN="${PM_WEBHOOK_TOKEN:-}"

if [[ -z "$TOKEN" ]]; then
  echo "PM_WEBHOOK_TOKEN env var required"
  exit 1
fi

HOOK_BODY=$(cat <<EOF
#!/usr/bin/env bash
# Project-Manager webhook (auto-generated)
PM_URL="$PM_URL"
TOKEN="$TOKEN"
NAME="\$(basename "\$(git rev-parse --show-toplevel)")"
HASH="\$(git rev-parse HEAD)"
MSG="\$(git log -1 --pretty=%s | head -c 200 | tr -d '"')"
BRANCH="\$(git branch --show-current)"
AUTHOR="\$(git log -1 --pretty=%an | tr -d '"')"
curl -s -m 5 -X POST "\$PM_URL/api/webhook/git" \\
  -H "Content-Type: application/json" \\
  -H "X-PM-Token: \$TOKEN" \\
  -d "{\\"project\\":\\"\$NAME\\",\\"hash\\":\\"\$HASH\\",\\"message\\":\\"\$MSG\\",\\"branch\\":\\"\$BRANCH\\",\\"author\\":\\"\$AUTHOR\\"}" \\
  > /dev/null 2>&1 || true
EOF
)

for HOOK in post-commit post-receive; do
  HOOK_PATH="$REPO/.git/hooks/$HOOK"
  echo "$HOOK_BODY" > "$HOOK_PATH"
  chmod +x "$HOOK_PATH"
  echo "installed: $HOOK_PATH"
done

echo "Done. Test: cd $REPO && git commit --allow-empty -m 'hook test'"
```

- [ ] **Step 2: Add npm script and make executable**

```bash
chmod +x scripts/install-git-hook.sh
```

Update `package.json` scripts: add `"install:hook": "scripts/install-git-hook.sh"`

- [ ] **Step 3: Smoke test on the project itself**

```bash
PM_URL=http://localhost:3000 PM_WEBHOOK_TOKEN=$(grep PM_WEBHOOK_TOKEN .env | cut -d'"' -f2) \
  ./scripts/install-git-hook.sh .
ls -la .git/hooks/post-commit
```

Expected: file exists, executable, contains the project name.

- [ ] **Step 4: Commit**

```bash
git add scripts/install-git-hook.sh package.json
git commit -m "feat(phase2): git hook installer for webhook"
```

---

## Task 6: Dashboard SSE Subscription

**Files:**
- Create: `src/components/live-refresh.tsx`
- Modify: `src/app/page.tsx`

- [ ] **Step 1: Write `src/components/live-refresh.tsx`**

```tsx
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";

export function LiveRefresh() {
  const router = useRouter();
  useEffect(() => {
    const es = new EventSource("/api/sse");
    es.addEventListener("project.updated", () => router.refresh());
    es.addEventListener("commit.new", () => router.refresh());
    es.addEventListener("scan.complete", () => router.refresh());
    es.onerror = () => {
      // EventSource auto-reconnects; nothing to do
    };
    return () => es.close();
  }, [router]);
  return null;
}
```

- [ ] **Step 2: Mount it in `src/app/page.tsx`**

Edit `src/app/page.tsx`. After the `<Nav />` line, add:
```tsx
<LiveRefresh />
```
And import at top:
```tsx
import { LiveRefresh } from "@/components/live-refresh";
```

- [ ] **Step 3: Also emit `scan.complete` from scan API**

Edit `src/app/api/scan/route.ts`. After the for-loop, before `return NextResponse.json(...)`, add:
```ts
const { eventBus } = await import("@/lib/events/bus");
eventBus.emit({ type: "scan.complete", added, updated });
```

- [ ] **Step 4: Build smoke**

```bash
npm run build 2>&1 | tail -10
```

- [ ] **Step 5: Commit**

```bash
git add src/components/live-refresh.tsx src/app/page.tsx src/app/api/scan/route.ts
git commit -m "feat(phase2): dashboard live refresh via SSE"
```

---

## Task 7: Project Detail — Commits Section

**Files:**
- Create: `src/components/commit-log.tsx`
- Modify: `src/app/projects/[id]/page.tsx`

- [ ] **Step 1: Write `src/components/commit-log.tsx`**

```tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export interface CommitRow {
  id: string;
  hash: string;
  message: string;
  author: string | null;
  branch: string | null;
  timestamp: Date;
}

export function CommitLogList({ commits }: { commits: CommitRow[] }) {
  if (commits.length === 0) {
    return (
      <Card>
        <CardHeader><CardTitle>Commits</CardTitle></CardHeader>
        <CardContent className="text-sm text-muted-foreground">No commits received yet. Install the git hook in this repo.</CardContent>
      </Card>
    );
  }
  return (
    <Card>
      <CardHeader><CardTitle>Recent commits ({commits.length})</CardTitle></CardHeader>
      <CardContent className="space-y-2 text-sm">
        {commits.map((c) => (
          <div key={c.id} className="border-l-2 border-border pl-3 py-1">
            <div className="flex items-center gap-2 text-xs text-muted-foreground">
              <span className="font-mono">{c.hash.slice(0, 8)}</span>
              {c.branch && <span className="rounded bg-secondary px-1.5">{c.branch}</span>}
              {c.author && <span>by {c.author}</span>}
              <span className="ml-auto">{new Date(c.timestamp).toISOString().slice(0, 19).replace("T", " ")}</span>
            </div>
            <div className="font-medium">{c.message}</div>
          </div>
        ))}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 2: Modify `src/app/projects/[id]/page.tsx`**

Add commits fetch + render. After `const project = await prisma.project.findUnique(...)` line, add:
```ts
const commits = await prisma.commitLog.findMany({
  where: { projectId: id },
  orderBy: { timestamp: "desc" },
  take: 20,
});
```

Inside the JSX, after the existing `<Card>`, add (before closing `</main>`):
```tsx
<CommitLogList commits={commits} />
```

Add import at top:
```tsx
import { CommitLogList } from "@/components/commit-log";
```

- [ ] **Step 3: Add LiveRefresh to detail page too**

Same as dashboard — import + mount under `<Nav />`.

- [ ] **Step 4: Build smoke**

```bash
npm run build 2>&1 | tail -10
```

- [ ] **Step 5: Commit**

```bash
git add src/components/commit-log.tsx src/app/projects/
git commit -m "feat(phase2): commit log on project detail + live refresh"
```

---

## Task 8: Deploy Phase 2 to LXC + verify

- [ ] **Step 1: Run full test suite**

```bash
npm test 2>&1 | tail -10
npm run build 2>&1 | tail -10
```

Expected: all pass, build clean.

- [ ] **Step 2: Tag**

```bash
git tag phase-2-webhook-sse
git log --oneline | head -10
```

- [ ] **Step 3: Build new tarball + deploy**

```bash
cd /data/video-pipeline/LXC4103/project
tar --exclude='node_modules' --exclude='.next' --exclude='prisma/dev.db*' \
    --exclude='prisma/test.db*' --exclude='.env' --exclude='tsconfig.tsbuildinfo' \
    --exclude='core.*' --exclude='playwright-report' --exclude='test-results' \
    -czf /tmp/pm-deploy-p2.tar.gz project-manager/
```

Then upload + extract on target via paramiko script (reuse pattern from Phase 1):
- `scp` tarball → `/tmp/pm-deploy-p2.tar.gz`
- `cd /opt && tar xzf /tmp/pm-deploy-p2.tar.gz` (overwrites in place, but preserves /opt/project-manager/.env, prisma/dev.db, node_modules)
- Note: tar will OVERWRITE files but won't delete extras. node_modules is preserved.
- `cd /opt/project-manager && npx prisma migrate deploy` (apply phase2 migration)
- `npm install` (in case deps changed; should be no-op for phase 2)
- `npm run build`
- `systemctl restart project-manager`

- [ ] **Step 4: Install git hook on the project itself (eat dogfood)**

On LXC:
```bash
cd /opt/project-manager
TOKEN=$(grep PM_WEBHOOK_TOKEN .env | cut -d'"' -f2)
PM_URL=http://localhost:3000 PM_WEBHOOK_TOKEN=$TOKEN ./scripts/install-git-hook.sh /data/video-pipeline/LXC4103/project/project-manager
```

- [ ] **Step 5: Verify webhook end-to-end**

```bash
cd /data/video-pipeline/LXC4103/project/project-manager
git commit --allow-empty -m "test: phase 2 webhook smoke"
sleep 2
sqlite3 /opt/project-manager/prisma/dev.db "SELECT hash, message FROM CommitLog ORDER BY timestamp DESC LIMIT 1;"
```

Expected: row appears.

- [ ] **Step 6: Verify SSE stream**

```bash
curl -N -s -H "Cookie: pm_session=<token>" http://localhost:3000/api/sse &
SSE_PID=$!
sleep 1
# in another shell trigger a webhook
git commit --allow-empty -m "test: trigger SSE"
sleep 2
kill $SSE_PID
```

Expected: stdout shows `event: hello`, then `event: commit.new ...`, etc.

- [ ] **Step 7: Smoke browser test**

Open browser http://192.168.40.20:3000/projects/<id>, then commit on the repo, observe page auto-refreshing.

---

## Self-Review

**Spec coverage:**
- ✅ Webhook endpoint with token auth (Task 4)
- ✅ Git hook installer (Task 5)
- ✅ SSE channel (Task 3, 6)
- ✅ CommitLog persistence (Task 1, 4)
- ✅ Realtime UI (Task 6, 7)

**Type consistency:** `PMEvent` discriminated union used in bus + SSE + UI. `CommitRow` matches Prisma `CommitLog` shape (Date type). Webhook body schema matches what installer.sh emits.

**Out of scope (Phase 3):** Docker registry, builder LXC, deploy targets, env vars manager.

---

## Execution

Subagent-driven, same pattern as Phase 1.
