Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,30 @@ with an explicit, keyboard-accessible Show more / Show fewer affordance. Asks
no longer expands without limit, and Run recovery no longer hides additional
cards in a clipped nested scroll area. Responsive Playwright coverage now
guards both the dashboard rail fill and the attention overflow contract.
## 2026-07-20 — AXI-138 faster and safer local iteration

Scoped Turbopack to the Forge repository root so unrelated parent lockfiles no
longer expand module resolution or file watching in workstation and Codex
worktrees. Prisma query output is now quiet by default and opt-in through
`FORGE_LOG_PRISMA_QUERIES=1`, preserving warnings and errors without flooding
the terminal on production-sized local snapshots. Restoring the frozen pnpm
dependency tree repaired the reported `nodemailer` resolution failure.

Local CI now owns a disposable `forge_test` database and Redis database 13,
guarded by exact local-target validation and a machine-local quality lock. Each
quality run resets and migrates only that test state; it never executes the
unit/integration suite against the shared `forge` development database. This
was verified against a copied production snapshot whose legitimate legacy pin
previously collided with a static test fixture.

Focused workflow, target-guard, and logging tests passed 17/17. Typecheck and
lint passed with existing warnings. The complete guarded quality gate passed
all 1,512 Vitest tests with one intentional skip against `forge_test`. Cold
route compilation remains expected in Next development mode; warm sign-in
requests measured 0.28–0.35 seconds with no Prisma query-log lines or
Turbopack workspace-root warning. The isolated production build passed and the
split E2E-only gate passed 56 Playwright tests with one intentional scenario
skip, without repeating the quality phase.

## 2026-07-20 — AXI-137 delivery provenance and lifecycle truth

Expand Down
38 changes: 32 additions & 6 deletions docs/guide/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ Every command prints what it started, skipped, migrated, generated, seeded,
or replaced plus the selected local database and endpoints. The TypeScript
orchestrator works when invoked from Windows PowerShell or Git Bash.

Sign in with the stable bootstrap credentials it prints:

Sign in with the bootstrap credentials it prints:

```
Expand All @@ -36,6 +34,26 @@ owner@forge.local / forge-dev

(Override via `ADMIN_EMAIL` / `ADMIN_PASSWORD` env vars before running.)

The first visit to a route is slower than later refreshes because Next compiles
that route on demand in development. Forge pins Turbopack to the repository
root so unrelated lockfiles elsewhere on the workstation do not expand module
resolution or file watching. The `.next` cache is preserved between normal
restarts; avoid deleting it unless you are diagnosing a corrupt build cache.

Prisma query text is quiet by default so a production-sized local snapshot
does not flood the terminal. Enable it for a focused database investigation:

```bash
FORGE_LOG_PRISMA_QUERIES=1 pnpm dev:app
```

In PowerShell, use
`$env:FORGE_LOG_PRISMA_QUERIES = "1"; pnpm dev:app` and remove the variable
afterward. When iterating on a copied production snapshot, keep background
workers disabled with `FORGE_DISABLE_IN_PROCESS_WORKER=1`; this preserves
realtime browser updates while preventing local maintenance jobs from acting
on cloned production state.

::: tip
The base seed (`prisma/seed.ts`) is deliberately simple and idempotent.
Re-running `pnpm dev` on a populated database skips it; use `--fresh`
Expand Down Expand Up @@ -178,10 +196,18 @@ Use `pnpm ci:local:quality` during implementation and run the complete gate once
before release readiness. `pnpm ci:local:e2e` runs only Playwright when a
focused E2E repeat is needed. The cross-platform runner verifies or starts the
guarded local services, supplies the known local endpoints even in an isolated
worktree, serializes E2E runs with a machine-local lock, selects an available
port, resets only the dedicated `forge_e2e` database, and starts a fresh server. Pass
`pnpm ci:local:e2e -- --reuse-e2e-build` only when `.next` was built from the
current source. Then append a line to `DEVLOG.md` and commit.
worktree, and resets the disposable `forge_test` database plus Redis database
13 before unit/integration tests. A machine-local quality lock prevents two
worktrees from resetting that disposable state concurrently. The gate never
runs those tests against the shared `forge` development database, so an
imported production snapshot remains available for UI iteration. Playwright
separately owns disposable `forge_e2e`.
On Windows, stop a dev server running from the same worktree before the gate so
Prisma can replace its generated engine DLL; restart with `pnpm dev:app` after.
The E2E phase serializes runs with a machine-local lock, selects an available
port, resets only the dedicated `forge_e2e` database, and starts a fresh server.
Pass `pnpm ci:local:e2e -- --reuse-e2e-build` only when `.next` was built from
the current source. Then append a line to `DEVLOG.md` and commit.

## Measuring issue UI load

Expand Down
9 changes: 9 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { NextConfig } from "next";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";

const projectRoot = dirname(fileURLToPath(import.meta.url));

const isolatedBuild = !!process.env.NEXT_DIST_DIR && process.env.NEXT_DIST_DIR !== ".next";
const devOriginHosts = Array.from(
Expand All @@ -20,6 +24,11 @@ const devOriginHosts = Array.from(
const config: NextConfig = {
reactStrictMode: true,
poweredByHeader: false,
// Multiple lockfiles may exist above a Codex worktree on developer
// machines. Keep Turbopack resolution and file watching scoped to Forge.
turbopack: {
root: projectRoot,
},
allowedDevOrigins: devOriginHosts.length > 0 ? devOriginHosts : undefined,
// `standalone` is for the prod Docker image. The E2E build (NEXT_DIST_DIR=
// .next-e2e) is served with plain `next start`, which doesn't support
Expand Down
21 changes: 17 additions & 4 deletions scripts/ci-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type LocalCiStep,
} from "./lib/ci-local-workflow";
import { LOCAL_DEV, assertSafeLocalEnvironment, parseWindowsPnpmEntry } from "./lib/dev-workflow";
import { LOCAL_TEST_DATABASE_URL } from "./lib/local-data-target";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");

Expand All @@ -33,8 +34,8 @@ function formatStep(step: LocalCiStep): string {
}

const localCiEnv: Record<string, string> = {
DATABASE_URL: LOCAL_DEV.databaseUrl,
REDIS_URL: LOCAL_DEV.redisUrl,
DATABASE_URL: LOCAL_TEST_DATABASE_URL,
REDIS_URL: "redis://localhost:56379/13",
S3_ENDPOINT: LOCAL_DEV.s3Endpoint,
S3_PUBLIC_ENDPOINT: LOCAL_DEV.s3Endpoint,
S3_REGION: process.env.S3_REGION ?? "us-east-1",
Expand Down Expand Up @@ -89,8 +90,20 @@ async function main(): Promise<void> {
const quality = buildLocalCiPlan(options).filter(
(candidate) => candidate.label !== "Playwright E2E",
);
for (const step of quality) {
runStep(step, pnpm);
if (quality.length > 0) {
const qualityLockPath = join(tmpdir(), "forge-quality.lock");
const releaseQualityLock = await acquireDirectoryLock(qualityLockPath, {
onWait: (owner) => {
const detail = owner ? ` (PID ${owner.pid} on ${owner.host})` : "";
console.log(`[ci:local] Waiting for another Forge quality run${detail}…`);
},
});
try {
console.log("[ci:local] Acquired quality lock for disposable test state.");
for (const step of quality) runStep(step, pnpm);
} finally {
await releaseQualityLock();
}
}
if (!needsE2e) return;

Expand Down
18 changes: 17 additions & 1 deletion scripts/lib/ci-local-workflow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { hostname } from "node:os";
import { LOCAL_DATABASE_URL } from "./local-data-target";

export type LocalCiMode = "quality" | "full" | "e2e-only";

Expand Down Expand Up @@ -51,7 +52,22 @@ export function parseLocalCiOptions(argv: string[]): LocalCiOptions {

export function buildLocalCiPlan(options: LocalCiOptions, e2ePort = 3200): LocalCiStep[] {
const quality: LocalCiStep[] = [
{ label: "Ensure local services", args: ["dev:services"] },
{
label: "Ensure local services",
args: ["dev:services"],
env: {
DATABASE_URL: LOCAL_DATABASE_URL,
REDIS_URL: "redis://localhost:56379",
},
},
{
label: "Reset disposable test database",
args: ["exec", "tsx", "scripts/reset-test-db.ts"],
},
{
label: "Apply test database migrations",
args: ["exec", "prisma", "migrate", "deploy"],
},
{ label: "Generate Prisma client", args: ["prisma:generate"] },
{ label: "Lint", args: ["lint"] },
{ label: "Typecheck", args: ["typecheck"] },
Expand Down
26 changes: 26 additions & 0 deletions scripts/lib/local-data-target.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fileURLToPath } from "node:url";

export const LOCAL_DATABASE_URL = "postgresql://forge:forge@localhost:55432/forge?schema=public";
export const LOCAL_TEST_DATABASE_URL =
"postgresql://forge:forge@localhost:55432/forge_test?schema=public";
export const LOCAL_DATABASE_CONTAINER = "forge-dev-postgres";

export type LocalTarget = {
Expand Down Expand Up @@ -72,6 +74,30 @@ export function validateLocalScenarioTarget(databaseUrl: string): void {
}
}

export function validateLocalTestTarget(databaseUrl: string): LocalTarget {
const url = new URL(databaseUrl);
const target = {
host: url.hostname,
port: url.port,
database: url.pathname.replace(/^\//, ""),
user: decodeURIComponent(url.username),
schema: url.searchParams.get("schema") ?? "",
container: LOCAL_DATABASE_CONTAINER,
};
const valid =
url.protocol === "postgresql:" &&
target.host === "localhost" &&
target.port === "55432" &&
target.database === "forge_test" &&
target.user === "forge" &&
decodeURIComponent(url.password) === "forge" &&
target.schema === "public";
if (!valid) {
throw new Error("Local CI requires the exact disposable forge_test database target");
}
return target;
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
console.log(formatLocalTarget(validateLocalTarget(process.argv[2] ?? "")));
Expand Down
36 changes: 36 additions & 0 deletions scripts/reset-test-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { spawnSync } from "node:child_process";
import {
formatLocalTarget,
LOCAL_TEST_DATABASE_URL,
validateLocalTestTarget,
} from "./lib/local-data-target";

const databaseUrl = process.env.DATABASE_URL ?? LOCAL_TEST_DATABASE_URL;
const target = validateLocalTestTarget(databaseUrl);

function docker(args: string[]) {
const result = spawnSync("docker", args, { encoding: "utf8", stdio: "inherit", shell: false });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`docker failed with exit ${result.status}`);
}

console.log(`[test-db] Resetting disposable target: ${formatLocalTarget(target)}`);
docker([
"exec",
target.container,
"psql",
"-U",
target.user,
"-d",
"forge",
"-v",
"ON_ERROR_STOP=1",
"-c",
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'forge_test' AND pid <> pg_backend_pid();",
]);
docker(["exec", target.container, "dropdb", "-U", target.user, "--if-exists", target.database]);
docker(["exec", target.container, "createdb", "-U", target.user, target.database]);
docker(["exec", "forge-dev-redis", "redis-cli", "-n", "13", "FLUSHDB"]);
console.log(
"[test-db] Disposable forge_test database and Redis DB 13 are empty and ready for tests.",
);
7 changes: 7 additions & 0 deletions src/server/db-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type ForgePrismaLogLevel = "query" | "warn" | "error";

export function prismaLogLevels(env: NodeJS.ProcessEnv): ForgePrismaLogLevel[] {
if (env.FORGE_LOG_PRISMA_QUERIES === "1") return ["query", "warn", "error"];
if (env.NODE_ENV === "development") return ["warn", "error"];
return ["error"];
}
3 changes: 2 additions & 1 deletion src/server/db.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import "server-only";
import { PrismaClient } from "@prisma/client";
import { prismaLogLevels } from "./db-log";

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };

export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "warn", "error"] : ["error"],
log: prismaLogLevels(process.env),
});

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
8 changes: 5 additions & 3 deletions tests/unit/ci-local-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ describe("local CI workflow", () => {
expect(() => parseLocalCiOptions(["--quick"])).toThrow(/Unknown/);
});

it("generates Prisma before one quality pass and adds E2E only for full mode", () => {
it("resets a dedicated test database before one quality pass and adds E2E only for full mode", () => {
const quality = buildLocalCiPlan(parseLocalCiOptions(["--quality"]));
expect(quality.map((step) => step.label)).toEqual([
"Ensure local services",
"Reset disposable test database",
"Apply test database migrations",
"Generate Prisma client",
"Lint",
"Typecheck",
Expand All @@ -43,8 +45,8 @@ describe("local CI workflow", () => {
expect(quality.filter((step) => step.label === "Unit and integration tests")).toHaveLength(1);

const full = buildLocalCiPlan(parseLocalCiOptions([]), 3277);
expect(full).toHaveLength(6);
expect(full[5]).toMatchObject({
expect(full).toHaveLength(8);
expect(full[7]).toMatchObject({
label: "Playwright E2E",
env: {
E2E_PORT: "3277",
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/db-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { prismaLogLevels } from "@/server/db-log";

describe("prismaLogLevels", () => {
it("keeps normal development output concise", () => {
expect(prismaLogLevels({ NODE_ENV: "development" })).toEqual(["warn", "error"]);
});

it("enables query diagnostics explicitly", () => {
expect(prismaLogLevels({ NODE_ENV: "development", FORGE_LOG_PRISMA_QUERIES: "1" })).toEqual([
"query",
"warn",
"error",
]);
});

it("logs only errors in production", () => {
expect(prismaLogLevels({ NODE_ENV: "production" })).toEqual(["error"]);
});
});
15 changes: 15 additions & 0 deletions tests/unit/local-data-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
import {
LOCAL_DATABASE_CONTAINER,
LOCAL_DATABASE_URL,
LOCAL_TEST_DATABASE_URL,
formatLocalTarget,
validateLocalScenarioTarget,
validateLocalTarget,
validateLocalTestTarget,
} from "../../scripts/lib/local-data-target";

describe("local production-data refresh target guard", () => {
Expand Down Expand Up @@ -49,4 +51,17 @@ describe("local production-data refresh target guard", () => {
validateLocalScenarioTarget("postgresql://forge:forge@localhost:55432/forge?schema=private"),
).toThrow(/exact Forge local docker/);
});

it("accepts only the disposable local CI database", () => {
expect(validateLocalTestTarget(LOCAL_TEST_DATABASE_URL)).toMatchObject({
database: "forge_test",
container: LOCAL_DATABASE_CONTAINER,
});
expect(() => validateLocalTestTarget(LOCAL_DATABASE_URL)).toThrow(/forge_test/);
expect(() =>
validateLocalTestTarget(
"postgresql://forge:forge@forge.axiom-labs.dev:5432/forge_test?schema=public",
),
).toThrow(/forge_test/);
});
});
Loading