mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Cloud provisions a dedicated tenant stack for each
customer. During signup it asks for a mission, a name and role for the
first agent, and a first task.
> - Cloud pushes those answers into the new stack at activation, as
`POST /api/companies/:companyId/onboarding-seed`.
> - No route served that path. The tenant answered 404, so Cloud
recorded the push as unacknowledged and retried on every portfolio
fetch.
> - The failure was soft. The answers stayed durable in Cloud and the
stack still activated. But the stack opened on the empty first-run
wizard, and it asked the customer again for what they had already given.
> - This pull request adds the receiving endpoint. It validates the
seed, applies it, and acknowledges it.
> - The benefit is that a seeded stack opens with the mission, the agent
and the first task already in place.
## Linked Issues or Issue Description
No public GitHub issue covers this. The problem is described in-PR,
following the feature template.
**Subsystem affected**
server/ — Express REST API and orchestration services. Also
`packages/db` (one new table) and `packages/shared` (one new validator).
**Problem or motivation**
Paperclip Cloud collects onboarding answers at signup and pushes them to
the tenant stack at activation. The tenant had no route for that
request. It answered 404. Cloud treats a non-2xx as "not yet applied",
so it kept the answers and retried, but the stack itself stayed
unseeded. A customer who had already named their mission, their first
agent and their first task arrived at an empty first-run wizard that
asked for all three again.
**Proposed solution**
Serve `POST /api/companies/:companyId/onboarding-seed`. Validate the
body, apply it to the company, then acknowledge it.
The seed is customer free text, so it is bounded and validated in
`packages/shared` and read from the JSON body only. It is never read
from an `x-paperclip-cloud-*` header. That header set is the trusted
identity envelope: every member is derived server-side from the host
plus verified domain records, and that is exactly what makes it
trustworthy. Mixing user content into it would remove the property. A
test plants a mission on a cloud header and asserts that the body value
wins.
Application reuses the shapes the first-run wizard already produces, so
a seeded stack and a manually onboarded one look the same afterwards:
- The mission becomes the company-level goal. A multi-line mission
splits into a title and a description, as the wizard does.
- The agent becomes the company's first hire. Its free-text role ("Chief
of Staff") lands on `title`. The structural `role` stays `ceo`, which is
what the org chart and the default-instructions lookup read.
- The first task becomes an issue in the Onboarding project, assigned to
that agent.
Cloud retries until it gets a 2xx, and it reads any 2xx as "the tenant
holds this content". So the endpoint is idempotent per `revision`. A new
`company_onboarding_seeds` table records the applied revision together
with the goal, the agent and the issue it produced. A replay of a
revision that already matches is a successful no-op. A later revision —
the customer edited their answers — updates those three rows in place
instead of creating a second agent and a second task. The record is
written last, after every other write has landed, so a partial
application cannot present itself as acknowledged.
Everything is applied before the 200 is sent. This is an ordering
guarantee, not eventual consistency. The tests read the database
immediately after the response, with no waiting and no polling, so a
lazy receiver fails them on a fast machine as well as a slow one. That
matters because the redirect into the tenant dashboard is gated on this
acknowledgement.
**Alternatives considered**
Store the seed and let the tenant UI apply it on first load. Rejected:
the dashboard redirect is gated on the acknowledgement, so a background
apply would let the dashboard open before the agent and the task exist.
The whole point is that it must not.
Reuse `POST /companies/:companyId/agents` and `POST
/companies/:companyId/issues` over HTTP from Cloud. Rejected: it needs
three round trips with no shared idempotency key, and it moves the "did
all of it land?" decision to the caller.
**Roadmap alignment**
This completes an existing Cloud-to-tenant contract. It does not add a
new user-facing surface.
## What Changed
- Add `POST /api/companies/:companyId/onboarding-seed` in
`server/src/routes/onboarding-seed.ts`. It authenticates exactly as
`POST /api/companies/:companyId/logo` does, through
`assertCompanyAccess`.
- Add `server/src/services/onboarding-seed.ts`. It applies the mission,
the agent and the first task, and records the applied revision last.
- Add the `company_onboarding_seeds` table: schema, migration `0216`,
and journal entry. It holds the applied revision and the ids of the
goal, agent and issue the seed produced.
- Add `applyOnboardingSeedSchema` in `packages/shared`. It bounds
mission to 2000, agent name to 80, agent role to 120, task title to 200,
and task details to 2000 — the same limits Cloud enforces before it
sends.
- Mount the router in `server/src/app.ts` and register the path in the
OpenAPI document.
- Add `server/src/__tests__/onboarding-seed-route.test.ts` with 13
tests.
- The seeded agent is created on `claude_local`. This mirrors the
teams-catalog default for agents created server-side, where no human
runs an environment test first. `PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE`
overrides it.
## Verification
```sh
pnpm typecheck # whole workspace, passes
npx vitest run \
server/src/__tests__/onboarding-seed-route.test.ts \
server/src/__tests__/openapi-routes.test.ts # 15 passed
```
The suite runs against embedded Postgres with migrations applied, so
migration `0216` is exercised by every test.
The route tests cover:
- the happy path — mission, agent and task all applied, read immediately
after the 200
- replay of the same revision — no second agent, no second task, no
second goal, no second project
- a later revision — the goal, agent and task are updated in place
- a multi-line mission splitting into a goal title and description
- a revision-only seed
- the activity log entry written once, and not again on a replay
- a caller without access to the company — 403, and nothing written
- a body with no revision — 400
- each field bound past its limit — 400
- a mission planted on an `x-paperclip-cloud-*` header — ignored, body
wins
- an existing Onboarding project — reused, not duplicated
Not verified here: the full Cloud-to-tenant walk against a live stack.
That needs a deployed Cloud and a provisioned tenant together, which is
separate staging work.
## Risks
Migration `0216` creates one new table. It adds no column to an existing
table, rewrites nothing, and backfills nothing, so it is safe to apply
online. The migration safety check passes.
The endpoint writes to a company. Access is enforced by
`assertCompanyAccess`, the same gate the company logo write uses, and a
test covers the denial.
Behavioral note for stacks that already hold data. If a company already
has a non-built-in `ceo` agent, a first seed updates that agent's name
and title rather than creating a second lead. Likewise a seed adopts an
existing company-level goal rather than adding a parallel one. This is
deliberate: the seed is the customer's own stated answer from signup,
and two competing missions or two leads would be worse than one updated
in place. In the intended case — a stack that Cloud has just activated —
none of these exist yet.
The seeded agent is created on `claude_local` with an empty adapter
config. It is idle and needs the usual credential setup before it runs.
Seeding it does not start it.
## Update — rebased onto master + review hardening
Master moved on after this PR was cut, so it was **rebased onto
`master`** and
the seed migration was **renumbered from `0212` to `0216`** (the merged
#11101
took `0212_onboarding_first_task_unique`); the drizzle journal was
re-stitched
and `check:migrations` passes.
Two things landed on top of the original receiver:
- **Mission-only walk contract (PAP-67 r17.4).** The tenant now owns the
first
agent and the first task via #11101's server-owned onboarding path,
which
stamps `ONBOARDING_FIRST_TASK_ORIGIN_KIND` and races safely on the
partial
unique index `issues_onboarding_first_task_uq`. A comment in the apply
path
documents why this receiver leaves the first task to that path on the
cloud
walk, and a paperclip-cloud `node:test`
(`src/onboarding/walk-seed.test.ts`)
asserts the walk's seed carries no `agent`/`firstTask`. The receiver
retains
the agent/first-task code for its documented body contract, kept inert
on the
cloud path by the mission-only seed.
- **Three Greptile P1 fixes** (`95622fa37`): concurrent application is
now
serialized under a per-company `pg_advisory_xact_lock` (no duplicate
goal/agent/project/task on overlapping pushes); a revised first task
carries
its resolved `assigneeAgentId`/`goalId`; and the
`company.onboarding_seed_applied`
audit write is best-effort so a logging failure can't leave the entry
permanently absent. Two new regression tests cover the first two.
## Model Used
Claude Opus 5 (`claude-opus-5`), 1M context window, extended thinking,
with tool use and code execution. Used for the original codebase
investigation, the implementation, and the tests. The rebase, migration
renumber, mission-only contract, and the three P1 fixes were done with
Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use
and code execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
847 lines
33 KiB
TypeScript
847 lines
33 KiB
TypeScript
import express, { Router, type Request as ExpressRequest } from "express";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { Db } from "@paperclipai/db";
|
|
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
|
import type { InspectDatabaseBackupHealthOptions } from "./services/database-backup-health.js";
|
|
import type { StorageService } from "./storage/types.js";
|
|
import { httpLogger, errorHandler } from "./middleware/index.js";
|
|
import { actorMiddleware } from "./middleware/auth.js";
|
|
import { boardMutationGuard } from "./middleware/board-mutation-guard.js";
|
|
import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js";
|
|
import { applyTrustProxy, parseTrustProxyEnv } from "./middleware/trust-proxy.js";
|
|
import {
|
|
IMPORT_TRANSFER_SPOOL_SWEEP_INTERVAL_MS,
|
|
resolveDefaultImportTransferSpoolRoot,
|
|
sweepAbandonedImportTransferSpools,
|
|
} from "./services/company-import-transfers.js";
|
|
import { companyTransferRunService } from "./services/company-transfer-runs.js";
|
|
import { healthRoutes } from "./routes/health.js";
|
|
import { cloudRoutes } from "./routes/cloud.js";
|
|
import { companyRoutes } from "./routes/companies.js";
|
|
import { companySkillRoutes } from "./routes/company-skills.js";
|
|
import { companySkillPolicyRoutes } from "./routes/company-skill-policy.js";
|
|
import { inboxAgentPolicyRoutes } from "./routes/inbox-agent-policy.js";
|
|
import { builtInAgentRoutes } from "./routes/built-in-agents.js";
|
|
import { folderRoutes } from "./routes/folders.js";
|
|
import { summarySlotRoutes } from "./routes/summary-slots.js";
|
|
import { statusCardRoutes } from "./routes/status-cards.js";
|
|
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
|
|
import { agentRoutes } from "./routes/agents.js";
|
|
import type { SetupTokenSessionService } from "./services/setup-token-session.js";
|
|
import { projectRoutes } from "./routes/projects.js";
|
|
import { issueRoutes } from "./routes/issues.js";
|
|
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
|
import { caseRoutes } from "./routes/cases.js";
|
|
import { fileResourceRoutes } from "./routes/file-resources.js";
|
|
import { routineRoutes } from "./routes/routines.js";
|
|
import { pipelineRoutes } from "./routes/pipelines.js";
|
|
import { environmentRoutes } from "./routes/environments.js";
|
|
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
|
import { goalRoutes } from "./routes/goals.js";
|
|
import { onboardingSeedRoutes } from "./routes/onboarding-seed.js";
|
|
import { boardChatRoutes } from "./routes/board-chat.js";
|
|
import { approvalRoutes } from "./routes/approvals.js";
|
|
import { secretRoutes } from "./routes/secrets.js";
|
|
import { toolAccessRoutes } from "./routes/tool-access.js";
|
|
import { smokeLabRoutes } from "./routes/smoke-lab.js";
|
|
import { costRoutes } from "./routes/costs.js";
|
|
import { activityRoutes } from "./routes/activity.js";
|
|
import { dashboardRoutes } from "./routes/dashboard.js";
|
|
import { attentionRoutes } from "./routes/attention.js";
|
|
import { decisionTrainingRoutes } from "./routes/decision-training.js";
|
|
import { decisionRoutes } from "./routes/decisions.js";
|
|
import { decisionQueueRoutes } from "./routes/decision-queues.js";
|
|
import type { DecisionServiceOptions } from "./services/decisions.js";
|
|
import { userProfileRoutes } from "./routes/user-profiles.js";
|
|
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
|
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
|
import { resourceMembershipRoutes } from "./routes/resource-memberships.js";
|
|
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
|
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
|
import { openApiRoutes } from "./routes/openapi.js";
|
|
import {
|
|
instanceDatabaseBackupRoutes,
|
|
type InstanceDatabaseBackupService,
|
|
} from "./routes/instance-database-backups.js";
|
|
import { llmRoutes } from "./routes/llms.js";
|
|
import { authRoutes } from "./routes/auth.js";
|
|
import { assetRoutes } from "./routes/assets.js";
|
|
import { accessRoutes } from "./routes/access.js";
|
|
import { pluginRoutes } from "./routes/plugins.js";
|
|
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
|
|
import { adapterRoutes } from "./routes/adapters.js";
|
|
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
|
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
|
|
import { applyUiBranding } from "./ui-branding.js";
|
|
import { logger } from "./middleware/logger.js";
|
|
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader, type PluginLoader } from "./services/plugin-loader.js";
|
|
import {
|
|
SELF_HOSTED_AUTO_INSTALL_KEYS,
|
|
ensureBundledPlugins,
|
|
resolveBundledCatalogRoot,
|
|
resolveBundledPluginInstalls,
|
|
} from "./services/bundled-plugins.js";
|
|
import { createPluginWorkerManager, type PluginWorkerManager } from "./services/plugin-worker-manager.js";
|
|
import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
|
|
import { pluginJobStore } from "./services/plugin-job-store.js";
|
|
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
|
import { createToolGatewayService } from "./services/tool-gateway.js";
|
|
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
|
|
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
|
|
import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js";
|
|
import { createPluginEventBus } from "./services/plugin-event-bus.js";
|
|
import { setPluginEventBus } from "./services/activity-log.js";
|
|
import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js";
|
|
import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js";
|
|
import { pluginRegistryService } from "./services/plugin-registry.js";
|
|
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
|
|
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
|
import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js";
|
|
import { DEFAULT_JSON_BODY_LIMIT, PORTABLE_JSON_BODY_LIMIT } from "./http/body-limits.js";
|
|
import { COMPANY_IMPORT_API_PATH } from "./routes/company-import-paths.js";
|
|
import { apiCompression } from "./middleware/api-compression.js";
|
|
|
|
type UiMode = "none" | "static" | "vite-dev";
|
|
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
|
|
const VITE_DEV_ASSET_PREFIXES = [
|
|
"/@fs/",
|
|
"/@id/",
|
|
"/@react-refresh",
|
|
"/@vite/",
|
|
"/assets/",
|
|
"/node_modules/",
|
|
"/src/",
|
|
];
|
|
const VITE_DEV_STATIC_PATHS = new Set([
|
|
"/apple-touch-icon.png",
|
|
"/favicon-16x16.png",
|
|
"/favicon-32x32.png",
|
|
"/favicon.ico",
|
|
"/favicon.svg",
|
|
"/site.webmanifest",
|
|
"/sw.js",
|
|
]);
|
|
|
|
export function isDatabaseConnectionUnavailableError(err: unknown): boolean {
|
|
const error = err as { code?: unknown; message?: unknown; cause?: unknown };
|
|
if (error?.code === "ECONNREFUSED") return true;
|
|
return Boolean(error?.cause && isDatabaseConnectionUnavailableError(error.cause));
|
|
}
|
|
|
|
export function resolveViteHmrPort(serverPort: number): number {
|
|
if (serverPort <= 55_535) {
|
|
return serverPort + 10_000;
|
|
}
|
|
return Math.max(1_024, serverPort - 10_000);
|
|
}
|
|
|
|
export function resolveViteHmrHost(bindHost: string): string | undefined {
|
|
const normalized = bindHost.trim().toLowerCase();
|
|
if (normalized === "0.0.0.0" || normalized === "::") return undefined;
|
|
return bindHost;
|
|
}
|
|
|
|
export function shouldServeViteDevHtml(req: ExpressRequest): boolean {
|
|
const pathname = req.path;
|
|
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
|
|
if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false;
|
|
return req.accepts(["html"]) === "html";
|
|
}
|
|
|
|
export function shouldEnablePrivateHostnameGuard(opts: {
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
}): boolean {
|
|
return (
|
|
opts.deploymentExposure === "private" &&
|
|
(opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated")
|
|
);
|
|
}
|
|
|
|
export function createManagedBundledPluginWorkerRecovery(input: {
|
|
managedBundledPluginKeys: readonly string[];
|
|
workerManager: Pick<PluginWorkerManager, "getWorker" | "isRunning" | "stopWorker">;
|
|
getLoader: () => Pick<PluginLoader, "loadSingle"> | null;
|
|
}): (plugin: { id: string; pluginKey: string }) => Promise<boolean> {
|
|
const recoverablePluginKeys = new Set(input.managedBundledPluginKeys);
|
|
const inFlightStarts = new Map<string, Promise<boolean>>();
|
|
|
|
// A failed attempt can leave behind the dead handle it registered (e.g. the
|
|
// worker process died during initialize, which kills the process without
|
|
// scheduling a restart). No pre-existing handle survives to a recovery
|
|
// attempt — recovery only starts when getWorker() was empty — so discarding
|
|
// the dead handle lets a later capability request retry instead of being
|
|
// blocked by the handle-presence gate until the process restarts. Handles
|
|
// in starting/running/backoff states belong to the worker manager's own
|
|
// lifecycle and are left alone.
|
|
const discardDeadRecoveryHandle = async (plugin: { id: string; pluginKey: string }) => {
|
|
const handle = input.workerManager.getWorker(plugin.id);
|
|
if (!handle || (handle.status !== "crashed" && handle.status !== "stopped")) return;
|
|
try {
|
|
await input.workerManager.stopWorker(plugin.id);
|
|
} catch (err) {
|
|
logger.warn(
|
|
{
|
|
pluginId: plugin.id,
|
|
pluginKey: plugin.pluginKey,
|
|
err: err instanceof Error ? err.message : String(err),
|
|
},
|
|
"failed to discard dead plugin worker handle after recovery failure",
|
|
);
|
|
}
|
|
};
|
|
|
|
return async (plugin) => {
|
|
if (!recoverablePluginKeys.has(plugin.pluginKey)) return false;
|
|
|
|
const inFlight = inFlightStarts.get(plugin.id);
|
|
if (inFlight) return inFlight;
|
|
|
|
const startPromise = (async () => {
|
|
if (input.workerManager.getWorker(plugin.id)) {
|
|
return input.workerManager.isRunning(plugin.id);
|
|
}
|
|
|
|
const loader = input.getLoader();
|
|
if (!loader) return false;
|
|
|
|
try {
|
|
const result = await loader.loadSingle(plugin.id, {
|
|
markErrorOnFailure: false,
|
|
});
|
|
if (result.success === true || input.workerManager.isRunning(plugin.id)) {
|
|
return true;
|
|
}
|
|
await discardDeadRecoveryHandle(plugin);
|
|
return false;
|
|
} catch (err) {
|
|
logger.warn(
|
|
{
|
|
pluginId: plugin.id,
|
|
pluginKey: plugin.pluginKey,
|
|
err: err instanceof Error ? err.message : String(err),
|
|
},
|
|
"managed bundled plugin lazy worker recovery failed",
|
|
);
|
|
await discardDeadRecoveryHandle(plugin);
|
|
throw err;
|
|
}
|
|
})();
|
|
|
|
inFlightStarts.set(plugin.id, startPromise);
|
|
try {
|
|
return await startPromise;
|
|
} finally {
|
|
if (inFlightStarts.get(plugin.id) === startPromise) {
|
|
inFlightStarts.delete(plugin.id);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function createApp(
|
|
db: Db,
|
|
opts: {
|
|
uiMode: UiMode;
|
|
serverPort: number;
|
|
storageService: StorageService;
|
|
feedbackExportService?: {
|
|
flushPendingFeedbackTraces(input?: {
|
|
companyId?: string;
|
|
traceId?: string;
|
|
limit?: number;
|
|
now?: Date;
|
|
}): Promise<unknown>;
|
|
};
|
|
databaseBackupService?: InstanceDatabaseBackupService;
|
|
databaseBackupHealth?: InspectDatabaseBackupHealthOptions;
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
allowedHostnames: string[];
|
|
bindHost: string;
|
|
authPublicBaseUrl?: string;
|
|
authReady: boolean;
|
|
companyDeletionEnabled: boolean;
|
|
instanceId?: string;
|
|
hostVersion?: string;
|
|
localPluginDir?: string;
|
|
pluginMigrationDb?: Db;
|
|
pluginWorkerManager?: PluginWorkerManager;
|
|
decisionServiceOptions: DecisionServiceOptions;
|
|
betterAuthHandler?: express.RequestHandler;
|
|
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
|
/**
|
|
* `plugins.autoInstall` from the managed config (PAPERCLIP_MANAGED_CONFIG).
|
|
* `null`/absent ⇒ self-hosted: only the built-in kubernetes bundle is
|
|
* ensured, exactly as before. A managed list is resolved against the
|
|
* bundled catalog fail-to-start (see services/bundled-plugins.ts).
|
|
*/
|
|
managedPluginAutoInstall?: readonly string[] | null;
|
|
/** Test override for the bundled plugin catalog root. */
|
|
bundledPluginCatalogRoot?: string;
|
|
},
|
|
) {
|
|
const app = express();
|
|
app.locals.paperclipDb = db;
|
|
const captureRawBody = (req: express.Request, _res: express.Response, buf: Buffer) => {
|
|
(req as unknown as { rawBody: Buffer }).rawBody = buf;
|
|
};
|
|
|
|
// Respect the operator's `TRUST_PROXY` env var (see middleware/trust-proxy.ts).
|
|
// Default is unset → Express trusts nothing, which is the only safe choice
|
|
// when the server may be reachable without a known reverse proxy in front.
|
|
applyTrustProxy(app, parseTrustProxyEnv(process.env.TRUST_PROXY));
|
|
|
|
app.use(COMPANY_IMPORT_API_PATH, express.json({
|
|
limit: PORTABLE_JSON_BODY_LIMIT,
|
|
verify: captureRawBody,
|
|
}));
|
|
app.use(express.json({
|
|
limit: DEFAULT_JSON_BODY_LIMIT,
|
|
verify: captureRawBody,
|
|
}));
|
|
app.use("/api", apiCompression());
|
|
app.use(httpLogger);
|
|
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
});
|
|
const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({
|
|
allowedHostnames: opts.allowedHostnames,
|
|
bindHost: opts.bindHost,
|
|
});
|
|
app.use(
|
|
privateHostnameGuard({
|
|
enabled: privateHostnameGateEnabled,
|
|
allowedHostnames: opts.allowedHostnames,
|
|
bindHost: opts.bindHost,
|
|
}),
|
|
);
|
|
app.use(
|
|
actorMiddleware(db, {
|
|
deploymentMode: opts.deploymentMode,
|
|
resolveSession: opts.resolveSession,
|
|
}),
|
|
);
|
|
app.use("/api/auth", authRoutes(db));
|
|
if (opts.betterAuthHandler) {
|
|
app.all("/api/auth/{*authPath}", opts.betterAuthHandler);
|
|
}
|
|
app.use(llmRoutes(db));
|
|
|
|
const hostServicesDisposers = new Map<string, () => void>();
|
|
const workerManager = opts.pluginWorkerManager ?? createPluginWorkerManager();
|
|
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
|
|
const bundledCatalogRoot =
|
|
opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env);
|
|
const bundledPluginInstalls = resolveBundledPluginInstalls(
|
|
managedAutoInstallKeys ?? SELF_HOSTED_AUTO_INSTALL_KEYS,
|
|
{
|
|
catalogRoot: bundledCatalogRoot,
|
|
env: process.env,
|
|
enforceCatalogRoot: managedAutoInstallKeys !== null,
|
|
},
|
|
);
|
|
const managedBundledPluginKeys =
|
|
managedAutoInstallKeys !== null
|
|
? bundledPluginInstalls.map((install) => install.pluginKey)
|
|
: [];
|
|
let runtimePluginLoader: Pick<PluginLoader, "loadSingle"> | null = null;
|
|
// A sibling process can install a managed bundled plugin while this process
|
|
// skips the mid-install row, then finish the row after this process's
|
|
// loadAll() pass. The capabilities route may recover only those managed
|
|
// bundles by starting their ready-but-unstarted worker lazily.
|
|
const recoverManagedBundledPluginWorker =
|
|
managedAutoInstallKeys !== null
|
|
? createManagedBundledPluginWorkerRecovery({
|
|
managedBundledPluginKeys,
|
|
workerManager,
|
|
getLoader: () => runtimePluginLoader,
|
|
})
|
|
: undefined;
|
|
|
|
// Mount API routes
|
|
const api = Router();
|
|
api.use(boardMutationGuard());
|
|
api.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
authReady: opts.authReady,
|
|
companyDeletionEnabled: opts.companyDeletionEnabled,
|
|
databaseBackupHealth: opts.databaseBackupHealth,
|
|
}),
|
|
);
|
|
api.use(openApiRoutes());
|
|
api.use("/cloud", cloudRoutes());
|
|
api.use("/companies", companyRoutes(db, opts.storageService));
|
|
api.use(llmRoutes(db));
|
|
api.use(folderRoutes(db));
|
|
api.use(companySkillRoutes(db));
|
|
api.use(companySkillPolicyRoutes(db));
|
|
api.use(inboxAgentPolicyRoutes(db));
|
|
api.use(builtInAgentRoutes(db));
|
|
api.use(summarySlotRoutes(db));
|
|
api.use(statusCardRoutes(db));
|
|
api.use(teamsCatalogRoutes(db));
|
|
// The setup-token login session service. The router builds it and hands it
|
|
// back through the callback below, so the shutdown hook can cancel every live
|
|
// session (SR-4).
|
|
let setupTokenLoginService: SetupTokenSessionService | null = null;
|
|
// The dedicated proxy IP or CIDR allowlist for the confidential setup-token
|
|
// login responses (SR-7). The global `TRUST_PROXY` setting does not satisfy
|
|
// the guard; an operator sets this allowlist to the real TLS-terminating
|
|
// proxy addresses. An empty value keeps the confidential responses on direct
|
|
// TLS (or a `local_trusted` loopback peer) only.
|
|
const setupTokenLoginProxyAllowlist = (process.env.CLAUDE_LOGIN_TRUSTED_PROXIES ?? "")
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
// The production server does not bind `setupTokenLogin` yet, so the start route
|
|
// fails closed with a fixed no-secret 503 and the login never spawns a process
|
|
// or holds a lease. This is a deliberate staged rollout: the live transport
|
|
// needs a real sandbox-lease manager, a live pseudo-terminal factory over the
|
|
// sandbox provider, and a durable cleanup store (the in-router default store is
|
|
// in-memory only). Each of those is a separate follow-up that goes through its
|
|
// own security review before the production server binds `setupTokenLogin`.
|
|
api.use(
|
|
agentRoutes(db, {
|
|
pluginWorkerManager: workerManager,
|
|
deploymentMode: opts.deploymentMode,
|
|
confidentialProxyAllowlist: setupTokenLoginProxyAllowlist,
|
|
onSetupTokenLoginService: (service) => {
|
|
setupTokenLoginService = service;
|
|
// Startup reaper (SR-4): release any lease whose login session is
|
|
// terminal or past its deadline after a restart. The DB is ready here.
|
|
void service.reap().catch((err) => {
|
|
logger.error({ err }, "Setup-token login startup reaper failed");
|
|
});
|
|
},
|
|
}),
|
|
);
|
|
api.use(assetRoutes(db, opts.storageService));
|
|
api.use(projectRoutes(db));
|
|
api.use(caseRoutes(db, opts.storageService));
|
|
api.use(issueTreeControlRoutes(db));
|
|
api.use(fileResourceRoutes(db));
|
|
api.use(routineRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(pipelineRoutes(db));
|
|
api.use(environmentRoutes(db, {
|
|
pluginWorkerManager: workerManager,
|
|
recoverMissingPluginWorker: recoverManagedBundledPluginWorker
|
|
? {
|
|
pluginKeys: managedBundledPluginKeys,
|
|
startWorker: recoverManagedBundledPluginWorker,
|
|
}
|
|
: undefined,
|
|
}));
|
|
api.use(executionWorkspaceRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(goalRoutes(db));
|
|
api.use(onboardingSeedRoutes(db));
|
|
api.use(boardChatRoutes(db, { deploymentMode: opts.deploymentMode }));
|
|
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(secretRoutes(db));
|
|
const trustedLocalStdioRuntimeHost =
|
|
process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
|
|
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
|
|
?? null;
|
|
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(activityRoutes(db));
|
|
api.use(dashboardRoutes(db));
|
|
api.use(attentionRoutes(db));
|
|
api.use(decisionTrainingRoutes(db));
|
|
api.use(decisionRoutes(db, opts.decisionServiceOptions));
|
|
api.use(decisionQueueRoutes(db));
|
|
api.use(userProfileRoutes(db));
|
|
api.use(sidebarBadgeRoutes(db));
|
|
api.use(sidebarPreferenceRoutes(db));
|
|
api.use(resourceMembershipRoutes(db));
|
|
api.use(inboxDismissalRoutes(db));
|
|
api.use(instanceSettingsRoutes(db));
|
|
if (opts.databaseBackupService) {
|
|
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
|
|
}
|
|
const pluginRegistry = pluginRegistryService(db);
|
|
const eventBus = createPluginEventBus();
|
|
setPluginEventBus(eventBus);
|
|
const jobStore = pluginJobStore(db);
|
|
const lifecycle = pluginLifecycleManager(db, { workerManager });
|
|
const scheduler = createPluginJobScheduler({
|
|
db,
|
|
jobStore,
|
|
workerManager,
|
|
});
|
|
const toolDispatcher = createPluginToolDispatcher({
|
|
workerManager,
|
|
lifecycleManager: lifecycle,
|
|
db,
|
|
});
|
|
const toolGateway = createToolGatewayService(db, {
|
|
pluginToolDispatcher: toolDispatcher,
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
trustedLocalStdioRuntimeHost,
|
|
});
|
|
// Issue routes are intentionally mounted after the gateway is constructed because
|
|
// issue approval endpoints delegate to it. The intervening routers use distinct
|
|
// route prefixes, so this dependency does not change issue-route precedence.
|
|
api.use(issueRoutes(db, opts.storageService, {
|
|
feedbackExportService: opts.feedbackExportService,
|
|
pluginWorkerManager: workerManager,
|
|
approveToolActionRequest: (input) => toolGateway.approveActionRequest(input),
|
|
}));
|
|
app.use(mcpGatewayProtocolRoutes(toolGateway));
|
|
api.use(toolAccessRoutes(db, {
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
trustedLocalStdioRuntimeHost,
|
|
toolGateway,
|
|
}));
|
|
api.use(smokeLabRoutes(db, {
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
}));
|
|
const jobCoordinator = createPluginJobCoordinator({
|
|
db,
|
|
lifecycle,
|
|
scheduler,
|
|
jobStore,
|
|
});
|
|
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
|
|
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
|
|
const loader = pluginLoader(
|
|
db,
|
|
{
|
|
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
|
migrationDb: opts.pluginMigrationDb,
|
|
},
|
|
{
|
|
workerManager,
|
|
eventBus,
|
|
jobScheduler: scheduler,
|
|
jobStore,
|
|
toolDispatcher,
|
|
lifecycleManager: lifecycle,
|
|
instanceInfo: {
|
|
instanceId: opts.instanceId ?? "default",
|
|
hostVersion: opts.hostVersion ?? "0.0.0",
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
},
|
|
buildHostHandlers: (pluginId, manifest) => {
|
|
const notifyWorker = (method: string, params: unknown) => {
|
|
const handle = workerManager.getWorker(pluginId);
|
|
if (handle) handle.notify(method, params);
|
|
};
|
|
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, {
|
|
pluginWorkerManager: workerManager,
|
|
manifest,
|
|
});
|
|
hostServicesDisposers.set(pluginId, () => services.dispose());
|
|
return createHostClientHandlers({
|
|
pluginId,
|
|
capabilities: manifest.capabilities,
|
|
services,
|
|
});
|
|
},
|
|
},
|
|
);
|
|
runtimePluginLoader = loader;
|
|
api.use(
|
|
toolGatewayRoutes(db, toolGateway),
|
|
);
|
|
api.use(
|
|
pluginRoutes(
|
|
db,
|
|
loader,
|
|
{ scheduler, jobStore },
|
|
{ workerManager },
|
|
{ toolDispatcher },
|
|
{ workerManager },
|
|
{ toolGateway },
|
|
),
|
|
);
|
|
api.use(adapterRoutes());
|
|
api.use(
|
|
accessRoutes(db, {
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
bindHost: opts.bindHost,
|
|
allowedHostnames: opts.allowedHostnames,
|
|
authPublicBaseUrl: opts.authPublicBaseUrl,
|
|
}),
|
|
);
|
|
app.use("/api", api);
|
|
app.use("/api", (_req, res) => {
|
|
res.status(404).json({ error: "API route not found" });
|
|
});
|
|
app.use(pluginUiStaticRoutes(db, {
|
|
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
|
}));
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
if (opts.uiMode === "static") {
|
|
// Try published location first (server/ui-dist/), then monorepo dev location (../../ui/dist)
|
|
const candidates = [
|
|
path.resolve(__dirname, "../ui-dist"),
|
|
path.resolve(__dirname, "../../ui/dist"),
|
|
];
|
|
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
|
if (uiDist) {
|
|
// Hashed asset files (Vite emits them under /assets/<name>.<hash>.<ext>)
|
|
// never change once built, so they can be cached aggressively.
|
|
app.use(
|
|
"/assets",
|
|
express.static(path.join(uiDist, "assets"), {
|
|
maxAge: "1y",
|
|
immutable: true,
|
|
}),
|
|
);
|
|
// Non-hashed static files (favicon.ico, manifest, robots.txt, etc.):
|
|
// short cache so operators who swap them out see the new version
|
|
// reasonably fast. Override for `index.html` specifically — it is
|
|
// served by this middleware for `/` and `/index.html`, and it must
|
|
// never outlive the asset hashes it points at.
|
|
app.use(
|
|
express.static(uiDist, {
|
|
maxAge: "1h",
|
|
setHeaders(res, filePath) {
|
|
if (path.basename(filePath) === "index.html") {
|
|
res.set("Cache-Control", "no-cache");
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
// SPA fallback. Only for non-asset routes — if the browser asks for
|
|
// /assets/something.js that doesn't exist, we must NOT serve the HTML
|
|
// shell: the browser would try to load it as a JavaScript module, fail
|
|
// with a MIME-type error, and cache that broken response. Return 404
|
|
// instead. The index.html response itself is no-cache so a subsequent
|
|
// deploy's updated asset hashes are picked up on next load.
|
|
app.get(/.*/, (req, res) => {
|
|
if (req.path.startsWith("/assets/")) {
|
|
res.status(404).end();
|
|
return;
|
|
}
|
|
res
|
|
.status(200)
|
|
.set("Content-Type", "text/html")
|
|
.set("Cache-Control", "no-cache")
|
|
.end(readBrandedStaticIndexHtml(uiDist));
|
|
});
|
|
} else {
|
|
console.warn("[paperclip] UI dist not found; running in API-only mode");
|
|
}
|
|
}
|
|
|
|
if (opts.uiMode === "vite-dev") {
|
|
const uiRoot = path.resolve(__dirname, "../../ui");
|
|
const publicUiRoot = path.resolve(uiRoot, "public");
|
|
const hmrPort = resolveViteHmrPort(opts.serverPort);
|
|
const hmrHost = resolveViteHmrHost(opts.bindHost);
|
|
const { createServer: createViteServer } = await import("vite");
|
|
const vite = await createViteServer({
|
|
root: uiRoot,
|
|
appType: "custom",
|
|
server: {
|
|
middlewareMode: true,
|
|
hmr: {
|
|
...(hmrHost ? { host: hmrHost } : {}),
|
|
port: hmrPort,
|
|
clientPort: hmrPort,
|
|
},
|
|
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
|
|
},
|
|
});
|
|
viteHtmlRenderer = createCachedViteHtmlRenderer({
|
|
vite,
|
|
uiRoot,
|
|
brandHtml: applyUiBranding,
|
|
});
|
|
const renderViteHtml = viteHtmlRenderer;
|
|
|
|
if (fs.existsSync(publicUiRoot)) {
|
|
app.use(express.static(publicUiRoot, { index: false }));
|
|
}
|
|
app.get(/.*/, async (req, res, next) => {
|
|
if (!shouldServeViteDevHtml(req)) {
|
|
next();
|
|
return;
|
|
}
|
|
try {
|
|
const html = await renderViteHtml.render(req.originalUrl);
|
|
res.status(200).set({ "Content-Type": "text/html" }).end(html);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
app.use(vite.middlewares);
|
|
}
|
|
|
|
app.use(errorHandler);
|
|
|
|
jobCoordinator.start();
|
|
scheduler.start();
|
|
let feedbackExportShuttingDown = false;
|
|
let feedbackExportTimer: ReturnType<typeof setInterval> | null = null;
|
|
const disableFeedbackExportFlushes = () => {
|
|
feedbackExportShuttingDown = true;
|
|
if (feedbackExportTimer) {
|
|
clearInterval(feedbackExportTimer);
|
|
feedbackExportTimer = null;
|
|
}
|
|
};
|
|
const flushPendingFeedbackExports = async () => {
|
|
if (feedbackExportShuttingDown) return;
|
|
try {
|
|
await opts.feedbackExportService?.flushPendingFeedbackTraces();
|
|
} catch (err) {
|
|
if (isDatabaseConnectionUnavailableError(err)) {
|
|
disableFeedbackExportFlushes();
|
|
logger.warn({ err }, "Disabling pending feedback export flushes because the database is unavailable");
|
|
return;
|
|
}
|
|
logger.error({ err }, "Failed to flush pending feedback exports");
|
|
}
|
|
};
|
|
|
|
feedbackExportTimer = opts.feedbackExportService
|
|
? setInterval(() => {
|
|
void flushPendingFeedbackExports();
|
|
}, FEEDBACK_EXPORT_FLUSH_INTERVAL_MS)
|
|
: null;
|
|
feedbackExportTimer?.unref?.();
|
|
if (opts.feedbackExportService) {
|
|
void flushPendingFeedbackExports();
|
|
}
|
|
// Abandoned chunked-import spool sweep: hourly (plus once at startup),
|
|
// deleting spool dirs whose transfer saw no activity for 24h and cancelling
|
|
// their still-open ledger runs. Same setInterval + unref + shutdown-clear
|
|
// shape as the feedback export flush above.
|
|
const importTransferSpoolRoot = resolveDefaultImportTransferSpoolRoot();
|
|
const sweepImportTransferSpools = () => {
|
|
sweepAbandonedImportTransferSpools(db, importTransferSpoolRoot)
|
|
.then((result) => {
|
|
if (result.swept > 0) {
|
|
logger.info(result, "swept abandoned company import transfer spools");
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
logger.error({ err }, "abandoned company import transfer spool sweep failed");
|
|
});
|
|
};
|
|
let importTransferSweepTimer: ReturnType<typeof setInterval> | null = setInterval(
|
|
sweepImportTransferSpools,
|
|
IMPORT_TRANSFER_SPOOL_SWEEP_INTERVAL_MS,
|
|
);
|
|
importTransferSweepTimer.unref?.();
|
|
// Startup only (never on the hourly interval — that would kill live
|
|
// applies): apply jobs are in-memory in this single process, so any run
|
|
// still "applying" now was interrupted by the previous shutdown and would
|
|
// otherwise 409 every retry forever. Fail those stranded runs — their
|
|
// spooled parts stay reusable — then run the normal sweep once.
|
|
void companyTransferRunService
|
|
.recoverStrandedApplyingRuns(db)
|
|
.then((recovered) => {
|
|
if (recovered.length > 0) {
|
|
logger.warn(
|
|
{ count: recovered.length, runIds: recovered },
|
|
"failed company transfer runs stranded in applying by a restart",
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
logger.error({ err }, "stranded company transfer apply recovery failed");
|
|
})
|
|
.finally(() => {
|
|
sweepImportTransferSpools();
|
|
});
|
|
void toolDispatcher.initialize().catch((err) => {
|
|
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
|
|
});
|
|
const devWatcher = createPluginDevWatcher(
|
|
lifecycle,
|
|
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
|
|
);
|
|
// Auto-provision bundled plugins so their providers are registered for
|
|
// agent runs. Bundles are excluded from the pnpm
|
|
// workspace and built standalone into the image (see Dockerfile), then
|
|
// installed here from their local paths. This runs BEFORE loadAll() so
|
|
// loadAll() can activate them in the same startup pass.
|
|
//
|
|
// Workers are started exactly once, by loadAll(): the `lifecycle` manager
|
|
// above is constructed without a runtime-capable loader
|
|
// (pluginLifecycleManager(db, { workerManager }) — no `loader` option), so
|
|
// the lifecycle.load() that ensureBundledPlugins performs per newly
|
|
// installed bundle only records the `ready` status and does not spawn a
|
|
// worker (see activateReadyPlugin in services/plugin-lifecycle.ts).
|
|
//
|
|
// Managed instances (`plugins.autoInstall` from PAPERCLIP_MANAGED_CONFIG)
|
|
// drive the key list from the control plane; self-hosted instances keep
|
|
// the pre-existing behavior of ensuring only the kubernetes bundle.
|
|
//
|
|
// Resolution is deliberately synchronous and NOT fail-safe: an
|
|
// unknown key or a path escaping the bundled catalog root throws out of
|
|
// createApp so a managed instance refuses to start (positive allowlist,
|
|
// fail closed).
|
|
// SAFETY: installation is fully fail-safe. Any failure
|
|
// (missing bundle, install error, load error) is caught, logged, and
|
|
// swallowed per plugin so the server ALWAYS finishes booting. A degraded
|
|
// boot (a provider unavailable, some agents cannot run) is strictly
|
|
// preferable to a crash loop.
|
|
//
|
|
// The chain is not awaited here (createApp stays fast), but the settled
|
|
// promise is exposed via `app.locals.bundledPluginsStartup` so boot steps
|
|
// that must not outrun plugin availability — managed sandbox environments
|
|
// (`applyManagedEnvironments`) run before the heartbeat resumes queued
|
|
// runs — can sequence on it. It never rejects.
|
|
const bundledPluginsStartup = ensureBundledPlugins(
|
|
bundledPluginInstalls,
|
|
{ registry: pluginRegistry, loader, lifecycle, logger },
|
|
// Managed mode reinstalls soft-uninstalled bundles (the control plane
|
|
// owns provisioning); self-hosted leaves an operator's uninstall alone.
|
|
// Operator-DISABLED plugins are never touched in either mode.
|
|
{ reinstallUninstalled: managedAutoInstallKeys !== null },
|
|
)
|
|
.then(() => 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");
|
|
});
|
|
app.locals.bundledPluginsStartup = bundledPluginsStartup;
|
|
let appServicesShutdown = false;
|
|
const shutdownAppServices = () => {
|
|
if (appServicesShutdown) return;
|
|
appServicesShutdown = true;
|
|
disableFeedbackExportFlushes();
|
|
if (importTransferSweepTimer) {
|
|
clearInterval(importTransferSweepTimer);
|
|
importTransferSweepTimer = null;
|
|
}
|
|
devWatcher?.close();
|
|
viteHtmlRenderer?.dispose();
|
|
hostServiceCleanup.disposeAll();
|
|
hostServiceCleanup.teardown();
|
|
// Cancel every live setup-token login session, so each direct child stops
|
|
// before the server releases each lease (SR-4).
|
|
void setupTokenLoginService?.shutdown();
|
|
};
|
|
app.locals.paperclipShutdown = shutdownAppServices;
|
|
|
|
process.once("exit", shutdownAppServices);
|
|
process.once("beforeExit", () => {
|
|
void flushPluginLogBuffer();
|
|
});
|
|
|
|
return app;
|
|
}
|