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
13 changes: 10 additions & 3 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ jobs:
with:
node-version: "22"
cache: pnpm
- name: Install goal-gate search tools
run: |
sudo apt-get update
sudo apt-get install -y ripgrep
rg --version
- name: Restore goal gate cache
uses: actions/cache@v4
with:
Expand All @@ -84,10 +89,12 @@ jobs:
id: coverage
continue-on-error: true
# `exit ${PIPESTATUS[0]}` so the step reports the command's real exit
# code, not tee's 0 — otherwise a failure is masked and Verify is
# falsely green.
# code, not tee's 0. The full DB-backed coverage suite runs serially in
# Verify because parallel fork workers have emitted late GitHub runner
# exits after all tests passed, which hides the real coverage signal.
run: |
pnpm run test:coverage 2>&1 | tee verify.log
echo "Running full DB-backed coverage serially to avoid nondeterministic Vitest fork-worker exits on GitHub-hosted runners." | tee verify.log
VSPEC_VITEST_MAX_WORKERS=1 pnpm run test:coverage -- --no-file-parallelism 2>&1 | tee -a verify.log
exit "${PIPESTATUS[0]}"
- name: Run goal gate sweep
id: gates
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/world-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ jobs:
with:
node-version: "22"
cache: pnpm
- name: Install goal-gate search tools
run: |
sudo apt-get update
sudo apt-get install -y ripgrep
rg --version
- run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
run: pnpm --filter @vooster/app exec playwright install --with-deps chromium
- name: Install Vercel CLI
run: npm install -g vercel@latest
- name: Run world-state checks
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ apps/
integration/ # Adapter-level (DB, OAuth, filesystem)
unit/ # Pure domain and application logic
fixtures/ # Seed data, factory helpers
app/ # @vooster/app — Next.js 15 product web UI (read-first; browse + limited writes, on Vercel) — see docs/10-web-app.md
app/ # @vooster/app — Next.js 16 product web UI (read-first; browse + limited writes, on Vercel) — see docs/10-web-app.md
app/ # App Router routes — all Server Components
components/ui/ # shadcn primitives
lib/ # server-side data fetchers + label maps
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ COPY apps/api/package.json ./apps/api/package.json
COPY apps/api/prisma ./apps/api/prisma
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 8080
CMD ["sh", "-c", "pnpm exec prisma db push --schema apps/api/prisma/schema.prisma --skip-generate && node dist/apps/api/src/index.js"]
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"typecheck": "cd ../.. && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cors": "^11.3.0",
"@fastify/helmet": "^13.1.0",
"@vooster/contracts": "workspace:*"
}
}
7 changes: 4 additions & 3 deletions apps/api/src/application/ai-guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function guideSections(): AiGuideSection[] {
},
{
heading: "Existing use case edits",
body: "For an existing use case, start a pinned session, inspect step ids in `vspec usecase show <KEY-NNN> --format=agent`, and use `data.usecase.current_revision_id` as the `--base-revision` for `vspec step edit`. After every mutation, re-read the use case or use the returned `data.revision.id` as the next base revision. Use `vspec step add --at <n>` to insert a new step at a 1-based position, and `vspec step move <step-id> --to <n>` to reorder without changing wording. Extension points use labels such as `2a`, not plain step numbers."
body: "For an existing use case, start a pinned session, inspect step ids in `vspec usecase show <KEY-NNN> --format=agent`, and use `data.usecase.current_revision_id` as the `--base-revision` for `vspec step edit`. After every mutation, re-read the use case or use the returned `data.revision.id` as the next base revision. Use `vspec step add --at <n>` to insert a new step at a 1-based position; `vspec step add` appends by default when `--at` is omitted. Use `vspec step move <step-id> --to <n>` to reorder without changing wording. Extension points use labels such as `2a`, not plain step numbers."
},
{
heading: "The --format=agent payload contract",
Expand Down Expand Up @@ -195,8 +195,9 @@ Use the CLI path:
\`vspec step edit\`.
5. After each mutation, re-read the use case or use the returned
\`data.revision.id\` as the next base revision.
6. Use \`vspec step add --at <n>\` to insert a new step at a 1-based position,
and \`vspec step move <step-id> --to <n>\` to reorder without changing wording.
6. Use \`vspec step add --at <n>\` to insert a new step at a 1-based position;
\`vspec step add\` appends by default when \`--at\` is omitted. Use
\`vspec step move <step-id> --to <n>\` to reorder without changing wording.
7. Extension points use labels such as \`2a\`, not plain step numbers.

## The --format=agent payload contract
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/http/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Fastify, { type FastifyInstance } from "fastify";
import fastifyCors from "@fastify/cors";
import fastifyHelmet from "@fastify/helmet";
import { healthResponseSchema } from "@vooster/contracts";
import { createMemoryApiKeyStore } from "../infrastructure/memory-api-key-store.js";
import { createMemoryActorStore } from "../infrastructure/memory-actor-store.js";
Expand Down Expand Up @@ -63,6 +65,10 @@ import type { UserStore } from "../ports/user-store.js";
export async function createServer(options: ServerOptions): Promise<FastifyInstance> {
const serverOptions = withGithubOAuthFromEnv(options);
const app = Fastify({ logger: false });
await app.register(fastifyHelmet);
await app.register(fastifyCors, {
origin: allowedCorsOriginsFromEnv()
});
const state = initialState();
const apiKeyStore = serverOptions.signupStore ?? createMemoryApiKeyStore();
const actorStore = serverOptions.signupStore ?? createMemoryActorStore();
Expand Down Expand Up @@ -425,6 +431,14 @@ export async function createServer(options: ServerOptions): Promise<FastifyInsta
return app;
}

function allowedCorsOriginsFromEnv(): false | string[] {
const origins = process.env.VSPEC_ALLOWED_ORIGINS?.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);

return origins && origins.length > 0 ? origins : false;
}

function initialState(): SignupState {
const state: SignupState = {
pendingOAuth: new Map(),
Expand Down
71 changes: 54 additions & 17 deletions apps/api/tests/helpers/postgres-db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createConnection } from "node:net";
import path from "node:path";
import { promisify } from "node:util";

Expand All @@ -18,25 +19,23 @@ export type TestDatabase = {
export async function withTestDatabase(): Promise<TestDatabase> {
const schema = `test_${randomUUID().replaceAll("-", "_")}`;
const databaseUrl = databaseUrlForSchema(schema);
await assertPostgresReachable(new URL(databaseUrl));
const pnpm = pnpmCommand([
"exec",
"prisma",
"db",
"push",
"--schema",
"apps/api/prisma/schema.prisma",
"--skip-generate"
]);

// Keep this command aligned with the goal gate's relocated Prisma schema.
await execFileAsync(
"pnpm",
[
"exec",
"prisma",
"db",
"push",
"--schema",
"apps/api/prisma/schema.prisma",
"--skip-generate"
],
{
cwd: root,
env: { ...process.env, DATABASE_URL: databaseUrl },
maxBuffer: 10 * 1024 * 1024
}
);
await execFileAsync(pnpm.command, pnpm.args, {
cwd: root,
env: { ...process.env, DATABASE_URL: databaseUrl },
maxBuffer: 10 * 1024 * 1024
});

return {
databaseUrl,
Expand All @@ -47,6 +46,44 @@ export async function withTestDatabase(): Promise<TestDatabase> {
};
}

function pnpmCommand(args: string[]): { command: string; args: string[] } {
const npmExecPath = process.env.npm_execpath;
if (npmExecPath !== undefined && path.basename(npmExecPath).includes("pnpm")) {
return { command: process.execPath, args: [npmExecPath, ...args] };
}
if (process.platform === "win32") {
return { command: "cmd.exe", args: ["/d", "/s", "/c", "pnpm", ...args] };
}
return { command: "pnpm", args };
}

async function assertPostgresReachable(url: URL): Promise<void> {
const port = Number(url.port || "5432");
await new Promise<void>((resolve, reject) => {
const socket = createConnection({ host: url.hostname, port });
const fail = (message: string) => {
socket.destroy();
reject(
new Error(
`Postgres test database is unreachable at ${url.hostname}:${String(port)}. ${message} Set TEST_DATABASE_URL or start the local test database before running persistence tests.`
)
);
};

socket.setTimeout(1500);
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("timeout", () => {
fail("Connection timed out.");
});
socket.once("error", (error: NodeJS.ErrnoException) => {
fail(error.code !== undefined ? error.code : error.message);
});
});
}

function databaseUrlForSchema(schema: string): string {
const url = baseDatabaseUrl();
url.searchParams.set("schema", schema);
Expand Down
4 changes: 3 additions & 1 deletion apps/api/tests/integration/dogfood-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { promisify } from "node:util";

const execFileAsync = promisify(execFile);
const root = process.cwd();
const bashBin =
process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "bash";

describe("dogfood-test", () => {
it("consumes the existing build without invoking pnpm", async () => {
Expand All @@ -20,7 +22,7 @@ describe("dogfood-test", () => {
);

try {
await execFileAsync("bash", ["scripts/dogfood-test.sh"], {
await execFileAsync(bashBin, ["scripts/dogfood-test.sh"], {
cwd: root,
env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` },
maxBuffer: 1024 * 1024
Expand Down
13 changes: 12 additions & 1 deletion apps/api/tests/integration/persistence-matrix-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,18 @@ function serverCommand(): { command: string; args: string[] } {
}
return { command: process.execPath, args: [distEntry] };
}
return { command: "pnpm", args: ["exec", "tsx", apiEntry] };
return pnpmCommand(["exec", "tsx", apiEntry]);
}

function pnpmCommand(args: string[]): { command: string; args: string[] } {
const npmExecPath = process.env.npm_execpath;
if (npmExecPath !== undefined && path.basename(npmExecPath).includes("pnpm")) {
return { command: process.execPath, args: [npmExecPath, ...args] };
}
if (process.platform === "win32") {
return { command: "cmd.exe", args: ["/d", "/s", "/c", "pnpm", ...args] };
}
return { command: "pnpm", args };
}

export interface TestDatabaseRegistry {
Expand Down
86 changes: 86 additions & 0 deletions apps/api/tests/unit/application/ai-guide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,90 @@ describe("AI guide", () => {
json.sections.find((section) => section.heading === "Greenfield setup")?.body
).toContain("Add at least one stakeholder interest before creating scenarios.");
});

test("documents default append behavior for step add", () => {
const markdown = buildAiGuide({
cachedGuides: [],
cliVersion: "1.0.0",
format: "markdown",
simulateNetworkFailure: false
}).body as { content: string };
const json = buildAiGuide({
cachedGuides: [],
cliVersion: "1.0.0",
format: "json",
simulateNetworkFailure: false
}).body as { sections: Array<{ body: string; heading: string }> };

expect(markdown.content).toContain(
"`vspec step add` appends by default when `--at` is omitted"
);
expect(
json.sections.find((section) => section.heading === "Existing use case edits")
?.body
).toContain("`vspec step add` appends by default when `--at` is omitted");
});

test("refreshes markdown cache metadata when the CLI version changes", () => {
const response = buildAiGuide({
cachedGuides: [{ cli_version: "0.9.0", content: "old guide" }],
cliVersion: "1.0.0",
format: "markdown",
simulateNetworkFailure: false
});

expect(response.status).toBe(200);
expect(response.body).toMatchObject({
cache: {
cli_version: "1.0.0",
previous_cli_version: "0.9.0",
status: "REFRESHED_VERSION_MISMATCH"
}
});
});

test("returns a cold offline problem without cached guide content", () => {
const response = buildAiGuide({
cachedGuides: [],
cliVersion: "1.0.0",
format: "markdown",
simulateNetworkFailure: true
});

expect(response.status).toBe(503);
expect(response.body).toMatchObject({
bootstrap:
"Read https://vspec.dev/ai-guide and retry vspec ai-guide once online.",
exit_code: 5,
status: 503,
suggested_next_actions: [
{ command: "vspec ai-guide", reason: "Retry once network access returns." }
],
title: "AI guide unavailable",
type: "about:blank"
});
});

test("falls back to a stale cached guide during network failure", () => {
const response = buildAiGuide({
cachedGuides: [{ cli_version: "0.9.0", content: "cached markdown" }],
cliVersion: "1.0.0",
format: "markdown",
simulateNetworkFailure: true
});

expect(response.status).toBe(200);
expect(response.body).toMatchObject({
cache: { cli_version: "0.9.0", status: "STALE_FALLBACK" },
content:
"WARNING: this guide may be out of date relative to the installed CLI.\n\ncached markdown",
warnings: [
{
message:
"Using cached guide 0.9.0 because the current guide could not be fetched.",
type: "STALE_AI_GUIDE"
}
]
});
});
});
14 changes: 12 additions & 2 deletions apps/api/tests/unit/domain-entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ describe("domain entity vocabulary", () => {

try {
const result = await execFileAsync(
"npx",
["tsc", "-p", tsconfigPath, "--pretty", "false"],
...pnpmCommand(["exec", "tsc", "-p", tsconfigPath, "--pretty", "false"]),
{
cwd: root,
env: process.env,
Expand All @@ -45,6 +44,17 @@ describe("domain entity vocabulary", () => {
}, 30_000);
});

function pnpmCommand(args: string[]): [string, string[]] {
const npmExecPath = process.env.npm_execpath;
if (npmExecPath !== undefined && path.basename(npmExecPath).includes("pnpm")) {
return [process.execPath, [npmExecPath, ...args]];
}
if (process.platform === "win32") {
return ["cmd.exe", ["/d", "/s", "/c", "pnpm", ...args]];
}
return ["pnpm", args];
}

function domainEntityTsconfig(): string {
return `${JSON.stringify(
{
Expand Down
Loading
Loading