Dockerfile.engine
Rama main · réplica de lectura del repositorio original.
# ── Stage 1: Install dependencies ──────────────────────────────
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/engine/package.json packages/engine/
# Create a minimal package.json for web to satisfy workspace resolution
RUN mkdir -p packages/web && echo '{"name":"@polyant/web","version":"1.3.5","private":true}' > packages/web/package.json
RUN npm ci --workspace=@polyant/engine --include-workspace-root
# Ensure workspace node_modules dir exists (even if empty) for COPY
RUN mkdir -p packages/engine/node_modules
# ── Stage: prod-deps (runtime deps only — no devDeps) ──────────
# Separate prod-only install so the runtime image doesn't carry the dev tooling
# (typescript, tsx, eslint, vitest, drizzle-kit, @types/*): the entrypoint runs
# `node dist/...` (not tsx) and migrations use drizzle-orm (not drizzle-kit).
# `npm prune --omit=dev` on the hoisted workspace tree is fragile → install prod
# from scratch instead. BuildKit runs this stage in parallel with `build`, and
# Docker layer caching skips it entirely when package-lock is unchanged.
# @polyant-ai/plugin-sdk is an engine `dependency` (the core *.tool.ts author it),
# so it survives here.
FROM node:24-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/engine/package.json packages/engine/
RUN mkdir -p packages/web && echo '{"name":"@polyant/web","version":"1.3.5","private":true}' > packages/web/package.json
RUN npm ci --omit=dev --workspace=@polyant/engine --include-workspace-root
RUN mkdir -p packages/engine/node_modules
# ── Stage: plugins (self-contained per-plugin build) ───────────
# Plugins baked into the image live in packages/engine/src/plugins/<name> (the
# same convention dir the loader scans at runtime, gitignored — an operator or a
# deploy pipeline drops them there before building). Each is built SELF-CONTAINED:
# its own node_modules and its tools compiled TS→JS in place. --rootDir . keeps
# the `tools/` prefix (a plugin tsconfig infers rootDir=tools, which would
# otherwise flatten the output to the root). git is needed for the SDK's git
# dependency.
FROM node:24-alpine AS plugins
RUN apk add --no-cache git
WORKDIR /build
COPY packages/engine/src/plugins/ ./plugins/
# What the plugins need from the RUNTIME IMAGE, aggregated into three plain files
# the runtime stage consumes. This stage is the only one that sees every plugin
# before the runtime image is built.
COPY scripts/collect-plugin-system-requirements.mjs ./collect.mjs
RUN node collect.mjs plugins /build/system
# The declared env applies to the plugin's own BUILD too, not only to the
# runtime: a plugin that points at a distro binary has to say so before its
# `npm install` tries to download a private copy of one.
RUN . /build/system/env.sh && \
for d in plugins/*/; do \
[ -f "$d/plugin.json" ] || continue; \
( set -e; cd "$d"; \
npm install --no-audit --no-fund; \
tsconfig=tsconfig.json; \
if [ -f tsconfig.build.json ]; then tsconfig=tsconfig.build.json; fi; \
npx tsc -p "$tsconfig" --rootDir . --outDir . --noEmit false; \
npm prune --omit=dev; \
find . -name node_modules -prune -o -name '*.ts' -type f -exec rm -f {} + ) || exit 1; \
done
# Strip the .ts sources after compiling: they sit next to the emitted .js in the
# same dir, and Node 22.18+ strips types → the loader (regex /\.(tool|hook)\.(ts|js)$/)
# would import BOTH and hit "Duplicate registration". Runtime only needs the .js.
# ── Stage 2: Build ─────────────────────────────────────────────
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/engine/node_modules ./packages/engine/node_modules
COPY package.json package-lock.json ./
COPY packages/engine/ packages/engine/
# Use tsconfig.build.json to exclude test files from compilation; the script also
# copies the migration SQL next to the compiled migrate.js (dist/database/migrations).
RUN npm run build:docker -w @polyant/engine
# ── Stage 3: Runtime ───────────────────────────────────────────
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# What the ENGINE itself needs. Nothing here exists for a tool: the tool families
# that shelled out to git, gh and chromium are plugins now, and each declares its
# own packages in plugin.json. That is why this list is one line instead of the
# page it used to be — and why an image built without the PDF plugin no longer
# carries Chromium and its font stack.
RUN apk add --no-cache curl ca-certificates
# What the INCLUDED PLUGINS asked for, collected from their manifests in the
# plugins stage. Both files exist even when empty, and `xargs -r` runs nothing on
# an empty one — so a build with no plugins installs nothing extra.
COPY --from=plugins /build/system/ /app/plugin-system/
RUN xargs -r apk add --no-cache < /app/plugin-system/apk.txt && \
xargs -r npm install -g --no-audit --no-fund < /app/plugin-system/npm-global.txt
# Plugin env vars reach the process through the entrypoint rather than ENV: their
# names are not known when this file is written. See docker-entrypoint.sh.
# Copy hoisted deps + non-hoisted workspace deps (e.g. langsmith).
# Runtime pulls from prod-deps (npm ci --omit=dev) — NOT deps — so the image
# ships no dev tooling. The build stage above still uses deps (it needs tsc).
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=prod-deps /app/packages/engine/node_modules ./packages/engine/node_modules
COPY --from=build /app/packages/engine/dist ./packages/engine/dist
# Baked-in plugins: self-contained (own node_modules) at the loader's convention
# dir dist/plugins/<name>. loadAllTools scans dist/plugins/* at boot; each tool's
# imports resolve from the plugin's own node_modules by walk-up.
COPY --from=plugins /build/plugins/ ./packages/engine/dist/plugins/
COPY packages/engine/package.json packages/engine/
COPY package.json ./
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Run as the image's unprivileged `node` user. The engine writes to two places
# under /app, both created here and owned by it: the file logger's logs/ and the
# per-conversation workspaces of writeFile and friends. Everything else it
# writes goes to /tmp or $HOME (/home/node), already writable. The code and the
# plugins stay root-owned, so the process cannot rewrite what it runs.
RUN mkdir -p /app/logs /app/packages/engine/workspaces && \
chown node:node /app/logs /app/packages/engine/workspaces
USER node
EXPOSE 4000
# /health answers once migrations have run and the HTTP server listens; the
# start period covers the migrations the entrypoint runs first.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget -q --spider "http://127.0.0.1:${API_PORT:-4000}/health" || exit 1
ENTRYPOINT ["docker-entrypoint.sh"]