diff --git a/.gitignore b/.gitignore index 0425a0f..da7e382 100644 --- a/.gitignore +++ b/.gitignore @@ -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-* @@ -50,3 +51,7 @@ tests/release-smoke/test-results/ tests/release-smoke/playwright-report/ .superset/ .claude/worktrees/ + +# Vercel +.vercel/ +api/index.js diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 0000000..6db9987 --- /dev/null +++ b/api/index.ts @@ -0,0 +1,7 @@ +export const config = { + maxDuration: 60, +}; + +import taskcoreVercelHandler from "../server/src/vercel.ts"; + +export default taskcoreVercelHandler; diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index ada4973..c1137fb 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -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` + diff --git a/doc/VERCEL.md b/doc/VERCEL.md new file mode 100644 index 0000000..9fa2376 --- /dev/null +++ b/doc/VERCEL.md @@ -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-.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:///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`) diff --git a/package.json b/package.json index a8c8d40..c3280f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 2b1949a..d351e67 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -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 = {}; + 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 }); } diff --git a/packages/db/src/runtime-config.ts b/packages/db/src/runtime-config.ts index 3527d74..6ccd056 100644 --- a/packages/db/src/runtime-config.ts +++ b/packages/db/src/runtime-config.ts @@ -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"; @@ -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", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5912ea5..1b34e13 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; +export { resolvePostgresUrlFromEnv } from "./vercel-postgres.js"; export { COMPANY_STATUSES, DEPLOYMENT_MODES, diff --git a/packages/shared/src/vercel-postgres.ts b/packages/shared/src/vercel-postgres.ts new file mode 100644 index 0000000..2326df9 --- /dev/null +++ b/packages/shared/src/vercel-postgres.ts @@ -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; +} diff --git a/scripts/build-vercel-function.mjs b/scripts/build-vercel-function.mjs new file mode 100644 index 0000000..71e1d5f --- /dev/null +++ b/scripts/build-vercel-function.mjs @@ -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); +} diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 44bb576..8518b06 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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"], models: hermesModels, supportsLocalAgentJwt: true, agentConfigurationDoc: hermesAgentConfigurationDoc, diff --git a/server/src/app.ts b/server/src/app.ts index 87235a5..c68ec6b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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. @@ -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) => { @@ -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(); diff --git a/server/src/config.ts b/server/src/config.ts index a7fd68e..739b5b4 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -12,6 +12,7 @@ import { DEPLOYMENT_MODES, SECRET_PROVIDERS, STORAGE_PROVIDERS, + resolvePostgresUrlFromEnv, type BindMode, type AuthBaseUrlMode, type DeploymentExposure, @@ -296,7 +297,7 @@ export function loadConfig(): Config { authPublicBaseUrl, authDisableSignUp, databaseMode: fileDatabaseMode, - databaseUrl: process.env.DATABASE_URL ?? fileDbUrl, + databaseUrl: resolvePostgresUrlFromEnv() ?? fileDbUrl, embeddedPostgresDataDir: resolveHomeAwarePath( fileConfig?.database.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(), ), diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index b8738c7..867236f 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -1,11 +1,14 @@ import path from "node:path"; -import fs from "node:fs"; import pino from "pino"; import { pinoHttp } from "pino-http"; import { readConfigFile } from "../config-file.js"; import { resolveDefaultLogsDir, resolveHomeAwarePath } from "../home-paths.js"; import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js"; +function isServerlessRuntime(): boolean { + return process.env.VERCEL === "1" || process.env.NOW === "1"; +} + function resolveServerLogDir(): string { const envOverride = process.env.TASKCORE_LOG_DIR?.trim(); if (envOverride) return resolveHomeAwarePath(envOverride); @@ -17,9 +20,6 @@ function resolveServerLogDir(): string { } const logDir = resolveServerLogDir(); -fs.mkdirSync(logDir, { recursive: true }); - -const logFile = path.join(logDir, "server.log"); const sharedOpts = { translateTime: "SYS:HH:MM:ss", @@ -27,23 +27,30 @@ const sharedOpts = { singleLine: true, }; -export const logger = pino({ - level: "debug", - redact: ["req.headers.authorization"], -}, pino.transport({ - targets: [ - { - target: "pino-pretty", - options: { ...sharedOpts, ignore: "pid,hostname,req,res,responseTime", colorize: true, destination: 1 }, - level: "info", - }, - { - target: "pino-pretty", - options: { ...sharedOpts, colorize: false, destination: logFile, mkdir: true }, +// Serverless runtimes (e.g. Vercel Functions) have read-only filesystems and +// cannot run pino worker transports. Log to stdout only in that case. +export const logger = isServerlessRuntime() + ? pino({ + level: process.env.LOG_LEVEL?.trim() || "info", + redact: ["req.headers.authorization"], + }) + : pino({ level: "debug", - }, - ], -})); + redact: ["req.headers.authorization"], + }, pino.transport({ + targets: [ + { + target: "pino-pretty", + options: { ...sharedOpts, ignore: "pid,hostname,req,res,responseTime", colorize: true, destination: 1 }, + level: "info", + }, + { + target: "pino-pretty", + options: { ...sharedOpts, colorize: false, destination: path.join(logDir, "server.log"), mkdir: true }, + level: "debug", + }, + ], + })); export const httpLogger = pinoHttp({ logger, diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts new file mode 100644 index 0000000..56a493e --- /dev/null +++ b/server/src/types/express.d.ts @@ -0,0 +1,36 @@ +// Express Request augmentation for the request actor resolved by +// `actorMiddleware` in `src/middleware/auth.ts`. +// +// The actor is attached to every request before route handlers run and is the +// single source of truth for "who is making this request" (board user, agent, +// or none). Company scoping and permission checks read this property. + +declare global { + namespace Express { + interface Request { + actor: RequestActor; + } + } +} + +type RequestActorSource = + | "local_implicit" + | "session" + | "board_key" + | "agent_jwt" + | "agent_key" + | "none"; + +type RequestActor = { + type: "board" | "agent" | "none"; + source: RequestActorSource; + userId?: string; + agentId?: string; + companyId?: string; + companyIds?: string[]; + isInstanceAdmin?: boolean; + keyId?: string; + runId?: string; +}; + +export {}; diff --git a/server/src/vercel.ts b/server/src/vercel.ts new file mode 100644 index 0000000..d341bb4 --- /dev/null +++ b/server/src/vercel.ts @@ -0,0 +1,164 @@ +/// +import type { Request as ExpressRequest, RequestHandler, Response as ExpressResponse } from "express"; +import { createDb, type Db } from "@taskcore/db"; +import { createApp } from "./app.js"; +import { loadConfig, type Config } from "./config.js"; +import { logger } from "./middleware/logger.js"; +import { initializeBoardClaimChallenge } from "./board-claim.js"; +import { feedbackService } from "./services/index.js"; +import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-share-client.js"; +import { createStorageServiceFromConfig } from "./storage/index.js"; +import type { BetterAuthSessionResult } from "./auth/better-auth.js"; + +type ExpressApp = Awaited>; +type ExpressRequestHandler = (req: ExpressRequest, res: ExpressResponse) => void; + +function isVercelRuntime(): boolean { + return process.env.VERCEL === "1" || process.env.NOW === "1"; +} + +function applyVercelDefaults(): void { + if (!isVercelRuntime()) return; + const defaults: Record = { + TASKCORE_DEPLOYMENT_MODE: "authenticated", + TASKCORE_DEPLOYMENT_EXPOSURE: "public", + SERVE_UI: "false", + TASKCORE_PLUGINS_ENABLED: "false", + TASKCORE_DB_BACKUP_ENABLED: "false", + HEARTBEAT_SCHEDULER_ENABLED: "false", + TASKCORE_STORAGE_LOCAL_DIR: "/tmp/taskcore-storage", + TASKCORE_LOG_DIR: "/tmp/taskcore-logs", + TASKCORE_PG_MAX_CONNECTIONS: "5", + }; + for (const [key, value] of Object.entries(defaults)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +function assertVercelConfig(config: Config): void { + if (isVercelRuntime() && config.deploymentMode !== "authenticated") { + throw new Error( + "Taskcore on Vercel requires TASKCORE_DEPLOYMENT_MODE=authenticated " + + "(a public, unauthenticated board is not allowed).", + ); + } + if (isVercelRuntime() && config.deploymentExposure !== "public") { + throw new Error( + "Taskcore on Vercel requires TASKCORE_DEPLOYMENT_EXPOSURE=public.", + ); + } + if (config.deploymentMode === "authenticated" && config.deploymentExposure === "public") { + if (config.authBaseUrlMode !== "explicit" || !config.authPublicBaseUrl) { + throw new Error( + "Authenticated public exposure requires auth.baseUrlMode=explicit and a public URL. " + + "Set TASKCORE_AUTH_PUBLIC_BASE_URL (or BETTER_AUTH_URL) to the deployment URL " + + "(e.g. https://taskcore-.vercel.app).", + ); + } + } +} + +async function createAppForServerless(config: Config, db: Db): Promise { + let authReady = config.deploymentMode === "local_trusted"; + let betterAuthHandler: RequestHandler | undefined; + let resolveSession: + | ((req: ExpressRequest) => Promise) + | undefined; + + if (config.deploymentMode === "authenticated") { + const { + createBetterAuthHandler, + createBetterAuthInstance, + deriveAuthTrustedOrigins, + resolveBetterAuthSession, + } = await import("./auth/better-auth.js"); + const derivedTrustedOrigins = deriveAuthTrustedOrigins(config); + const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const effectiveTrustedOrigins = Array.from(new Set([...derivedTrustedOrigins, ...envTrustedOrigins])); + logger.info( + { + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl ?? null, + trustedOrigins: effectiveTrustedOrigins, + }, + "Authenticated mode auth origin configuration (serverless)", + ); + const auth = createBetterAuthInstance(db, config, effectiveTrustedOrigins); + betterAuthHandler = createBetterAuthHandler(auth); + resolveSession = (req) => resolveBetterAuthSession(auth, req); + await initializeBoardClaimChallenge(db, { deploymentMode: config.deploymentMode }); + authReady = true; + } + + const storageService = createStorageServiceFromConfig(config); + const feedback = feedbackService(db, { + shareClient: createFeedbackTraceShareClientFromConfig(config), + }); + + return createApp(db, { + uiMode: "none", + serverPort: 3000, + storageService, + feedbackExportService: feedback, + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + allowedHostnames: config.allowedHostnames, + bindHost: config.host, + authReady, + companyDeletionEnabled: config.companyDeletionEnabled, + betterAuthHandler, + resolveSession, + }); +} + +async function boot(): Promise { + applyVercelDefaults(); + + const config = loadConfig(); + if (!config.databaseUrl) { + throw new Error( + "Taskcore on Vercel requires an external PostgreSQL connection. " + + "Set DATABASE_URL (or the Vercel Postgres / RDS environment: POSTGRES_URL or PGHOST/PGDATABASE/PGUSER/PGPASSWORD).", + ); + } + assertVercelConfig(config); + + const maxConnections = Math.max(1, Number(process.env.TASKCORE_PG_MAX_CONNECTIONS) || 10); + const prepareDisabled = process.env.TASKCORE_PG_PREPARE !== undefined + ? process.env.TASKCORE_PG_PREPARE === "true" + : isVercelRuntime(); + const db = createDb(config.databaseUrl, { + max: maxConnections, + ...(prepareDisabled ? { prepare: false } : {}), + }); + + logger.info( + { + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + storageProvider: config.storageProvider, + databaseConfigured: true, + }, + "Booting Taskcore serverless app", + ); + + return createAppForServerless(config, db); +} + +let appPromise: Promise | null = null; + +export default async function taskcoreVercelHandler( + req: ExpressRequest, + res: ExpressResponse, +): Promise { + const app = await (appPromise ??= boot().catch((err) => { + appPromise = null; + throw err; + })); + (app as unknown as ExpressRequestHandler)(req, res); +} diff --git a/server/src/version.ts b/server/src/version.ts index 39a16a4..d30b829 100644 --- a/server/src/version.ts +++ b/server/src/version.ts @@ -4,7 +4,16 @@ type PackageJson = { version?: string; }; -const require = createRequire(import.meta.url); -const pkg = require("../package.json") as PackageJson; +function loadServerVersion(): string { + const fromEnv = process.env.TASKCORE_SERVER_VERSION; + if (fromEnv) return fromEnv; + try { + const require = createRequire(import.meta.url); + const pkg = require("../package.json") as PackageJson; + return pkg.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} -export const serverVersion = pkg.version ?? "0.0.0"; +export const serverVersion = loadServerVersion(); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index db912f4..b88db80 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -9,7 +9,8 @@ import type { IssueComment, } from "@taskcore/shared"; import { Button } from "@/components/ui/button"; -import { ArrowRight, Check, Copy, Taskcore } from "lucide-react"; +import { ArrowRight, Check, Copy } from "lucide-react"; +import { TaskcoreIcon } from "./TaskcoreIcon"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; @@ -938,7 +939,7 @@ export function CommentThread({ disabled={attaching} title="Attach image" > - + )} diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 6c04671..21a5cde 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; -import { Taskcore, Plus } from "lucide-react"; +import { Plus } from "lucide-react"; +import { TaskcoreIcon } from "./TaskcoreIcon"; import { useQueries, useQuery } from "@tanstack/react-query"; import { DndContext, @@ -202,7 +203,7 @@ export function CompanyRail() {
{/* Taskcore icon - aligned with top sections (implied line, no visible border) */}
- +
{/* Company list */} diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index f147dbf..67d6c1f 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -88,7 +88,8 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; -import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Taskcore, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; +import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; +import { TaskcoreIcon } from "./TaskcoreIcon"; interface IssueChatMessageContext { feedbackVoteByTargetId: Map; @@ -1789,7 +1790,7 @@ const IssueChatComposer = forwardRef - +
) : null} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 7f3664c..4be2489 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -44,7 +44,6 @@ import { AlertTriangle, Tag, Calendar, - Taskcore, FileText, Loader2, ListTree, @@ -58,6 +57,7 @@ import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDe import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { AgentIcon } from "./AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; +import { TaskcoreIcon } from "./TaskcoreIcon"; const DRAFT_KEY = "taskcore:issue-draft"; const DEBOUNCE_MS = 800; @@ -1525,7 +1525,7 @@ export function NewIssueDialog() {
- + {file.file.name}
@@ -1632,7 +1632,7 @@ export function NewIssueDialog() { onClick={() => stageFileInputRef.current?.click()} disabled={createIssue.isPending} > - + Upload diff --git a/ui/src/components/TaskcoreIcon.tsx b/ui/src/components/TaskcoreIcon.tsx new file mode 100644 index 0000000..91079cd --- /dev/null +++ b/ui/src/components/TaskcoreIcon.tsx @@ -0,0 +1,21 @@ +import { cn } from "../lib/utils"; + +interface TaskcoreIconProps { + className?: string; +} + +export function TaskcoreIcon({ className }: TaskcoreIconProps) { + return ( + + + + ); +} diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 4af4bcb..780482a 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -46,7 +46,6 @@ import { Github, Link2, ExternalLink, - Taskcore, Pencil, Plus, RefreshCw, @@ -54,6 +53,7 @@ import { Search, Trash2, } from "lucide-react"; +import { TaskcoreIcon } from "../components/TaskcoreIcon"; type SkillTreeNode = { name: string; @@ -160,7 +160,7 @@ function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string | case "local": return { icon: Folder, label: sourceLabel ?? "Folder", managedLabel: "Folder managed" }; case "taskcore": - return { icon: Taskcore, label: sourceLabel ?? "Taskcore", managedLabel: "Taskcore managed" }; + return { icon: TaskcoreIcon, label: sourceLabel ?? "Taskcore", managedLabel: "Taskcore managed" }; default: return { icon: Boxes, label: sourceLabel ?? "Catalog", managedLabel: "Catalog managed" }; } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a4909c1..7d49f35 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -91,12 +91,12 @@ import { MessageSquare, MoreHorizontal, MoreVertical, - Taskcore, Plus, Repeat, SlidersHorizontal, Trash2, } from "lucide-react"; +import { TaskcoreIcon } from "../components/TaskcoreIcon"; import { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, @@ -2094,7 +2094,7 @@ export function IssueDetail() { attachmentDragActive && "border-primary bg-primary/5", )} > - + {uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : ( <> Upload attachment diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..aea0b21 --- /dev/null +++ b/vercel.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "pnpm install --frozen-lockfile", + "buildCommand": "pnpm vercel:build", + "outputDirectory": "ui/dist", + "functions": { + "api/index.js": { + "maxDuration": 60 + } + }, + "rewrites": [ + { "source": "/api/(.*)", "destination": "/api/index.js" }, + { "source": "/api", "destination": "/api/index.js" }, + { "source": "/(.*)", "destination": "/index.html" } + ] +}