Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ cli/package.dev.json
server/src/**/*.js
server/src/**/*.js.map
server/src/**/*.d.ts
!server/src/types/express.d.ts
server/src/**/*.d.ts.map
tmp/
feedback-export-*
Expand All @@ -50,3 +51,7 @@ tests/release-smoke/test-results/
tests/release-smoke/playwright-report/
.superset/
.claude/worktrees/

# Vercel
.vercel/
api/index.js
7 changes: 7 additions & 0 deletions api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const config = {
maxDuration: 60,
};

import taskcoreVercelHandler from "../server/src/vercel.ts";

export default taskcoreVercelHandler;
2 changes: 2 additions & 0 deletions doc/DEPLOYMENT-MODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,5 @@ This prevents lockout when a user migrates from long-running local trusted usage
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
- V1 contract: `doc/SPEC-implementation.md`
- operator workflows: `doc/DEVELOPING.md` and `doc/CLI.md`
- Vercel deployment: `doc/VERCEL.md`

97 changes: 97 additions & 0 deletions doc/VERCEL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Deploying Taskcore to Vercel

Status: Supported deployment target
Date: 2026-07-31

## 1. Overview

Taskcore can run as a Vercel project with three deployable parts:

| Part | What deploys | Where it comes from |
|---|---|---|
| **API** | A single Node serverless function at `api/index.js` | `server/src/vercel.ts` bundled by `scripts/build-vercel-function.mjs` (esbuild) |
| **UI** | Static SPA build in `ui/dist` | `pnpm --filter @taskcore/ui build` |
| **Database** | External PostgreSQL | Vercel Postgres, Neon, Supabase, or any reachable Postgres via `DATABASE_URL` |

`vercel.json` wires the three together:

- `buildCommand`: `pnpm vercel:build` — builds workspace packages, the UI, then the serverless bundle
- `outputDirectory`: `ui/dist` — static UI served from the build output
- `rewrites`: `/api/*` goes to the function; everything else falls back to `index.html` (SPA routing)
- `functions.api/index.js.maxDuration`: 60s so a cold boot (Express + better-auth + DB pool) completes before the first response

The Express app serves the API only on Vercel (`SERVE_UI=false`, `uiMode: "none"`). All UI paths are served statically by the platform.

## 2. What Works / What Does Not

Works on Vercel:

- Full `/api/*` surface (board routes, agent routes, better-auth `/api/auth/*`)
- Static board UI at the deployment root
- External PostgreSQL (`DATABASE_URL`, `POSTGRES_URL`/`POSTGRES_URL_NON_POOLING`, or `PGHOST`/`PGDATABASE`/`PGUSER`/`PGPASSWORD`)
- S3 storage via `TASKCORE_STORAGE_PROVIDER=s3` (see `doc/DATABASE.md`-adjacent storage docs)

Not available in the serverless runtime (the Vercel handler disables these):

- Embedded PostgreSQL / PGlite — `DATABASE_URL` is **required** (`server/src/vercel.ts` fails boot without it)
- Long-lived background work: heartbeat scheduler, routine scheduler, database backups, plugin workers
- Live events WebSocket channel (polling endpoints still work)
- Local-disk storage (`/tmp` is ephemeral — uploads/assets are lost between cold starts)
- Local/embedded agent adapters (Claude, Codex, etc. run as processes — they cannot run in a serverless function)

## 3. Prerequisites

- Repo pushed to GitHub, imported into a Vercel project
- An external PostgreSQL database (Vercel Postgres storage is the simplest path — it sets `POSTGRES_URL` automatically)
- Node.js 20+ and pnpm 9+ for local builds

## 4. Required Environment Variables

| Variable | Required | Purpose |
|---|---|---|
| `DATABASE_URL` (or `POSTGRES_URL` / `POSTGRES_URL_NON_POOLING` / `PGHOST`+`PGDATABASE`+`PGUSER`+`PGPASSWORD`) | Yes | External Postgres connection |
| `BETTER_AUTH_SECRET` (or `TASKCORE_AGENT_JWT_SECRET`) | Yes | Auth cookie/JWT signing secret |
| `TASKCORE_AUTH_PUBLIC_BASE_URL` (or `BETTER_AUTH_URL`) | Yes | Public deployment URL, e.g. `https://taskcore-<project>.vercel.app` |

Defaults applied automatically when `VERCEL=1` (see `applyVercelDefaults` in `server/src/vercel.ts`):

| Variable | Default | Notes |
|---|---|---|
| `TASKCORE_DEPLOYMENT_MODE` | `authenticated` | Public unauthenticated boards are rejected |
| `TASKCORE_DEPLOYMENT_EXPOSURE` | `public` | |
| `SERVE_UI` | `false` | UI is served statically by Vercel |
| `TASKCORE_PLUGINS_ENABLED` | `false` | Plugin workers cannot run serverless |
| `TASKCORE_DB_BACKUP_ENABLED` | `false` | |
| `HEARTBEAT_SCHEDULER_ENABLED` | `false` | |
| `TASKCORE_STORAGE_LOCAL_DIR` | `/tmp/taskcore-storage` | Ephemeral — set S3 storage for durable uploads |
| `TASKCORE_PG_MAX_CONNECTIONS` | `5` | Keep bounded for serverless concurrency |

Recommended: `TASKCORE_STORAGE_PROVIDER=s3` with `TASKCORE_STORAGE_S3_BUCKET`, `TASKCORE_STORAGE_S3_REGION`, and `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` so attachments and assets survive cold starts.

## 5. Deploy Steps

1. Import the repo into Vercel (framework preset: **Other**; the repo's `vercel.json` overrides settings).
2. Create an external Postgres (or attach Vercel Postgres) and set the env vars above, including the deployment URL in `TASKCORE_AUTH_PUBLIC_BASE_URL`.
3. Deploy. `pnpm vercel:build` runs on Vercel: workspace packages → UI (`ui/dist`) → serverless bundle (`api/index.js`).
4. Migrations are **not** applied by the serverless runtime. Before first use, apply the schema once:
```sh
DATABASE_URL=... pnpm db:migrate
```
5. Sign in with a real user at `https://<deployment>/api/auth/sign-in/email` (via the UI login page) — the first admin becomes the instance admin (board claim flow in `authenticated` mode).

## 6. Local Verification

```sh
pnpm vercel:build # full production build (workspace + UI + function bundle)
vercel dev # run the deployed layout locally (API function + static UI)
```

`vercel build` locally validates the exact layout (`ui/dist` static output + `api/index.js` function) that the platform will serve.

## 7. Repository Files

- `vercel.json` — platform config (build, routes, function limits)
- `server/src/vercel.ts` — serverless Express handler with Vercel runtime defaults and config guards
- `scripts/build-vercel-function.mjs` — esbuild bundler for `api/index.js`
- `packages/shared/src/vercel-postgres.ts` — `DATABASE_URL`/`POSTGRES_URL`/`PGHOST` connection resolution
- `api/index.js` — generated bundle (gitignored; built during `vercel:build`)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dev:server": "pnpm --filter @taskcore/server dev",
"dev:ui": "pnpm --filter @taskcore/ui dev",
"build": "pnpm run preflight:workspace-links && pnpm -r build",
"vercel:build": "pnpm run preflight:workspace-links && pnpm -r build && node scripts/build-vercel-function.mjs",
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
"test": "pnpm run test:run",
"test:watch": "pnpm run preflight:workspace-links && vitest",
Expand Down
12 changes: 10 additions & 2 deletions packages/db/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,16 @@ export type MigrationState =
reason: "no-migration-journal-empty-db" | "no-migration-journal-non-empty-db" | "pending-migrations";
};

export function createDb(url: string) {
const sql = postgres(url);
export type CreateDbOptions = {
max?: number;
prepare?: boolean;
};

export function createDb(url: string, options?: CreateDbOptions) {
const opts: Record<string, unknown> = {};
if (options?.max !== undefined) opts.max = options.max;
if (options?.prepare !== undefined) opts.prepare = options.prepare;
const sql = postgres(url, opts);
return drizzlePg(sql, { schema });
}

Expand Down
3 changes: 2 additions & 1 deletion packages/db/src/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolvePostgresUrlFromEnv } from "@taskcore/shared";

const DEFAULT_INSTANCE_ID = "default";
const CONFIG_BASENAME = "config.json";
Expand Down Expand Up @@ -217,7 +218,7 @@ export function resolveDatabaseTarget(): ResolvedDatabaseTarget {
const envPath = resolveTaskcoreEnvPath(configPath);
const envEntries = readEnvEntries(envPath);

const envUrl = process.env.DATABASE_URL?.trim();
const envUrl = resolvePostgresUrlFromEnv();
if (envUrl) {
return {
mode: "postgres",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export { resolvePostgresUrlFromEnv } from "./vercel-postgres.js";
export {
COMPANY_STATUSES,
DEPLOYMENT_MODES,
Expand Down
38 changes: 38 additions & 0 deletions packages/shared/src/vercel-postgres.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Resolve a PostgreSQL connection URL from the environment.
*
* Vercel exposes the database connection through several conventions:
* - `DATABASE_URL` (explicit; always wins)
* - `POSTGRES_URL` / `POSTGRES_URL_NON_POOLING` (Vercel Postgres)
* - `PGHOST`/`PGPORT`/`PGUSER`/`PGPASSWORD`/`PGDATABASE`/`PGSSLMODE` (AWS RDS integration)
*
* Returns `undefined` when no usable connection is configured.
*/
export function resolvePostgresUrlFromEnv(): string | undefined {
const direct = process.env.DATABASE_URL?.trim();
if (direct) return direct;

const pooled = process.env.POSTGRES_URL?.trim();
if (pooled) return pooled;

const nonPooling = process.env.POSTGRES_URL_NON_POOLING?.trim();
if (nonPooling) return nonPooling;

const host = process.env.PGHOST?.trim();
const database = process.env.PGDATABASE?.trim();
if (!host || !database) return undefined;

const user = encodeURIComponent(process.env.PGUSER?.trim() || "postgres");
const password = process.env.PGPASSWORD ? encodeURIComponent(process.env.PGPASSWORD) : "";
const port = process.env.PGPORT?.trim() || "5432";

const auth = `${user}${password ? `:${password}` : ""}`;
let url = `postgres://${auth}@${host}:${port}/${database}`;

const sslMode = process.env.PGSSLMODE?.trim();
if (sslMode && sslMode !== "disable") {
url += "?sslmode=require";
}

return url;
}
109 changes: 109 additions & 0 deletions scripts/build-vercel-function.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* Builds the Vercel serverless function bundle.
*
* The Taskcore control plane runs as a long-lived Express server locally. For
* Vercel we compile the server into a single self-contained ESM module that
* exports a `(req, res)` handler and place it at `api/index.js`, which Vercel
* deploys as a Node.js Function. The static UI is deployed separately from
* `ui/dist` (see `vercel.json`).
*
* Workspace packages (`@taskcore/*`) export TypeScript source, so they must be
* bundled rather than resolved at runtime. npm dependencies stay external so
* Vercel's file tracer can include them from `node_modules`.
*/
import { build } from "esbuild";
import { existsSync, readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entry = path.join(repoRoot, "server/src/vercel.ts");
const outfile = path.join(repoRoot, "api/index.js");

if (!existsSync(entry)) {
console.error(`[vercel] entry not found: ${entry}`);
process.exit(1);
}

await mkdir(path.dirname(outfile), { recursive: true });

const serverPkg = JSON.parse(
readFileSync(path.join(repoRoot, "server/package.json"), "utf8"),
);

const nativeExternal = [
"sharp",
"@img/*",
"embedded-postgres",
"@vercel/node",
"pg-native",
"vite",
"jsdom",
];

/**
* Keep every npm dependency external so the bundle stays ESM-clean (no
* esbuild CJS `require` shims that break in the Vercel Node runtime) and so
* Vercel's file tracer can include them from node_modules. Workspace
* packages (`@taskcore/*`) resolve to TypeScript source outside node_modules
* and stay bundled, which is the whole point of the single-file function.
*/
const externalizeNodeModules = {
name: "externalize-node-modules",
setup(build) {
// One shared verdict per package name so concurrent importers agree on
// external vs bundled (esbuild processes resolve callbacks in batches).
const verdicts = new Map();
const resolveVerdict = (args) => {
const existing = verdicts.get(args.path);
if (existing) return existing;
const verdict = build
.resolve(args.path, {
importer: args.importer,
resolveDir: args.resolveDir,
kind: args.kind,
})
.then((result) => {
if (result.errors.length > 0) {
return { errors: result.errors };
}
if (result.path.includes("/node_modules/")) {
return { path: args.path, external: true };
}
return null;
})
.finally(() => verdicts.delete(args.path));
verdicts.set(args.path, verdict);
return verdict;
};
build.onResolve({ filter: /^[^./]/ }, (args) => resolveVerdict(args));
},
};

try {
await build({
entryPoints: [entry],
outfile,
bundle: true,
platform: "node",
format: "esm",
target: "node20",
external: nativeExternal,
plugins: [externalizeNodeModules],
logLevel: "info",
legalComments: "none",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
"process.env.TASKCORE_SERVER_VERSION": JSON.stringify(serverPkg.version ?? "0.0.0"),
},
banner: {
js: "/* Taskcore Vercel serverless bundle. Generated by scripts/build-vercel-function.mjs - do not edit. */",
},
});
console.log(`[vercel] serverless bundle written to ${outfile}`);
} catch (err) {
console.error("[vercel] failed to build serverless bundle:", err);
process.exit(1);
}
4 changes: 2 additions & 2 deletions server/src/adapters/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ const hermesLocalAdapter: ServerAdapterModule = {
execute: hermesExecute,
testEnvironment: hermesTestEnvironment,
sessionCodec: hermesSessionCodec,
listSkills: hermesListSkills,
syncSkills: hermesSyncSkills,
listSkills: hermesListSkills as unknown as ServerAdapterModule["listSkills"],
syncSkills: hermesSyncSkills as unknown as ServerAdapterModule["syncSkills"],
Comment on lines +188 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Double unknown cast hides type mismatches between Hermes skill APIs and ServerAdapterModule.

The unknownServerAdapterModule casts bypass TypeScript’s structural checks, so any mismatch between the Hermes helpers and the expected signatures will only fail at runtime. Instead, update the Hermes helper types (or ServerAdapterModule if Hermes is canonical) so they are directly compatible without unsafe casts.

Suggested implementation:

  sessionCodec: hermesSessionCodec,
  listSkills: hermesListSkills,
  syncSkills: hermesSyncSkills,
  models: hermesModels,

To fully implement the suggestion (and surface any real type mismatches instead of hiding them), you should also:

  1. Ensure the object this snippet belongs to is explicitly typed as ServerAdapterModule, e.g. const hermesAdapter: ServerAdapterModule = { ... }. This will make TypeScript check that hermesListSkills and hermesSyncSkills match the required signatures.
  2. Update the type signatures of hermesListSkills and hermesSyncSkills in their respective modules so that they are structurally compatible with ServerAdapterModule["listSkills"] and ServerAdapterModule["syncSkills"] (parameters, return types, and async/Promise shape).
  3. If Hermes is the canonical API, adjust the ServerAdapterModule interface instead so its listSkills and syncSkills definitions match the Hermes helpers, then let this registry file rely on standard type inference without casts.

models: hermesModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: hermesAgentConfigurationDoc,
Expand Down
35 changes: 20 additions & 15 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export async function createApp(
},
) {
const app = express();
const pluginsEnabled = process.env.TASKCORE_PLUGINS_ENABLED !== "false";

app.use(express.json({
// Company import/export payloads can inline full portable packages.
Expand Down Expand Up @@ -337,8 +338,13 @@ export async function createApp(

app.use(errorHandler);

jobCoordinator.start();
scheduler.start();
if (pluginsEnabled) {
jobCoordinator.start();
scheduler.start();
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
});
}
const feedbackExportTimer = opts.feedbackExportService
? setInterval(() => {
void opts.feedbackExportService?.flushPendingFeedbackTraces().catch((err) => {
Expand All @@ -352,25 +358,24 @@ export async function createApp(
logger.error({ err }, "Failed to flush pending feedback exports");
});
}
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
});
const devWatcher = opts.uiMode === "vite-dev"
const devWatcher = pluginsEnabled && opts.uiMode === "vite-dev"
? createPluginDevWatcher(
lifecycle,
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
)
: null;
void loader.loadAll().then((result) => {
if (!result) return;
for (const loaded of result.results) {
if (devWatcher && loaded.success && loaded.plugin.packagePath) {
devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath);
if (pluginsEnabled) {
void loader.loadAll().then((result) => {
if (!result) return;
for (const loaded of result.results) {
if (devWatcher && loaded.success && loaded.plugin.packagePath) {
devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath);
}
}
}
}).catch((err) => {
logger.error({ err }, "Failed to load ready plugins on startup");
});
}).catch((err) => {
logger.error({ err }, "Failed to load ready plugins on startup");
});
}
process.once("exit", () => {
if (feedbackExportTimer) clearInterval(feedbackExportTimer);
devWatcher?.close();
Expand Down
Loading