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 underapps/api/src/lib/agentRuntime/.apps/web(@taskwise/web) — React 19 · Vite 5 · Tailwind v4 · TanStack Query 5, with a custompushStaterouter (no React Router) and server state in React Query (no Redux); the centrepiece component isV2ChatThread.packages/chat-ui(@aisolver/chat-ui) — the shared chat UI, consumed asworkspace:*.
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:
| Rule | Scope | What it forbids | Gate |
|---|---|---|---|
routes↛routes | routes/ and v2/routes/ | a route file importing another route file — with a Stage-6B exception for co-located module composition | exit 1 |
lib↛routes | lib/ and v2/lib/ | the lower layer importing the upper | exit 1 |
| static import-cycle ceiling | all of apps/api/src | any file inside a static import cycle; ceiling CYCLE_CEILING = 0 (override ARCH_CYCLE_MAX) | exit 1 |
| legacy-path ban | all of apps/api/src | any relative specifier referencing the old agentWorker or v2/lib paths | exit 1 |
raw-anthropic-fetch | all of apps/api/src | hitting api.anthropic.com/v1/messages off a 6-entry allowlist | exit 1 |
| cycles / LOC / mixed-SCC reports | all of apps/api/src | nothing — visibility only | never |
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 compile — tsc 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 (resume →
resumed/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.