Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d157665
chore: notify n8n on workflow completion
div0rce Apr 27, 2026
384e772
test: centralize test runner scopes
div0rce Apr 28, 2026
8cc759a
fix: partition full test runner lanes
div0rce Apr 28, 2026
5e24a91
test: enforce test runner ownership
div0rce Apr 28, 2026
d791464
ci(workflow): remove direct runtime test steps from ci.yml
div0rce Apr 28, 2026
3e6697f
scripts(package): add new check scripts for static and runtime verifi…
div0rce Apr 28, 2026
b217a3c
guardrails(ci-coverage): enhance ci guardrail coverage with script ch…
div0rce Apr 28, 2026
b21912a
guardrails(ci-check): update ci must run check to enforce single runt…
div0rce Apr 28, 2026
5b67796
tests(fixtures): update ci must run check test fixtures for new logic
div0rce Apr 28, 2026
f4f7c7c
tests(guardrails): update ci must run check tests for new assertions
div0rce Apr 28, 2026
b22b2d5
docs(pr-template): update pull request template with new health gates
div0rce Apr 28, 2026
8dfa940
docs(agents): update agents guide with new pr checklist and proofs
div0rce Apr 28, 2026
6d5e441
docs(readme): update health gates section with new verification commands
div0rce Apr 28, 2026
8bce30e
docs(ci-guardrails): update ci and guardrails documentation for new c…
div0rce Apr 28, 2026
544cf47
docs(guardrails): update guardrails doc with new ci and runtime rules
div0rce Apr 28, 2026
c8a0e62
docs(script-standards): update script standards for new ci entrypoints
div0rce Apr 28, 2026
0c0071d
audit(full-checkout): update audit script with new ci and test partit…
div0rce Apr 28, 2026
29e5c7d
test: trigger cherry automation forbidden guard
div0rce Apr 28, 2026
2a8eef6
test: trigger cherry automation
div0rce Apr 28, 2026
0965af6
test: retrigger cherry status checks
div0rce Apr 28, 2026
6aba0ad
test: retrigger cherry n8n routing
div0rce Apr 28, 2026
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
9 changes: 4 additions & 5 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Status: Active
Last updated: 2026-01-02
Last updated: 2026-04-28

# Pull Request Checklist

Expand All @@ -13,10 +13,9 @@ Last updated: 2026-01-02
## Testing

- [ ] Not run (explain why)
- [ ] `npm run check`
- [ ] `npm test`
- [ ] `npm run build`
- [ ] `npm run ci:verify`
- [ ] Targeted proof for changed surface:
- [ ] `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure`
- [ ] `npm run test:db` (only for DB/env changes)

## Engine Impact

Expand Down
6 changes: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,5 @@ jobs:
- name: Guardrails
run: npm run check:guardrails

- name: Node runtime tests
run: npm run check:tests:node

- name: Next runtime tests
run: npm run check:tests:next

- name: Verify CI truth
run: npm run ci:verify
31 changes: 31 additions & 0 deletions .github/workflows/n8n-notify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Notify n8n

on:
workflow_run:
workflows:
- CI

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match workflow_run trigger to actual CI workflow name

workflow_run.workflows is configured as CI, but this repo’s CI workflow is named ci in .github/workflows/ci.yml (name: ci). Because this filter matches workflow names, the notifier job will never run after CI completions, so n8n will miss all intended events.

Useful? React with 👍 / 👎.

types:
- completed

jobs:
notify-n8n:
runs-on: ubuntu-latest

steps:
- name: Send workflow result to n8n
env:
N8N_WEBHOOK_URL: ${{ secrets.N8N_WEBHOOK_URL }}
N8N_WEBHOOK_TOKEN: ${{ secrets.N8N_WEBHOOK_TOKEN }}
run: |
curl -X POST "$N8N_WEBHOOK_URL" \
-H "Authorization: Bearer $N8N_WEBHOOK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event": "github.workflow.completed",
"repo": "${{ github.repository }}",
"workflow": "${{ github.event.workflow_run.name }}",
"status": "${{ github.event.workflow_run.conclusion }}",
"branch": "${{ github.event.workflow_run.head_branch }}",
"sha": "${{ github.event.workflow_run.head_sha }}",
"url": "${{ github.event.workflow_run.html_url }}"
}'
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Status: Active
Last updated: 2026-01-29
Last updated: 2026-04-28

# Cherry Agents — Canonical Operating Guide

Expand Down Expand Up @@ -93,14 +93,19 @@ Forbidden framings: “fronting card,” “proxy BIN,” “tap to pay with Che
- `npx prisma format`
- `npx prisma migrate dev --name <desc>`
- `npx prisma generate`
- Run `npm run check`, `npm test`, and `npm run build`.
- Run the issue's acceptance commands, or the narrowest proof that covers schema, runtime, and build impact.
- For docs: add `Status` + `Last updated`, split Current vs Future, add Related docs.

## PR Checklist (what each command proves)
- `npm run check` → guardrails + lint + typecheck are green.
- `npm test` → unit/guardrail tests green (Prisma mocked by loader).
- `npm test` → partitioned full runtime suite: root legacy tests, `tests/node`, then `tests/next` (Prisma mocked by loader).
- `npm run build` → Next.js build passes.
- `npm run ci:verify` → mirrors CI entrypoint.
- `npm run test:db` → DB/env tests only; not part of standard mocked runtime proof.
- `npm run check:fast` → local guardrails + script typecheck only.
- `npm run check:local` → `check:fast` plus partitioned runtime suite.
- Full repo proof → `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure`.
- Agents must not run both `npm test` and `verify:repo-closure` unless explicitly required.
- If schema changed: migrations apply and Prisma client is regenerated.

## Drift Policy
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,17 @@ npm run dev

The repo runtime is Node 24.15.0. Use `.nvmrc` / `engines.node` as the source of truth, and keep PATH stable (e.g. `/usr/bin:/bin:/usr/local/bin`) so `rg`, `git`, and `node` resolve deterministically.

## Health Gates (must pass before pushing)
## Health Gates
```bash
npm run check
npm test
npm run build
CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure
```

For day-to-day development, use the narrowest proof that covers the changed surface.
`npm test` is the partitioned full runtime runner: root legacy tests, `tests/node`,
then `tests/next`. Runtime ownership is enforced by
`tests/node/guardrails/test-runner-ownership.test.ts`. DB tests remain separate under
`npm run test:db`.

---

## Key Commands and Scripts
Expand Down Expand Up @@ -144,3 +148,4 @@ Always import Prisma from `@/lib/prisma` and validate inputs with Zod schemas in
- Third-party typing gaps must be patched via `types/compat/**` with a documented audit boundary.
- Tailwind tokens live in `app/globals.css`; prefer semantic utilities.
- Keep lint/typecheck green; add focused tests when touching engine/sessions/ledger logic.
test
34 changes: 34 additions & 0 deletions app/api/automation/_auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getStandardBearerHeader } from '../../../lib/http/bearer-token.js';

export type AutomationAuthResult = { ok: true } | { ok: false; response: NextResponse };

export function requireAutomationToken(request: NextRequest): AutomationAuthResult {
const expected = process.env['CHERRY_AUTOMATION_TOKEN'];
if (typeof expected !== 'string' || expected.trim().length === 0) {
return {
ok: false,
response: NextResponse.json(
{ error: 'automation_token_not_configured' },
{ status: 503 }
),
};
}

const bearerHeader = getStandardBearerHeader(request.headers);
const headerToken = request.headers.get('x-cherry-automation-token');
const bearerToken =
bearerHeader !== null && bearerHeader.startsWith('Bearer ')
? bearerHeader.slice('Bearer '.length)
: null;
const provided = bearerToken ?? headerToken;
if (provided !== expected) {
return {
ok: false,
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
};
}

return { ok: true };
}
41 changes: 41 additions & 0 deletions app/api/automation/classify/pr/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import {
AutomationEventIdempotencyConflictError,
classifyAndStorePrAutomation,
} from '../../../../../lib/automation/events.js';
import { ensureRouteConfigFromEnv } from '../../../../../lib/config/route.js';
import { PrAutomationClassifySchema } from '../../../../../lib/schemas/automation.js';
import { parseJsonBody } from '../../../../../lib/validation.js';
import { requireAutomationToken } from '../../_auth.js';

export async function POST(request: NextRequest): Promise<NextResponse> {
ensureRouteConfigFromEnv(process.env);

const auth = requireAutomationToken(request);
if (auth.ok === false) return auth.response;

const parsed = await parseJsonBody(request, PrAutomationClassifySchema);
if (parsed.ok === false) return parsed.response;

let result: Awaited<ReturnType<typeof classifyAndStorePrAutomation>>;
try {
result = await classifyAndStorePrAutomation(parsed.data);
} catch (error: unknown) {
if (error instanceof AutomationEventIdempotencyConflictError) {
return NextResponse.json(
{ error: 'automation_event_idempotency_conflict' },
{ status: 409 }
);
}
throw error;
}

return NextResponse.json({
ok: true,
created: result.created,
automationEventId: result.event.id,
outputHash: result.event.outputHash,
classifierOutput: result.classifierOutput,
});
}
40 changes: 40 additions & 0 deletions app/api/automation/events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { AutomationEventIngestSchema } from '../../../../lib/schemas/automation.js';
import {
AutomationEventIdempotencyConflictError,
storeAutomationEvent,
} from '../../../../lib/automation/events.js';
import { ensureRouteConfigFromEnv } from '../../../../lib/config/route.js';
import { parseJsonBody } from '../../../../lib/validation.js';
import { requireAutomationToken } from '../_auth.js';

export async function POST(request: NextRequest): Promise<NextResponse> {
ensureRouteConfigFromEnv(process.env);

const auth = requireAutomationToken(request);
if (auth.ok === false) return auth.response;

const parsed = await parseJsonBody(request, AutomationEventIngestSchema);
if (parsed.ok === false) return parsed.response;

let result: Awaited<ReturnType<typeof storeAutomationEvent>>;
try {
result = await storeAutomationEvent(parsed.data);
} catch (error: unknown) {
if (error instanceof AutomationEventIdempotencyConflictError) {
return NextResponse.json(
{ error: 'automation_event_idempotency_conflict' },
{ status: 409 }
);
}
throw error;
}

return NextResponse.json({
ok: true,
created: result.created,
automationEventId: result.event.id,
outputHash: result.event.outputHash,
});
}
33 changes: 33 additions & 0 deletions app/api/automation/replay/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { replayAutomationEvent } from '../../../../lib/automation/events.js';
import { ensureRouteConfigFromEnv } from '../../../../lib/config/route.js';
import { AutomationReplaySchema } from '../../../../lib/schemas/automation.js';
import { parseJsonBody } from '../../../../lib/validation.js';
import { requireAutomationToken } from '../_auth.js';

export async function POST(request: NextRequest): Promise<NextResponse> {
ensureRouteConfigFromEnv(process.env);

const auth = requireAutomationToken(request);
if (auth.ok === false) return auth.response;

const parsed = await parseJsonBody(request, AutomationReplaySchema);
if (parsed.ok === false) return parsed.response;

const result = await replayAutomationEvent(
parsed.data.automationEventId,
parsed.data.classifierVersion
);
if (result === null) {
return NextResponse.json({ error: 'automation_event_not_found' }, { status: 404 });
}

return NextResponse.json({
ok: true,
automationEventId: result.event.id,
outputHash: result.outputHash,
matches: result.matches,
reason: result.reason,
});
}
41 changes: 41 additions & 0 deletions app/api/automation/simulation-snapshots/compare/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import {
SimulationSnapshotIdempotencyConflictError,
compareAndStoreSimulationSnapshot,
} from '../../../../../lib/automation/events.js';
import { ensureRouteConfigFromEnv } from '../../../../../lib/config/route.js';
import { SimulationSnapshotCompareSchema } from '../../../../../lib/schemas/automation.js';
import { parseJsonBody } from '../../../../../lib/validation.js';
import { requireAutomationToken } from '../../_auth.js';

export async function POST(request: NextRequest): Promise<NextResponse> {
ensureRouteConfigFromEnv(process.env);

const auth = requireAutomationToken(request);
if (auth.ok === false) return auth.response;

const parsed = await parseJsonBody(request, SimulationSnapshotCompareSchema);
if (parsed.ok === false) return parsed.response;

let result: Awaited<ReturnType<typeof compareAndStoreSimulationSnapshot>>;
try {
result = await compareAndStoreSimulationSnapshot(parsed.data);
} catch (error: unknown) {
if (error instanceof SimulationSnapshotIdempotencyConflictError) {
return NextResponse.json(
{ error: 'simulation_snapshot_idempotency_conflict' },
{ status: 409 }
);
}
throw error;
}

return NextResponse.json({
ok: true,
created: result.created,
snapshotId: result.snapshot.id,
outputHash: result.snapshot.outputHash,
comparisonOutput: result.comparisonOutput,
});
}
50 changes: 50 additions & 0 deletions app/api/automation/statuses/github/retry/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import {
GithubStatusRetryNotFoundError,
retryGithubStatus,
} from '../../../../../../lib/automation/github-status.js';
import { ensureRouteConfigFromEnv } from '../../../../../../lib/config/route.js';
import { GithubStatusRetrySchema } from '../../../../../../lib/schemas/automation.js';
import { parseJsonBody } from '../../../../../../lib/validation.js';
import { requireAutomationToken } from '../../../_auth.js';

export async function POST(request: NextRequest): Promise<NextResponse> {
ensureRouteConfigFromEnv(process.env);

const auth = requireAutomationToken(request);
if (auth.ok === false) return auth.response;

const parsed = await parseJsonBody(request, GithubStatusRetrySchema);
if (parsed.ok === false) return parsed.response;

try {
const result = await retryGithubStatus(parsed.data, {
githubToken: process.env['GITHUB_TOKEN'] ?? '',
});
return NextResponse.json({
ok: true,
retried: result.retried,
statusCheck: result.statusCheck,
});
} catch (error: unknown) {
if (error instanceof GithubStatusRetryNotFoundError) {
return NextResponse.json({ error: 'github_status_not_found' }, { status: 404 });
}
const statusCheck = (error as { statusCheck?: unknown }).statusCheck;
const message = error instanceof Error ? error.message : 'github_status_retry_failed';
const status = /forbidden Cherry finance endpoint|Unsupported GitHub status context/.test(
message
)
? 400
: 502;
return NextResponse.json(
{
ok: false,
error: message,
statusCheck,
},
{ status }
);
}
}
Loading
Loading