Skip to content

Latest commit

 

History

History
146 lines (104 loc) · 40.5 KB

File metadata and controls

146 lines (104 loc) · 40.5 KB

Reverse-chronological record of shipped work — features, fixes, and chores. Newest first.

2026-07-05

  • Prompt library — saved, reusable prompt templates inserted in the composer by typing /<name> (the roadmap v0.5 slash-commands seed, in its genuinely-useful form; validated by Open WebUI, which has the same / prompt menu). A prompts table (owner-scoped, FORCE RLS, UNIQUE(user_id, name), migration 0015) + /api/v1/me/prompts CRUD (GET/POST/PATCH/DELETE), mirroring the memories pattern — the name is a slug (^[A-Za-z0-9_-]+$, DB CHECK) so /<name> is unambiguous, and a duplicate name is a real 409 (DB unique + a 23505-walking catch, stricter than the memories pre-check). Manage prompts in Settings; insert them in a chat via a / autocomplete menu. A two-reviewer round hardened the composer: the trigger is ^/(\S+)$ — bare / never opens the menu, so a literal / message still sends (the adversarial reviewer's literal-send trap); prefix matching is case-insensitive; only BARE Enter selects (Shift+Enter falls through to a newline); the menu list comes from a single shared usePromptsQuery() (fetched once, filtered per keystroke, invalidated by a settings edit). The core-composer change is minimal + backward-compatible: PromptInputTextarea now calls a passed onKeyDown first and bails if it preventDefaulted (before that, a passed handler silently clobbered Enter-to-send). Verified: 7 prompts RLS integration cases (owner CRUD, cross-tenant denied, per-user unique name but same-name-across-users allowed, a case-insensitive name conflict for the same user, slug + content CHECKs, and a concurrent-create race proving the per-user cap holds under pg_advisory_xact_lock), plus pure-trigger and web service cases, prompts route in openapi.json, api + web build/lint/tsc clean.
  • Prompt templating — saved prompts can now carry {{placeholder}} variables, completing the prompt library into genuinely reusable, parameterized prompts. When you insert a prompt that has placeholders (via /name), a small fill-in dialog collects a value per unique placeholder and substitutes them into the composer; a plain prompt inserts directly as before. Client-only, no schema change (placeholders are plain text in the prompt body). Chosen a fill DIALOG over inline cursor-jump templating deliberately — it sidesteps textarea DOM-ref/selection machinery and is complete for any number of variables + fully unit-testable via pure extractPlaceholders/fillPlaceholders. A two-reviewer round hardened it: substitution is a SINGLE String.replace(regex, callback) pass over the original body (so a value that itself contains {{x}} is never re-expanded); the / menu is dismissed via setDismissedFor(input) when the dialog opens (no menu-behind-dialog glitch or cancel-reopen loop); the regex is ReDoS-safe ({{([^{}]*?)}}, no overlapping quantifiers); and the dialog field ids are INDEX-based, because a placeholder name can contain spaces ({{target language}}) which is invalid in a DOM id and would break label[for]. Verified: 8 pure templating cases (unique/ordered extraction, dedupe, empty {{}} ignored, substitution, duplicate-fill, unfilled→empty, no-double-expansion), web build/lint/tsc clean; no api/schema touched.
  • Hardened apps/api/scripts/rls-test.sh's readiness wait: it now also confirms the published Postgres port is reachable from the host (bash /dev/tcp), not just that pg_isready succeeds inside the container — under WSL2/Docker the host port-forward can lag the container's internal readiness, which previously let the migration step connect too early and hit CONNECT_TIMEOUT.

2026-07-04

  • Fixed pnpm --filter web dev in git worktrees after the Next 16/Turbopack upgrade: the script now launches Next from the monorepo root with apps/web as the project directory, avoiding Turbopack's mixed-root module graph that made authenticated chat pages fail with Cannot find module '@workspace/ui/globals.css' while unauthenticated/login routes still appeared healthy.

  • pnpm hygiene from the Node-vs-Bun evaluation (docs/runtime-and-package-manager.md records the decision to stay on Node + pnpm and the revisit triggers): bumped pnpm 10.4.1 → 10.34.4 (picks up the 10.x security patches — lockfile path-traversal hardening, env-var-expansion restriction relevant to BYOK secrets), introduced a catalog: for the 14 dependencies shared by 2+ workspaces (single version edit point; @types/node lifted to ^22 matching the runtime floor), and migrated onlyBuiltDependencies to the reviewed allowBuilds map (pnpm 11's only mechanism) with every blocked install script documented. enableGlobalVirtualStore (near-instant per-worktree installs) was evaluated for the multi-agent worktree flow and deliberately left off: tsgo splits @types identities through the global-store realpaths and the web typecheck fails — documented in-place for revisit.

  • Audited the monorepo against Turborepo/Next.js/NestJS best practices (official with-nestjs/kitchen-sink examples, current docs, comparable OSS repos) and fixed what it surfaced. Two real bugs: apps/api's compiled entrypoint was nested under dist/src/ (root-level drizzle.config.ts polluted rootDir), so start:prod's node dist/main could never boot — now excluded from the build tsconfig and proven booting + serving /docs/json; and Nest shutdown hooks were never enabled, so SIGTERM couldn't drain the postgres.js pool or pg-boss (app.enableShutdownHooks() per docs/scaling.md's invariants). Build caching actually works now: per-package Turborepo configs (apps/api/turbo.json declares dist/** + openapi.json outputs — previously the api build was silently uncacheable; apps/web/turbo.json owns the .next/** outputs and scoped env), trimmed globalEnv to truly-global vars, test is a first-class cached turbo task (with the DB/eval gate vars in its hash), and CI persists the turbo cache via actions/cache — a warm turbo build is now FULL TURBO (~2s). Also: typedRoutes enabled in apps/web (the open-redirect sanitizer and nav hrefs now carry Route types), stale $schema URL updated, root package renamed shadcn-ui-monorepollame, contradictory pnpm ignoredBuiltDependencies entry dropped.

  • Chat-list previews show the real latest message: GET /api/v1/chats items now carry a lastMessage (role + text-only excerpt truncated server-side + timestamp; null only for the unreachable no-messages case). One DISTINCT ON query fetches the latest message per owned chat, owner-scoped through the chats join with cross-tenant isolation re-proven in the RLS integration suite; the apps/web nested chats sidebar renders the excerpt in place of its placeholder.

  • Hardened the e2e Postgres bootstrap: pg_isready can answer during initdb's temporary server and the follow-up psql then lands in the restart gap — readiness now requires consecutive successful checks.

2026-07-03

  • Redesigned the apps/web shell into a double sidebar: a collapsible icon rail (toggle row, New chat/Search actions, section nav — Dashboard/Chats/Projects/Gallery/Calendar/Email/Brain, with sections that don't exist yet rendered as disabled placeholders — and the account menu) plus a nested chats sidebar (header with a New chat action, time-grouped list with relative timestamps and per-chat actions; search stays in ⌘K). All three top bars share an aligned 3rem height with hairline dividers. The rail starts collapsed and remembers the user's choice via the existing sidebar_state cookie; on mobile the chat list stays reachable inside the sheet. The in-rail projects list and Library placeholder are gone (Projects lives in the nav, disabled until it ships).

  • Adversarial review pass (two independent reviewers over the full branch diff) — security came back clean (no high-severity findings); concurrency surfaced three real races, all fixed and e2e-proven: (1) a cancel landing in the worker's pickup window (after the gate read, before abort registration) was silently ignored — the worker now re-checks cancel_requested_at once registered; (2) executeRun ignored markStarted's claim result, so a run superseded/expired between enqueue and execution still burned a model call and appended events onto a terminal run — claiming is now mandatory and a failed claim aborts with no spend and no events; (3) a crashed process could wedge a chat forever via the single-flight index (inline mode has no deadman) — a new message now expires a stale-heartbeat zombie run and takes its slot (savepoint-wrapped, race-safe), so no chat can be permanently wedged in any mode.

  • Consolidation pass over the overnight branch: fixed two masked lint errors in the chat loop (an untyped let run and unsafe String() coercions in the unique-violation matcher — the local lint wrapper had hidden them; CI would have failed), and closed an at-least-once-delivery seam in the worker: a redelivered queue job whose run is already executing (fresh heartbeat) is now skipped instead of starting a second model call, while a stale running run still accepts the redelivery as crash recovery.

  • Closed out the #55 streaming-loop hardening deferrals (#73): in_reply_to integrity now holds at the database (a trigger rejects replies linked across chats or to non-user messages on the reply write path, whichever code path writes them — proven with negative tests); the e2e fake model client aborts on the abort event (not a post-hoc poll), with a new fidelity test proving a mid-stream cancel (PATCH /runs/:id — under durable-run semantics transport abort never kills a turn) fires onError, never onFinish, and persists no partial text; and the unit fake now fires onFinish on stream consumption (pull-driven), matching real AI SDK timing. Single-flight, the fourth item, shipped with #48.

  • Per-chat single-flight (#48, closing its acceptance list): a partial unique index admits at most one non-terminal run per chat — the DB-level guarantee against concurrent double model calls (#73, deferred from #55). A different message sent while a run is in flight gets a clean 409 with its whole transaction rolled back; a retry of the same message supersedes its prior attempt (cancelled + evented + in-process abort) so a silently-died turn is always retryable. markStarted and worker pickup now refuse terminal runs, so a superseded queued run can never be resurrected. The v0.1 "overlapping turns" e2e was rewritten to the new serialized contract.

  • Zombie runs now expire (#48 heartbeat + timeout): the executing worker stamps a per-run heartbeat, and every enqueued run gets its own delayed deadman job (pg-boss startAfter — no cross-tenant reaper scan, so the RLS moat stays intact): terminal runs are left alone, fresh-heartbeat runs are re-checked later, and a run whose heartbeat went stale (worker crash/hang) is marked expired with a run.expired event. Terminal statuses are now immutable at the repository level (first writer wins), so a late-finishing stream can never overwrite expired/cancelled. All knobs configurable (RUN_TIMEOUT_SECONDS, RUN_HEARTBEAT_STALE_SECONDS, RUN_HEARTBEAT_SECONDS); proven in worker-mode e2e with a hand-crafted zombie.

  • Runs are cancellable (#48): PATCH /api/v1/runs/:id with {status: "cancelled"} (resource PATCH per house REST rules, not a verb handle) stamps cancel_requested_at — the durable, cross-process signal — and aborts the in-process controller when the run is executing locally. A still-queued run is settled as cancelled at worker pickup without touching the model; a mid-flight run aborts through the same path a client abort used in inline mode. Idempotent re-cancel returns 200, a finished run 409, cross-tenant 404 — all proven in worker-mode e2e.

  • Every run now executes through the queue worker (#48/#50): POST /chats/:id/messages validates, persists the user message + run, enqueues on pg-boss, and answers with the run-event stream bridge — the HTTP connection is a viewport onto the durable run, so closing the tab no longer kills the turn. The former inline request-thread mode (and its RUN_EXECUTION_MODE flag) is removed: one execution path, one set of semantics. Consequences accepted at this stage and tracked: the web Stop button cancels via the upcoming PATCH /runs/:id wiring (stacked web slice) rather than transport abort, and first-token latency includes queue pickup + bridge polling until LISTEN/NOTIFY (#118)

  • Extracted run execution out of the HTTP path (staging #50): a new transport-agnostic RunExecutionService owns context assembly, the model call, and every durable side effect (assistant turn, run lifecycle + delta events, post-turn compaction/titling); ChatLoopService shrinks to the SPEC §9.5 API-side steps — validate, store message, create run, hand off. Behavior-preserving (full e2e parity); the worker move (#50) now swaps one hand-off call for an enqueue.

  • Reorganized apps/api into feature-directory modules: runs/, compaction/, and titles/ move out of the chats/ grab-bag into their own directories with real NestJS modules (RunsModule read surface, CompactionModule, TitlesModule — each importable without dragging the chat HTTP surface along, which is exactly what the worker split (#50) needs); chats/ keeps the loop, context builder, repositories, and telemetry.

  • Documented horizontal scaling (docs/scaling.md): the api×N/worker×M/single-Postgres topology, the invariants that keep replica scaling correct (stateless api, terminal-status-implies-terminal-event, RLS-in-DB), and the six design constraints the worker split (#48/#50) must respect — transactional enqueue via pg-boss's external-transaction support, per-chat FIFO via partial unique index + key_strict_fifo, worker concurrency for IO-bound runs, LISTEN/NOTIFY for live deltas (polling is the resume path only), model.delta retention, and the deadman sweep appending run.expired.

  • Landed the durable-run substrate (#48, first slice): runs and append-only run_events tables (SPEC §9.3–§9.4) with RLS ENABLE+FORCE and cross-tenant read/write denial proven live. Every user message now creates a run in the same transaction as the message, and the streaming loop dual-writes an ordered lifecycle log (run.createdrun.startedmodel.requestedmodel.completedrun.completed/run.failed/run.cancelled) — the durable source of truth the SSE replay (#49) will read. Still to come in #48: the worker consuming from pg-boss, token-delta events, cancellation/heartbeat/timeout, and per-chat single-flight (deliberately deferred until heartbeat exists, since without it a crashed run would deadlock its chat).

  • Made durable runs observable and replayable (#48/#49 API side): the loop now persists coalesced model.delta events (size-buffered via a pure delta-buffer, ordered by a sequential write chain), and a new run read surface exposes GET /api/v1/runs/:id plus the SPEC §9.4 cursor SSE GET /api/v1/runs/:id/events?after_sequence=N — each frame's SSE id: is its event sequence, an in-flight run is polled until terminal, a finished run streams its tail and closes, and a reconnect resumes from the last id with nothing lost. Cross-tenant reads 404 on both endpoints (proven in e2e). The apps/web resume-on-refresh client remains open in #49.

  • Stood up pg-boss as the run queue + scheduler on the existing Postgres (#47) — no Redis, no separate scheduler service (SPEC §24.0.1). All access goes through a new Queue interface (QUEUE token: ensureQueue/enqueue/consume/schedule/cancel), so the engine can later swap to BullMQ or Temporal without touching callers; queues default to retry-with-backoff plus a <queue>.dead dead-letter queue so failed work is inspectable, never dropped. Proven against real Postgres by a gated integration suite (enqueue/consume roundtrip, retries, dead-lettering, cron schedule persistence, deferred delivery). The module is deliberately not booted by the API yet — the durable-run pipeline (#48) and worker (#50) are its consumers.

  • Raised the Node floor to 22.12, landed ahead of the pg-boss-based queue substrate (#47/#105) which requires it. .node-version and .nvmrc are now the single source of truth (root engines.node and CI's actions/setup-node both read .node-version); the dev toolchain also gets a committed Nix flake (flake.nix/flake.lock, nodejs_22 + pnpm) with .envrc for direnv, so nix develop or direnv gives a reproducible shell without touching the host Node install.

  • Type-checking now runs on tsgo (the TypeScript 7 Go port, @typescript/native-preview pinned): apps/web's typecheck drops from ~6s to ~1s, and apps/api gains a typecheck script it never had (~0.6s) — closing the hole that let six latent spec type errors survive (specs are excluded from nest build, and nothing else built the full program). CI gates turbo run typecheck. Emit/build toolchains stay on TypeScript 5.x; tsgo is check-only. apps/web's tsconfig drops baseUrl (removed in TS7; its paths were already tsconfig-relative, tsc 5.x semantics unchanged).

  • Added lefthook pre-commit hooks (installed via the root prepare script on pnpm install): staged-file-scoped oxlint per workspace plus the api prettier check, parallel, sub-second on a typical commit; check-only by design — hooks never mutate files. Escape hatch: git commit -n / LEFTHOOK=0. Standing this up surfaced that oxlint's built-in correctness category defaults to warn severity, so the api's check-only lint (no --deny-warnings, unlike web/ui) gated nothing from that category — api's .oxlintrc.json now pins correctness: error.

  • Migrated linting from ESLint to oxlint across all workspaces. Motivation: whole-repo linting was slow and memory-fragile — the api's typescript-eslint project service took ~12s alone and parallel turbo lint OOM'd locally; oxlint runs the same surface in ~1.1s total, parallel, with no Node-heap failure mode. The api keeps its full type-aware rule set (the recommended-type-checked equivalents, same warn/off overrides) via oxlint-tsgolint, which runs on tsgo — the official TypeScript 7 compiler — so typed rules like no-floating-promises/no-unsafe-* keep tsc-fidelity type information (~0.5s). apps/web and packages/ui gate with --deny-warnings as before; packages/config-eslint and the entire ESLint dependency tree are deleted. Formatting stays with prettier (benchmarked oxfmt: 6-7× faster but no markdown support and not byte-compatible — deliberately deferred until it matures): a new root format:check gates apps/api/{src,test} in CI, the surface the removed eslint-plugin-prettier used to enforce. Fallout the switch surfaced and fixed: model-client.spec.ts carried six latent type errors nothing ever checked (specs are excluded from nest build and ts-jest didn't flag them; tsgolint builds the full program), api's tsconfig.json drops baseUrl (tsgo removed it) and declares "types": ["node", "jest"] explicitly, and a stray unused-.eslintrc.js at the repo root plus a stale Biome VS Code recommendation are gone.

  • Upgraded apps/web to Next.js 16 (15.5.19 → 16.2.10), following the official upgrade guide: middleware.ts renamed to proxy.ts (same cookie-presence gate; proxy always runs on the Node.js runtime, so the explicit runtime config is gone), Turbopack is now the default for both next dev and next build (dropped the --turbopack flag), and the removed next lint command is replaced by running ESLint directly (eslint . --max-warnings 0, same flat config). Along for the ride because Next 16 requires them: @sentry/nextjs 9 → 10 (v9 does not peer-support Next 16; v10's withSentryConfig is Turbopack-aware) and React pinned to ^19.2, plus @next/eslint-plugin-next 15 → 16 in the shared ESLint config. Async request APIs needed no changes — the app already awaited params and cookies().

2026-07-02

  • Fixed broken live streaming in the UI (regression from the #50 worker-default flip, user-reported): the delta buffer coalesced model tokens into model.delta events by size only (400 chars) — correct when the event log was a replay record, wrong once it became the live channel the bridge streams from, so any answer under 400 chars appeared all at once at stream end. The buffer now flushes on size or age (150ms), whichever comes first, with time injected by the caller so it stays pure and timer-free (worst-case staleness is one token gap). The original code even carried a "revisit granularity when the loop moves into the worker" comment — the revisit is done. Verified by unit tests and the full 12-test Playwright browser suite through the real worker + bridge path.
  • Adversarial review pass (two independent reviewers over the full branch diff) — security came back clean (no high-severity findings); concurrency surfaced three real races, all fixed and e2e-proven: (1) a cancel landing in the worker's pickup window (after the gate read, before abort registration) was silently ignored — the worker now re-checks cancel_requested_at once registered; (2) executeRun ignored markStarted's claim result, so a run superseded/expired between enqueue and execution still burned a model call and appended events onto a terminal run — claiming is now mandatory and a failed claim aborts with no spend and no events; (3) a crashed process could wedge a chat forever via the single-flight index (inline mode has no deadman) — a new message now expires a stale-heartbeat zombie run and takes its slot (savepoint-wrapped, race-safe), so no chat can be permanently wedged in any mode.
  • Consolidation pass over the overnight branch: fixed two masked lint errors in the chat loop (an untyped let run and unsafe String() coercions in the unique-violation matcher — the local lint wrapper had hidden them; CI would have failed), and closed an at-least-once-delivery seam in the worker: a redelivered queue job whose run is already executing (fresh heartbeat) is now skipped instead of starting a second model call, while a stale running run still accepts the redelivery as crash recovery.
  • Refresh-safe resume proven in a real browser — #49 and #80 closed: a new Playwright chat-flow suite runs the full stack (web + api in worker execution mode + throwaway Postgres + a deterministic mock OpenAI-compatible model server wired via OPENAI_BASE_URL) and proves create → stream → render plus the headline: reload the page mid-answer and the run survives, resumes, and completes on screen. The whole browser suite (12 tests) now runs against worker mode — standing soak evidence for flipping RUN_EXECUTION_MODE's default (#50). Along the way, fixed a latent #88 bug: the model client hit OpenAI's proprietary /responses endpoint, which OpenAI-compatible providers don't implement — it now uses /chat/completions (works everywhere, OpenAI included). Auth throttle limits became env-tunable (AUTH_RATE_LIMIT_PER_MINUTE) so parallel e2e workers from one IP don't starve the fixtures; production default stays strict.
  • Wired resume-on-refresh into the web chat (#49 client side): DefaultChatTransport now carries a prepareReconnectToStreamRequest pointing at GET /chats/:id/stream, and persisted chats mount with resume: true — reloading a chat mid-run reconnects to the active run's UI-message stream and picks up live (draft chats skip the probe; an idle chat's 204 resolves to a no-op). Verified by web unit tests, typecheck/build, and the full 10-test Playwright browser suite against the live api+web stack. The end-to-end browser proof of a mid-run refresh needs the Playwright API in worker mode — the remaining step to close #49.
  • Added the stream-resume endpoint (#49 API side): GET /api/v1/chats/:id/stream returns the chat's active run as an AI SDK UI-message stream — a page refresh mid-run replays every persisted delta and continues live to completion — or 204 when there is nothing to resume (a cross-tenant or unknown chat id answers the same 204: no existence leak). Matches the AI SDK v6 reconnectToStream transport contract, so the apps/web hookup is a small transport method; "the active run" is well-defined thanks to per-chat single-flight. Proven in worker-mode e2e: disconnect mid-run → resume replays the full ordered chunk stream.
  • Auth hardening, second tranche (#68): rate limiting via @nestjs/throttler — a generous instance-wide ceiling (300/min) with strict 10/min per-IP limits on login/register (each attempt burns a bcrypt compare), the throttle guard running before session validation so floods never pay the session lookup; proven by a 429 e2e. And expired-session housekeeping on a pg-boss cron (sessions.cleanup, hourly) — #47's scheduler's first production consumer; the purge is idempotent across instances and proven against real Postgres. Remaining in #68: cross-site CSRF posture, token-free cookie responses, session rotation (vacuous until a change-password endpoint exists).
  • Auth surface hardening, first tranche (#68): the API is now fail-closed by defaultSessionAuthGuard is a global APP_GUARD and only routes explicitly marked @Public() (login, register, the liveness root) skip it, so a future controller added without thinking about auth yields 401s instead of a silently public endpoint (per-route guards were removed so the global one is load-bearing and proven by the existing 401 e2e tests). Session validation is now atomic (validity re-checked in the same UPDATE … RETURNING that stamps last_seen_at, closing the TOCTOU window) with a 60s read-only debounce that takes the per-request write off the hot path; session listing filters expired rows (+ index); the current-session lookup is a single query; and TRUST_PROXY makes session.ip record the real client behind a reverse proxy (off by default — fail closed). Still open in #68: login/register rate limiting, cross-site CSRF posture, and the token-free cookie response.
  • Closed out the #55 streaming-loop hardening deferrals (#73): in_reply_to integrity now holds at the database (a trigger rejects replies linked across chats or to non-user messages, whichever code path writes them — proven with negative tests); the e2e fake model client aborts on the abort event (not a post-hoc poll), with a new fidelity test proving a mid-stream abort fires onError, never onFinish, and persists no partial text; and the unit fake now fires onFinish on stream consumption (pull-driven), matching real AI SDK timing. Single-flight, the fourth item, shipped with #48.
  • Per-chat single-flight (#48, closing its acceptance list): a partial unique index admits at most one non-terminal run per chat — the DB-level guarantee against concurrent double model calls (#73, deferred from #55). A different message sent while a run is in flight gets a clean 409 with its whole transaction rolled back; a retry of the same message supersedes its prior attempt (cancelled + evented + in-process abort) so a silently-died turn is always retryable. markStarted and worker pickup now refuse terminal runs, so a superseded queued run can never be resurrected. The v0.1 "overlapping turns" e2e was rewritten to the new serialized contract.
  • Zombie runs now expire (#48 heartbeat + timeout): the executing worker stamps a per-run heartbeat, and every enqueued run gets its own delayed deadman job (pg-boss startAfter — no cross-tenant reaper scan, so the RLS moat stays intact): terminal runs are left alone, fresh-heartbeat runs are re-checked later, and a run whose heartbeat went stale (worker crash/hang) is marked expired with a run.failed event. Terminal statuses are now immutable at the repository level (first writer wins), so a late-finishing stream can never overwrite expired/cancelled. All knobs configurable (RUN_TIMEOUT_SECONDS, RUN_HEARTBEAT_STALE_SECONDS, RUN_HEARTBEAT_SECONDS); proven in worker-mode e2e with a hand-crafted zombie.
  • Runs are cancellable (#48): PATCH /api/v1/runs/:id with {status: "cancelled"} (resource PATCH per house REST rules, not a verb handle) stamps cancel_requested_at — the durable, cross-process signal — and aborts the in-process controller when the run is executing locally. A still-queued run is settled as cancelled at worker pickup without touching the model; a mid-flight run aborts through the same path a client abort used in inline mode. Idempotent re-cancel returns 200, a finished run 409, cross-tenant 404 — all proven in worker-mode e2e.
  • Runs can now execute in a queue worker (#48/#50, flag-gated): with RUN_EXECUTION_MODE=worker, POST /chats/:id/messages only validates, stores, creates the run, and enqueues it on pg-boss; a co-located consumer drives the identical RunExecutionService, and the HTTP response streams from the durable run-event log through a new UI-message bridge speaking the AI SDK protocol — the existing web client works unchanged, and closing the connection mid-run no longer kills the turn (proven by a disconnect e2e: the run completes, the assistant message persists). Default stays inline pending soak; cancellation, heartbeat/timeout, and flipping the default remain in #48/#50.
  • Extracted run execution out of the HTTP path (staging #50): a new transport-agnostic RunExecutionService owns context assembly, the model call, and every durable side effect (assistant turn, run lifecycle + delta events, post-turn compaction/titling); ChatLoopService shrinks to the SPEC §9.5 API-side steps — validate, store message, create run, hand off. Behavior-preserving (full e2e parity); the worker move (#50) now swaps one hand-off call for an enqueue.
  • Added the llame vision document and linked it from agent context, clarifying the platform bets, current focus, emerging directions, and near-term non-goals; bumped the default OpenAI model to gpt-5.4-mini and added telemetry pricing for that default.
  • Added test CI (#70): a GitHub Actions workflow gates every PR (and pushes to master) on turbo run lint, turbo run build, the api unit suite, and apps/api/scripts/rls-test.sh — the cross-tenant RLS proof and HTTP e2e against a throwaway Postgres, same script as local. Actions are SHA-pinned, permissions: contents: read, actionlint + zizmor clean. Standing up root lint surfaced that packages/ui's lint had been silently broken forever (no eslint devDependency) — fixed, along with the three warnings it had been hiding.
  • Chats are titled on the server again (#78, regression from the #63 thin-client cutover): after the first completed turn, a cheap post-turn model call names the still-untitled chat from the user's message (2–5 words, sanitized). Untitled is a first-class state — chats.title is now nullable and NULL means "awaiting generation"; clients render their own (localizable) placeholder, the DB never stores a display literal, and the atomic WHERE title IS NULL guard means a user rename mid-generation always wins. Same fire-and-forget post-turn shape as compaction — both ride into the durable-run worker with the loop (#50).
  • Added the minimal Q&A eval set (#58) — the last v0.1 line item: happy-path, prompt-injection, and overflow/compaction cases run the real loop over HTTP against a real model; double-gated behind RUN_MODEL_EVALS=1 so CI and rls-test.sh never spend tokens (pnpm --filter api test:evals). All three verified green live against OpenAI — the overflow case doubles as an end-to-end integration proof of provider config (#88) + compaction (#57): the chat compacts mid-conversation and a fact from the absorbed turns survives via the summary.
  • Added lineage-based conversation context compaction (#57): when a chat's live context passes the trigger threshold, a post-turn model call summarizes the older turns into a first-class compactions row that records exactly what it supersedes (upto_seq) and chains to the compaction it absorbed (parent_id) — Hermes-style auditable lineage; messages are never deleted or mutated. The trigger prefers the real token usage the provider reported for the just-finished turn (char-estimate fallback), and the threshold derives from the model's context window (80%, via a small built-in catalog or MODEL_CONTEXT_WINDOW_TOKENS) with COMPACTION_TOKEN_THRESHOLD as explicit override. The summarization request is a cache-aligned continuation of the chat itself — same system prompt and history rendering as the turn that just ran, summarize instruction as the final user message — so the absorbed bulk is a provider prompt-cache read, not a fresh prefill. The next turn's context is summary + recent turns; the summarization call runs outside any DB transaction with a staleness guard against concurrent compactions. The pre-compaction most-recent-100 message cap is removed: a count cap silently drops old turns without any summary covering them whenever many short messages stay under the token threshold — tokens are the only context budget now. The new table ships with RLS ENABLE+FORCE and cross-tenant read/write denial proven in the RLS integration suite.
  • Made the chat loop's OpenAI-compatible provider configurable (#88): OPENAI_BASE_URL and OPENAI_MODEL env vars on apps/api point dev and the upcoming eval suite (#58) at any OpenAI-compatible endpoint (OpenRouter free tier, groq, a local model) instead of hardcoded paid api.openai.com; documented the OpenRouter setup in .env.example. A v0.1 dev/eval stopgap — the native OpenRouter provider and BYOK credential vault remain v0.4 (#37/#82).

2026-07-01

  • Added per-chat deep links for the web chat (#77): /chat/[id] now server-loads persisted history through apps/api, sidebar chat rows navigate to stable chat URLs, New Chat resets to / with a fresh draft id, and SSR history reads are bounded by a short timeout instead of waiting indefinitely on a stalled API.

2026-06-30

  • Upgraded the Vercel AI SDK off its pre-stable beta line: ai 5.0.0-beta.12 → 6.0.217, @ai-sdk/react → 3.0.219 (apps/web), @ai-sdk/openai → 3.0.79 (apps/api), staged through v5-stable and v6 with @ai-sdk/codemod for the v6 hop. Stopped at v6 rather than v7: ai@7.0.0 dropped CommonJS support entirely (ESM-only, no require export condition), which apps/api's NestJS/CommonJS build can't consume without a module-system migration — deferred to whenever the durable-run worker (#50) is built, since that's a new process that can reasonably start as ESM. apps/api's ContextBuilder now delivers the chat's system prompt via streamText's native system param instead of a role: 'system' entry in messages (the AI SDK warns on the latter as of v6, and v7 rejects it outright).
  • Refreshed the packages/ui shadcn/ui kit to current upstream: migrated all primitives from the individual @radix-ui/react-* packages to the unified radix-ui package, re-pulled the latest component source (new Button xs/icon-* sizes and data-variant/data-size, flatter default surfaces), and bumped lucide-react 0.475 → 1.x. No design-token changes — globals.css stays monochrome.
  • Added shadcn staple components to @workspace/ui: badge, tabs, switch, spinner, toggle, toggle-group, and alert-dialog.
  • Fixed the collapsed-sidebar user avatar squashing into a vertical rectangle: the trigger now uses SidebarMenuButton size="lg" (which zeroes padding when collapsed) instead of a manual h-12, so the 8×8 avatar stays square in icon mode.
  • Replaced the hand-rolled <kbd> shortcut hints in the sidebar with @workspace/ui's official Kbd component, surfaced both inline (on hover, expanded) and in the collapsed-state tooltip — using the same has-data-[slot=kbd] flex-gap idiom shadcn applies on Button, since TooltipContent doesn't ship it by default.
  • Added per-assistant-turn telemetry in apps/api (#56): assistant messages now persist token usage including cached input tokens and reasoning tokens, model/provider, latency, finish reason/status, and best-effort costUsd; completed turns emit a structured pino trace keyed by chat/message ids without message content.
  • First message now creates the chat in one call (#86): POST /api/v1/chats/:id/messages upserts the chat for a client-supplied id before streaming (idempotent createIfAbsent, mirroring the user-message upsert). The id is routing/idempotency only — the owner stays server-derived, and a cross-tenant id collision returns 404 (no hijack, no existence leak), proven by RLS-integration and e2e tests. Eliminates the empty-chat orphan left behind when a first send failed (e.g. the 402 no-model-key case, which now persists nothing). apps/web drops the create-then-stream machinery (the queuedMessage/queuedChatId queue and the remount-on-activeChatId dance): it mints the chat id up front and keys the session by it, so adopting the id on first send streams without a remount. Dropped the now-unused POST /api/v1/chats empty-chat endpoint — chats are created exclusively by their first message.
  • Added Playwright browser E2E coverage for the auth cutover (#79): the Playwright harness starts a throwaway Docker Postgres, applies migrations, starts apps/api + apps/web, reuses worker-scoped authenticated storage state, and verifies login success/failure, callback redirect safety, no-cookie redirects, logout, and revoked-session redirect behavior.
  • Completed the apps/web thin-client cutover (#63): removed its database, NextAuth adapter/JWT, and the LangGraph chat/models routes — the browser now calls apps/api directly at NEXT_PUBLIC_API_URL for /auth/v1 (login/register/logout) and /api/v1 (chats + streaming). Layered auth-state (middleware cookie-presence gate → authoritative api guard → client 401 interceptor; GET /auth/v1/me as source of truth), with one shared 401 handler across the ky client and the AI SDK chat transport. Added config-driven CORS allowlist + session-cookie Domain on apps/api.
  • Added the apps/api single-model streaming chat loop (#55): guarded POST /api/v1/chats/:id/messages, server-authoritative context, idempotent client message ids, AI SDK UI-message SSE streaming, assistant persistence with usage, and abort/cross-tenant/fail-fast e2e coverage.

2026-06-29

  • Shipped the v0.1 multi-tenant chat foundation (#53, #59): chats/messages schema (AI SDK v5 role+parts, sender-attributed) with a monotonic seq ordering key, a chat_visibility enum, and a deterministic, cache-aware ContextBuilder.
  • Row-Level Security ENABLEd and FORCEd on chats/messages, engaged per request via TenantDbService.runAs (transaction-local app.current_user_id); cross-tenant isolation proven against real Postgres (apps/api/scripts/rls-test.sh).
  • Local dev database via docker-compose (pnpm db:up / db:migrate / db:studio / db:psql / db:reset), provisioning a non-superuser app role so RLS is exercised as in production.
  • Added the apps/api /auth/v1 surface (#60): register, login, current user, and revocable server-side session resources backed by opaque tokens hashed at rest.
  • Security: re-exposed chat HTTP endpoints under /api/v1 only behind verified sessions, so TenantDbService.runAs is fed by trusted auth context instead of client-supplied ownerUserId.

2026-06-28

  • Authored the product specification (SPEC.md) and refined it to v0.3: single TypeScript stack, Postgres-first architecture, corrected single-SKILL.md skill format — verified via a multi-reviewer pass.
  • Added hierarchical CLAUDE.md context files (root + apps/web, apps/api, packages/ui).
  • Pinned Next.js to 15.5.19 for stable Node middleware; documented OpenAI/Anthropic API keys in .env.example.

2025-10-20

  • Dependency updates (Next.js, axios).

2025-07-29

  • Moved the database out of the Next.js app into the NestJS API.

2025-07-28

  • Scaffolded the NestJS API app.
  • Chat error display; Alert UI component.

2025-07-18

  • Experimented with multi-agent / expert-supervision orchestration.

2025-07-16

  • Persist and fetch user chats via the API/DB.
  • Agent supervisor/orchestrator and ReAct agent for chat.
  • Added Sentry.

2025-07-15

  • User info in the sidebar.

2025-07-14

  • Theme switch and font-family setting (incl. OpenDyslexic), with server-side cookie persistence.
  • Model preview card in the selector; upgraded AI SDK to beta.

2025-07-09

  • Per-message model selection; styled messages, auto-scroll container, and message components; dropped the completions PoC.

2025-07-03

  • Stateless chat PoC; test chat + completions APIs; message-input, code-block, and markdown components.

2025-07-02

  • Models API + query; PoC conversation tree; fixed the auth DB connection in middleware.

2025-06-30

  • Core chat UI shell: sidebar (mock chats/projects), model selector, and shadcn UI kit (dialog, popover, command, dropdown, sidebar).
  • React Query wiring; simple auth/register pages.

2025-06-29

  • Project bootstrapped (shadcn/ui monorepo); Sonner toaster.