Reverse-chronological record of shipped work — features, fixes, and chores. Newest first.
- 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). Apromptstable (owner-scoped, FORCE RLS,UNIQUE(user_id, name), migration 0015) +/api/v1/me/promptsCRUD (GET/POST/PATCH/DELETE), mirroring the memories pattern — thenameis a slug (^[A-Za-z0-9_-]+$, DB CHECK) so/<name>is unambiguous, and a duplicate name is a real 409 (DB unique + a23505-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 sharedusePromptsQuery()(fetched once, filtered per keystroke, invalidated by a settings edit). The core-composer change is minimal + backward-compatible:PromptInputTextareanow calls a passedonKeyDownfirst and bails if itpreventDefaulted (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 underpg_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 pureextractPlaceholders/fillPlaceholders. A two-reviewer round hardened it: substitution is a SINGLEString.replace(regex, callback)pass over the original body (so a value that itself contains{{x}}is never re-expanded); the/menu is dismissed viasetDismissedFor(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 breaklabel[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 thatpg_isreadysucceeds 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 hitCONNECT_TIMEOUT.
-
Fixed
pnpm --filter web devin git worktrees after the Next 16/Turbopack upgrade: the script now launches Next from the monorepo root withapps/webas the project directory, avoiding Turbopack's mixed-root module graph that made authenticated chat pages fail withCannot 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/nodelifted to ^22 matching the runtime floor), and migratedonlyBuiltDependenciesto the reviewedallowBuildsmap (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@typesidentities 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-sinkexamples, current docs, comparable OSS repos) and fixed what it surfaced. Two real bugs:apps/api's compiled entrypoint was nested underdist/src/(root-leveldrizzle.config.tspollutedrootDir), sostart:prod'snode dist/maincould 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.jsondeclaresdist/**+openapi.jsonoutputs — previously the api build was silently uncacheable;apps/web/turbo.jsonowns the.next/**outputs and scoped env), trimmedglobalEnvto truly-global vars,testis a first-class cached turbo task (with the DB/eval gate vars in its hash), and CI persists the turbo cache viaactions/cache— a warmturbo buildis now FULL TURBO (~2s). Also:typedRoutesenabled inapps/web(the open-redirect sanitizer and nav hrefs now carryRoutetypes), stale$schemaURL updated, root package renamedshadcn-ui-monorepo→llame, contradictory pnpmignoredBuiltDependenciesentry dropped. -
Chat-list previews show the real latest message:
GET /api/v1/chatsitems now carry alastMessage(role + text-only excerpt truncated server-side + timestamp; null only for the unreachable no-messages case). OneDISTINCT ONquery fetches the latest message per owned chat, owner-scoped through the chats join with cross-tenant isolation re-proven in the RLS integration suite; theapps/webnested chats sidebar renders the excerpt in place of its placeholder. -
Hardened the e2e Postgres bootstrap:
pg_isreadycan answer during initdb's temporary server and the follow-uppsqlthen lands in the restart gap — readiness now requires consecutive successful checks.
-
Redesigned the
apps/webshell 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 existingsidebar_statecookie; 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_atonce registered; (2)executeRunignoredmarkStarted'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 runand unsafeString()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_tointegrity 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) firesonError, neveronFinish, and persists no partial text; and the unit fake now firesonFinishon 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.
markStartedand 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 markedexpiredwith arun.expiredevent. Terminal statuses are now immutable at the repository level (first writer wins), so a late-finishing stream can never overwriteexpired/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/:idwith{status: "cancelled"}(resource PATCH per house REST rules, not a verb handle) stampscancel_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 ascancelledat 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/messagesvalidates, 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 itsRUN_EXECUTION_MODEflag) is removed: one execution path, one set of semantics. Consequences accepted at this stage and tracked: the web Stop button cancels via the upcomingPATCH /runs/:idwiring (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
RunExecutionServiceowns context assembly, the model call, and every durable side effect (assistant turn, run lifecycle + delta events, post-turn compaction/titling);ChatLoopServiceshrinks 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/apiinto feature-directory modules:runs/,compaction/, andtitles/move out of thechats/grab-bag into their own directories with real NestJS modules (RunsModuleread 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.deltaretention, and the deadman sweep appendingrun.expired. -
Landed the durable-run substrate (#48, first slice):
runsand append-onlyrun_eventstables (SPEC §9.3–§9.4) with RLSENABLE+FORCEand 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.created→run.started→model.requested→model.completed→run.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.deltaevents (size-buffered via a pure delta-buffer, ordered by a sequential write chain), and a new run read surface exposesGET /api/v1/runs/:idplus the SPEC §9.4 cursor SSEGET /api/v1/runs/:id/events?after_sequence=N— each frame's SSEid: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). Theapps/webresume-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
Queueinterface (QUEUEtoken: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>.deaddead-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-versionand.nvmrcare now the single source of truth (rootengines.nodeand CI'sactions/setup-nodeboth read.node-version); the dev toolchain also gets a committed Nix flake (flake.nix/flake.lock,nodejs_22+pnpm) with.envrcfor direnv, sonix developor direnv gives a reproducible shell without touching the host Node install. -
Type-checking now runs on tsgo (the TypeScript 7 Go port,
@typescript/native-previewpinned):apps/web'stypecheckdrops from ~6s to ~1s, andapps/apigains atypecheckscript it never had (~0.6s) — closing the hole that let six latent spec type errors survive (specs are excluded fromnest build, and nothing else built the full program). CI gatesturbo run typecheck. Emit/build toolchains stay on TypeScript 5.x; tsgo is check-only.apps/web's tsconfig dropsbaseUrl(removed in TS7; itspathswere already tsconfig-relative, tsc 5.x semantics unchanged). -
Added lefthook pre-commit hooks (installed via the root
preparescript onpnpm 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-incorrectnesscategory 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.jsonnow pinscorrectness: 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 lintOOM'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 (therecommended-type-checkedequivalents, same warn/off overrides) viaoxlint-tsgolint, which runs on tsgo — the official TypeScript 7 compiler — so typed rules likeno-floating-promises/no-unsafe-*keep tsc-fidelity type information (~0.5s).apps/webandpackages/uigate with--deny-warningsas before;packages/config-eslintand 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 rootformat:checkgatesapps/api/{src,test}in CI, the surface the removedeslint-plugin-prettierused to enforce. Fallout the switch surfaced and fixed:model-client.spec.tscarried six latent type errors nothing ever checked (specs are excluded fromnest buildand ts-jest didn't flag them; tsgolint builds the full program), api'stsconfig.jsondropsbaseUrl(tsgo removed it) and declares"types": ["node", "jest"]explicitly, and a stray unused-.eslintrc.jsat the repo root plus a stale Biome VS Code recommendation are gone. -
Upgraded
apps/webto Next.js 16 (15.5.19 → 16.2.10), following the official upgrade guide:middleware.tsrenamed toproxy.ts(same cookie-presence gate; proxy always runs on the Node.js runtime, so the explicitruntimeconfig is gone), Turbopack is now the default for bothnext devandnext build(dropped the--turbopackflag), and the removednext lintcommand is replaced by running ESLint directly (eslint . --max-warnings 0, same flat config). Along for the ride because Next 16 requires them:@sentry/nextjs9 → 10 (v9 does not peer-support Next 16; v10'swithSentryConfigis Turbopack-aware) and React pinned to ^19.2, plus@next/eslint-plugin-next15 → 16 in the shared ESLint config. Async request APIs needed no changes — the app already awaitedparamsandcookies().
- Fixed broken live streaming in the UI (regression from the #50 worker-default flip, user-reported): the delta buffer coalesced model tokens into
model.deltaevents 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_atonce registered; (2)executeRunignoredmarkStarted'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 runand unsafeString()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 flippingRUN_EXECUTION_MODE's default (#50). Along the way, fixed a latent #88 bug: the model client hit OpenAI's proprietary/responsesendpoint, 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):
DefaultChatTransportnow carries aprepareReconnectToStreamRequestpointing atGET /chats/:id/stream, and persisted chats mount withresume: 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/streamreturns 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 — or204when there is nothing to resume (a cross-tenant or unknown chat id answers the same 204: no existence leak). Matches the AI SDK v6reconnectToStreamtransport contract, so theapps/webhookup 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 onlogin/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 default —
SessionAuthGuardis a globalAPP_GUARDand 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 sameUPDATE … RETURNINGthat stampslast_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; andTRUST_PROXYmakessession.iprecord 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_tointegrity 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 firesonError, neveronFinish, and persists no partial text; and the unit fake now firesonFinishon 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.
markStartedand 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 markedexpiredwith arun.failedevent. Terminal statuses are now immutable at the repository level (first writer wins), so a late-finishing stream can never overwriteexpired/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/:idwith{status: "cancelled"}(resource PATCH per house REST rules, not a verb handle) stampscancel_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 ascancelledat 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/messagesonly validates, stores, creates the run, and enqueues it on pg-boss; a co-located consumer drives the identicalRunExecutionService, 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 staysinlinepending 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
RunExecutionServiceowns context assembly, the model call, and every durable side effect (assistant turn, run lifecycle + delta events, post-turn compaction/titling);ChatLoopServiceshrinks 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-miniand added telemetry pricing for that default. - Added test CI (#70): a GitHub Actions workflow gates every PR (and pushes to
master) onturbo run lint,turbo run build, the api unit suite, andapps/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 thatpackages/ui's lint had been silently broken forever (noeslintdevDependency) — 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.titleis now nullable and NULL means "awaiting generation"; clients render their own (localizable) placeholder, the DB never stores a display literal, and the atomicWHERE title IS NULLguard 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=1so CI andrls-test.shnever 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
compactionsrow 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 orMODEL_CONTEXT_WINDOW_TOKENS) withCOMPACTION_TOKEN_THRESHOLDas 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 RLSENABLE+FORCEand cross-tenant read/write denial proven in the RLS integration suite. - Made the chat loop's OpenAI-compatible provider configurable (#88):
OPENAI_BASE_URLandOPENAI_MODELenv vars onapps/apipoint dev and the upcoming eval suite (#58) at any OpenAI-compatible endpoint (OpenRouter free tier, groq, a local model) instead of hardcoded paidapi.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).
- Added per-chat deep links for the web chat (#77):
/chat/[id]now server-loads persisted history throughapps/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.
- Upgraded the Vercel AI SDK off its pre-stable beta line:
ai5.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/codemodfor the v6 hop. Stopped at v6 rather than v7:ai@7.0.0dropped CommonJS support entirely (ESM-only, norequireexport condition), whichapps/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'sContextBuildernow delivers the chat's system prompt viastreamText's nativesystemparam instead of arole: 'system'entry inmessages(the AI SDK warns on the latter as of v6, and v7 rejects it outright). - Refreshed the
packages/uishadcn/ui kit to current upstream: migrated all primitives from the individual@radix-ui/react-*packages to the unifiedradix-uipackage, re-pulled the latest component source (newButtonxs/icon-*sizes anddata-variant/data-size, flatter default surfaces), and bumpedlucide-react0.475 → 1.x. No design-token changes —globals.cssstays monochrome. - Added shadcn staple components to
@workspace/ui:badge,tabs,switch,spinner,toggle,toggle-group, andalert-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 manualh-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 officialKbdcomponent, surfaced both inline (on hover, expanded) and in the collapsed-state tooltip — using the samehas-data-[slot=kbd]flex-gap idiom shadcn applies onButton, sinceTooltipContentdoesn'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-effortcostUsd; 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/messagesupserts the chat for a client-supplied id before streaming (idempotentcreateIfAbsent, 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/webdrops the create-then-stream machinery (thequeuedMessage/queuedChatIdqueue and the remount-on-activeChatIddance): 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-unusedPOST /api/v1/chatsempty-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/webthin-client cutover (#63): removed its database, NextAuth adapter/JWT, and the LangGraph chat/models routes — the browser now callsapps/apidirectly atNEXT_PUBLIC_API_URLfor/auth/v1(login/register/logout) and/api/v1(chats + streaming). Layered auth-state (middleware cookie-presence gate → authoritative api guard → client401interceptor;GET /auth/v1/meas source of truth), with one shared 401 handler across the ky client and the AI SDK chat transport. Added config-driven CORS allowlist + session-cookieDomainonapps/api. - Added the
apps/apisingle-model streaming chat loop (#55): guardedPOST /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.
- Shipped the v0.1 multi-tenant chat foundation (#53, #59):
chats/messagesschema (AI SDK v5role+parts, sender-attributed) with a monotonicseqordering key, achat_visibilityenum, and a deterministic, cache-awareContextBuilder. - Row-Level Security
ENABLEd andFORCEd onchats/messages, engaged per request viaTenantDbService.runAs(transaction-localapp.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/v1surface (#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/v1only behind verified sessions, soTenantDbService.runAsis fed by trusted auth context instead of client-suppliedownerUserId.
- Authored the product specification (SPEC.md) and refined it to v0.3: single TypeScript stack, Postgres-first architecture, corrected single-
SKILL.mdskill format — verified via a multi-reviewer pass. - Added hierarchical
CLAUDE.mdcontext 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.
- Dependency updates (Next.js, axios).
- Moved the database out of the Next.js app into the NestJS API.
- Scaffolded the NestJS API app.
- Chat error display;
AlertUI component.
- Experimented with multi-agent / expert-supervision orchestration.
- Persist and fetch user chats via the API/DB.
- Agent supervisor/orchestrator and ReAct agent for chat.
- Added Sentry.
- User info in the sidebar.
- Theme switch and font-family setting (incl. OpenDyslexic), with server-side cookie persistence.
- Model preview card in the selector; upgraded AI SDK to beta.
- Per-message model selection; styled messages, auto-scroll container, and message components; dropped the completions PoC.
- Stateless chat PoC; test chat + completions APIs; message-input, code-block, and markdown components.
- Models API + query; PoC conversation tree; fixed the auth DB connection in middleware.
- 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.
- Project bootstrapped (shadcn/ui monorepo); Sonner toaster.