AZ
Back to constellation
Tools / LabsPublished

Aisolver

A collaborative task/project platform with built-in AI agents that act on your data — a pnpm monorepo whose ~320-line architecture lint fails the build on any boundary or cycle violation.

React 19Vite 5TypeScriptTailwind v4Fastify 5node-pg (no ORM)PostgreSQL 17WebSocketZod
Read the write-up

Overview

What it is

Aisolver (package taskwise-v2, repo taskwise-dev) undersells itself as "a rebuilt task manager." It is a collaborative task- and project-management platform with built-in AI agents that act on the user's own data — its own architecture doc frames it as "Todoist + Notion + a team of AI assistants that can actually do the work." Every user gets a personal butler agent, can hire specialist agents, group them into squads, and share them through workteams. The shipped surface (live on prod) spans a chat pipeline, Agent Skills, sandboxed artifacts, lists/tasks, files/docs/sheets, drives, contacts, a calendar, a durable orchestration/job engine, BI dashboards, business-process flowcharts, an admin console, and IMAP/SMTP mail with an email assistant. The classic task-manager surface — nested lists, drag-drop, a calendar, alarms, trash, invite-code registration — is the substrate the agents operate on, not the whole product.

The monorepo shape

It's a pnpm workspace (apps/*, packages/*) with two deployables and a shared package:

  • apps/api (@taskwise/api) — Fastify 5 · TypeScript ESM · node-pg · PostgreSQL 17, no ORM. The AI turn pipeline, the tool registry ("tool book"), Agent Skills ("skill book"), sandboxed artifacts, squad routing, and the orchestration/job engine all live under apps/api/src/lib/agentRuntime/.
  • apps/web (@taskwise/web) — React 19 · Vite 5 · Tailwind v4 · TanStack Query 5, with a custom pushState router (no React Router) and server state in React Query (no Redux); the centrepiece component is V2ChatThread.
  • packages/chat-ui (@aisolver/chat-ui) — the shared chat UI, consumed as workspace:*.

Two kernels hold a codebase this size together — a sql tagged-template that makes unparameterized SQL a compile error, and a WebSocket bus that survives reconnects — but the thing I reach for first when I open the repo is the architecture lint.

The architecture lint that fails the build

The centrepiece of the discipline is tools/arch-check.ts: a zero-dependency (node:fs + node:path only) guard, run as pnpm arch:check, that exits 1 on any violation so CI and the pre-push hook fail. "Please don't import the database from the route layer" is a code-review plea that erodes under deadline; a red check does not get tired. It enforces five distinct classes of decay and emits three non-gating visibility reports:

RuleScopeWhat it forbidsGate
routes↛routesroutes/ and v2/routes/a route file importing another route file — with a Stage-6B exception for co-located module compositionexit 1
lib↛routeslib/ and v2/lib/the lower layer importing the upperexit 1
static import-cycle ceilingall of apps/api/srcany file inside a static import cycle; ceiling CYCLE_CEILING = 0 (override ARCH_CYCLE_MAX)exit 1
legacy-path banall of apps/api/srcany relative specifier referencing the old agentWorker or v2/lib pathsexit 1
raw-anthropic-fetchall of apps/api/srchitting api.anthropic.com/v1/messages off a 6-entry allowlistexit 1
cycles / LOC / mixed-SCC reportsall of apps/api/srcnothing — visibility onlynever

The v2 chat pipeline was, for a while, the most active subtree and completely unguarded; Stage 4 of the audit remediation turned the route/lib roots into sets spanning both v1 and v2 so the boundary rules cover it too.

How the lint reasons about a cycle

The import-cycle rule is the interesting one. It builds the full intra-repo static import graph over all of apps/api/src (not just the route/lib boundary), runs an iterative Tarjan SCC (iterative precisely to avoid a stack overflow on a deep graph), and fails if the count of files sitting in any strongly-connected component of size ≥ 2 exceeds the ceiling. The ceiling is 0, and a comment records the burn-down as each cut landed:

// 0 — apps/api/src has NO static import cycles. Any new one fails the build.
// (History: 106 pre-H5 → 43 mixed-edge → 13 static after the H5 keystone/
// turnLocks cuts → 10 after the squadRouting vocative-leaf extraction → 0
// after lazy-loading synthesis→dispatch.)

Only static edges count. Dynamic import() is the sanctioned way to break a static cycle — it defers to runtime and creates no module load-order / TDZ hazard, which is the actual concern — so counting it would over-report a deliberately dynamic-broken edge as a violation. The legacy-path ban and the raw fetch guard, by contrast, scan all import kinds (static, dynamic, and side-effect import '…').

The Stage-6B exception is how the lint allows some route→route imports without opening the floodgates: a route god-file may be decomposed into a co-located routes/modules/<name>/ subtree, and only two edges are legal — a sibling within the same module dir, and the thin composer routes/<name>.ts importing its own module. Everything else stays a violation:

function isSanctionedModuleImport(file: string, target: string): boolean {
  const tm = moduleNameOf(target);
  if (!tm) return false;
  if (moduleNameOf(file) === tm) return true; // sibling within the module
  return file.replace(/\\/g, '/').endsWith(`/routes/${tm}.ts`); // the module's composer
}

The last rule is a domain-specific invariant, not a generic layering one: every non-streaming Claude call must go through the resilient callClaude wrapper (credit accounting, 429/retry, telemetry), so any file hitting the Anthropic messages endpoint that isn't one of six intentional raw callers is a violation. The allowlist is documented inline with why each entry is exempt — the streaming turn has its own retry, the skills path needs Files-API hosts callClaude can't serve, and so on.

The sql tagged-template kernel

Every SQL statement platform-wide is a sql`…${v}…` SqlFragment; the string overloads were deleted so raw-string SQL does not compiletsc is the gate. sql.raw is the sole non-binding splice, guarded by its own checker with an allowlist ledger, and a corpus tap records {text, values} at the pg boundary for byte-identity proofs. Parameterization stops being a habit you can forget and becomes a property the type system enforces.

realtimeBus — WebSocket with replayable history

Live updates were migrated off SSE onto WebSocket; the legacy in-process SSE subscriber map was removed outright. The replacement, realtimeBus, is a WS-native per-user notification bus (@fastify/websocket) built as a swappable interface so a future cross-process Redis-Streams/NATS migration is a single-file swap. Per user it keeps a Set<WebSocket> (multi-tab safe), a ring buffer of recent frames (RING_SIZE = 500, TTL-trimmed at 5 min) for reconnect replay, and a monotonic serial. Every frame embeds a process-wide processStartMs captured at module load, so a client reconnecting after a server restart is told the buffer is gone and falls back to a refetch — an at-least-once protocol with explicit gap detection via a resume handshake (resumeresumed/resume_unavailable). Ephemeral frames (presence, editing, typing) are delivered live but not retained, so a resume never resurrects a stale "✏️ editing" pill.

Testing, deploy & security posture

Web unit tests use Vitest + Testing Library + happy-dom; API contract tests run through two harnesses — tools/api-sim (a fast, deterministic REST+DB suite with no LLM, the everyday gate) and a slow, nondeterministic tools/chat-sim for agent behaviour — under a "no flaky tests" policy, and arch:check is a hard CI gate. Security: invite-gated signup, admin pinned to a single uid + 2FA, sanitize-html on inbound mail, parameterized SQL enforced by the sql kernel, and a per-operation permission kernel. Deploy is AWS/EKS via three ordered Terraform stacks (bootstrap → envs/dev → cicd) sharing one S3 backend, with a private-only EKS API, RDS PostgreSQL, a CloudFront+S3 SPA, IRSA roles, and External Secrets Operator.

Related write-ups