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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
!apps/web/public/
!services/api/quack/
!apps/web/app/collect/
!apps/web/app/fonts/
!apps/web/components/account/
!apps/web/components/ask/
!apps/web/components/collect/
Expand All @@ -29,6 +30,8 @@
!apps/web/.gitignore
!apps/web/app/collect/page.tsx
!apps/web/app/favicon.ico
!apps/web/app/fonts/bricolage-grotesque-latin-700.woff2
!apps/web/app/fonts/figtree-latin-variable.woff2
!apps/web/app/globals.css
!apps/web/app/layout.tsx
!apps/web/app/page.tsx
Expand Down Expand Up @@ -121,6 +124,7 @@
!services/api/quack/llm/report.py
!services/api/quack/llm/router.py
!services/api/quack/main.py
!services/api/quack/observability.py
!services/api/quack/prompts/__init__.py
!services/api/quack/prompts/ask.v1.md
!services/api/quack/prompts/explain.v1.md
Expand Down
86 changes: 86 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Release checks

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: verify-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 60
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: quack_test
POSTGRES_PASSWORD: test-only-password
POSTGRES_DB: quack_ledger_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U quack_test -d quack_ledger_test"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
CI: "true"
QUACK_ENV: production
QUACK_AUTH_MODE: "off"
MODEL_API_KEY: ""
QUACK_LEDGER_TEST_DATABASE_URL: postgresql://quack_test:test-only-password@127.0.0.1:5432/quack_ledger_test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
services/api/requirements.lock
services/api/requirements-tooling.lock
services/api/requirements*.txt
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install backend
run: |
python -m venv services/api/.venv
services/api/.venv/bin/python -m pip install -r services/api/requirements.lock -r services/api/requirements-tooling.lock -r services/api/requirements-dev.txt
- name: Install web
working-directory: apps/web
run: npm ci
- name: Audit locked Python dependencies
run: services/api/.venv/bin/python -m pip_audit --no-deps --disable-pip -r services/api/requirements.lock -r services/api/requirements-tooling.lock
- name: Audit web dependencies
working-directory: apps/web
run: npm audit
- name: Install test browser
working-directory: apps/web
run: npx playwright install --with-deps chromium
- name: Install checksum-verified secret scanner
run: |
mkdir -p "$RUNNER_TEMP/quack-tools"
curl --fail --silent --show-error --location https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o "$RUNNER_TEMP/quack-tools/gitleaks.tar.gz"
echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb $RUNNER_TEMP/quack-tools/gitleaks.tar.gz" | sha256sum --check
tar -xzf "$RUNNER_TEMP/quack-tools/gitleaks.tar.gz" -C "$RUNNER_TEMP/quack-tools" gitleaks
echo "$RUNNER_TEMP/quack-tools" >> "$GITHUB_PATH"
- name: Run release checks
run: npm run verify:release
- name: Upload browser failure evidence
if: failure()
uses: actions/upload-artifact@v4
with:
name: browser-test-evidence
path: |
apps/web/test-results/
apps/web/playwright-report/
retention-days: 7
Binary file not shown.
Binary file added apps/web/app/fonts/figtree-latin-variable.woff2
Binary file not shown.
7 changes: 4 additions & 3 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Metadata, Viewport } from "next";
import { Bricolage_Grotesque, Figtree } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";

const display = Bricolage_Grotesque({ variable: "--font-bricolage", subsets: ["latin"], weight: ["700"] });
const sans = Figtree({ variable: "--font-figtree", subsets: ["latin"] });
// Keep production builds independent of Google Fonts availability.
const display = localFont({ src: "./fonts/bricolage-grotesque-latin-700.woff2", variable: "--font-bricolage", weight: "700", display: "swap" });
const sans = localFont({ src: "./fonts/figtree-latin-variable.woff2", variable: "--font-figtree", weight: "300 900", display: "swap" });

export const metadata: Metadata = {
title: "Quack",
Expand Down
36 changes: 33 additions & 3 deletions apps/web/lib/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,47 @@ function db(): UseStore {
return store;
}

const object = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
const bounds = (value: unknown): value is Bounds => object(value) &&
finite(value.minX) && finite(value.minY) && finite(value.maxX) && finite(value.maxY) &&
value.minX <= value.maxX && value.minY <= value.maxY;

// IndexedDB is an input boundary too: old app versions, failed imports and extensions can leave
// malformed values. A failed validation must preserve the stored value for recovery.
export function isSavedNotebook(value: unknown): value is SavedNotebook {
if (!object(value) || !Number.isSafeInteger(value.version) || (value.version as number) < 0 || !Array.isArray(value.strokes)) return false;
const ids = new Set<string>();
for (const stroke of value.strokes) {
if (!object(stroke) || typeof stroke.id !== "string" || !stroke.id || ids.has(stroke.id) ||
typeof stroke.color !== "string" || !finite(stroke.width) || stroke.width < 0 || !finite(stroke.createdAt) ||
!Array.isArray(stroke.points) || !stroke.points.length) return false;
ids.add(stroke.id);
for (const point of stroke.points) {
if (!object(point) || !finite(point.x) || !finite(point.y) || !finite(point.timestamp) ||
(point.pressure !== undefined && (!finite(point.pressure) || point.pressure < 0 || point.pressure > 1))) return false;
}
}
if (value.dirty !== undefined && (!Array.isArray(value.dirty) || !value.dirty.every(bounds))) return false;
if (value.seen !== undefined && (!object(value.seen) || !Object.values(value.seen).every((item) =>
object(item) && typeof item.dismissed === "boolean" && (item.area === null || bounds(item.area))))) return false;
return true;
}

// Failing to read is not the same as having nothing saved. Treating it as empty would let the
// next stroke overwrite a student's notes.
export async function loadNotebook(key: string = KEY): Promise<LoadResult> {
try {
const saved = await get<SavedNotebook>(key, db());
return saved ? { status: "found", saved } : { status: "empty" };
const saved = await get<unknown>(key, db());
if (saved === undefined) return { status: "empty" };
return isSavedNotebook(saved) ? { status: "found", saved } : { status: "failed" };
} catch {
return { status: "failed" };
}
}

export async function saveNotebook(saved: SavedNotebook): Promise<boolean> {
if (!isSavedNotebook(saved)) return false;
try {
await set(KEY, saved, db());
return true;
Expand All @@ -45,12 +74,13 @@ export async function saveNotebook(saved: SavedNotebook): Promise<boolean> {
// other tab's work. So the write only goes ahead if the save is still the version we last knew,
// checked in one transaction so that nothing can slip in between. `known` is null if there was none.
export async function writeNotebook(saved: SavedNotebook, known: number | null, key: string = KEY): Promise<WriteOutcome> {
if (!isSavedNotebook(saved) || (known !== null && (!Number.isSafeInteger(known) || known < 0 || saved.version < known))) return "failed";
let conflict = false;
try {
await update<SavedNotebook>(
key,
(current) => {
conflict = current !== undefined && current.version !== known;
conflict = current === undefined ? known !== null : !isSavedNotebook(current) || current.version !== known;
return conflict ? current! : saved;
},
db(),
Expand Down
14 changes: 12 additions & 2 deletions apps/web/lib/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class AnalysisScheduler {
}

checkNow() {
if (this.disposed) return;
// Pressing again while a check runs can't make it faster, and used to start the same
// expensive reading over and over on the server.
const newest = this.newest;
Expand Down Expand Up @@ -174,6 +175,7 @@ export class AnalysisScheduler {
private mayStart(): boolean {
const newest = this.newest;
if (!newest) return true;
if (this.flights.some((flight) => flight.controller.signal.aborted)) return false;
if (!this.deps.overlap) return false;
if (this.flights.length >= MAX_IN_FLIGHT || newest.trigger !== "auto" || this.manualWaiting) return false;
return this.deps.getVersion() > newest.capture.version;
Expand Down Expand Up @@ -203,7 +205,7 @@ export class AnalysisScheduler {

private cancelInFlight() {
const flights = this.flights;
this.flights = [];
this.manualWaiting = false;
for (const flight of flights) {
flight.controller.abort();
flight.penUp?.();
Expand Down Expand Up @@ -239,6 +241,7 @@ export class AnalysisScheduler {
}

private async run(trigger: Trigger) {
if (this.disposed) return;
const capture = this.deps.capture(trigger);
if (!capture) {
mark("check_skipped", undefined, { trigger, reason: "nothing_to_check" });
Expand Down Expand Up @@ -268,8 +271,14 @@ export class AnalysisScheduler {
let answeredFor: Trigger | null = null;
// Whether a newer check started while this one was under way. Its reply, or the newer one's, has the last word.
const overtaken = () => this.flights.indexOf(flight) < this.flights.length - 1 || capture.version < this.delivered;
let onAbort: () => void = () => {};
try {
const result = await this.deps.send(capture, trigger, controller.signal);
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(new DOMException("aborted", "AbortError"));
controller.signal.addEventListener("abort", onAbort, { once: true });
});
const result = await Promise.race([this.deps.send(capture, trigger, controller.signal), aborted]);
if (controller.signal.aborted) throw new DOMException("aborted", "AbortError");
// The stroke under way isn't in the notebook yet, and could be this mistake's correction.
// Pen-up comes just before the stroke is added, and this carries on after that.
if (this.drawing) await new Promise<void>((resolve) => (flight.penUp = resolve));
Expand Down Expand Up @@ -331,6 +340,7 @@ export class AnalysisScheduler {
if (this.flights.length > 1 && (timedOut || !controller.signal.aborted)) this.deps.onBusyChange(true);
} finally {
clearTimeout(watchdog);
controller.signal.removeEventListener("abort", onAbort);
const index = this.flights.indexOf(flight);
if (index >= 0) this.flights.splice(index, 1);
// Busy until the last check under way has finished, and said once. After a cancel, the list is already empty.
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export type LineTrace = {
};

export type AnalyzeResponse = {
// The server validates that alerts have new verified mistakes, unique ids, and no resolved overlap.
snapshotVersion: number;
requestId: string | null;
status: AnalysisStatus;
Expand Down
18 changes: 13 additions & 5 deletions apps/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"lint": "eslint --max-warnings=0",
"test": "vitest run",
"test:e2e": "playwright test",
"test:production": "playwright test --config playwright.production.config.ts"
"test:production": "playwright test --config playwright.production.config.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@supabase/supabase-js": "2.118.0",
Expand All @@ -33,5 +34,10 @@
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^5.0.5"
},
"overrides": {
"@typescript-eslint/typescript-estree": {
"minimatch": "11.0.5"
}
}
}
4 changes: 3 additions & 1 deletion apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ export default defineConfig({
expect: { timeout: 10_000 },
workers: 1, // the fake Meta server holds one script at a time
fullyParallel: false,
forbidOnly: true,
retries: 0,
outputDir: "test-results/journeys",
reporter: [["list"], ["json", { outputFile: "test-results/results.json" }]],
use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 800 } },
use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 800 }, trace: "retain-on-failure", screenshot: "only-on-failure" },
projects: [
// The notebook on its own, as it was before accounts, which is what proves that path hasn't moved.
{ name: "notebook", testIgnore: /(accounts|learning|lessons)\.spec/, use: { baseURL: `http://127.0.0.1:${port(3100)}` } },
Expand Down
6 changes: 4 additions & 2 deletions apps/web/playwright.production.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ const port = 3200 + Number(process.env.QUACK_E2E_PORT_SHIFT ?? 0);
export default defineConfig({
testDir: "tests/production",
workers: 1,
forbidOnly: true,
retries: 0,
reporter: "list",
use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${port}` },
outputDir: "test-results/production",
reporter: [["list"], ["json", { outputFile: "test-results/production-results.json" }]],
use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${port}`, trace: "retain-on-failure", screenshot: "only-on-failure" },
webServer: {
command: `npx next start --port ${port} --hostname 127.0.0.1`,
url: `http://127.0.0.1:${port}`,
Expand Down
1 change: 1 addition & 0 deletions apps/web/tests/e2e/accounts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ test.describe("calibration", () => {
await page.getByTestId("calibration-start").click();
await expect(page.getByTestId("calibration-next")).toBeDisabled();
await write(page, "1");
await expect(page.getByTestId("calibration-next")).toBeEnabled();
await page.getByTestId("calibration-next").click();
await expect(page.getByTestId("calibration-problem")).toContainText("unfinished");
await expect(page.getByTestId("calibration-progress")).toHaveAttribute("aria-valuenow", "0");
Expand Down
Loading
Loading