Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

249 lines
8.4 KiB
TypeScript

#!/usr/bin/env -S npx tsx
/**
* Apps-control provisioning worker (Eliza Cloud Apps / Product 2).
*
* A PURPOSE-BUILT, deliberately slim sibling of `provisioning-worker.ts`. It
* claims ONLY the apps lane (`APPS_JOB_TYPES`: CONTAINER_* + APP_DEPLOY +
* APP_DB_DEPROVISION) and arms ONLY the apps deploy backend. It runs NONE of the
* agent-fleet singletons the main worker owns — no liveness heartbeat, no fleet
* image-upgrade, no node autoscale, no warm-pool drain — so a second copy of
* THIS daemon can never race, duplicate, or disrupt the live agent control
* plane. The two daemons coexist on the shared `jobs` table purely via
* lane-scoped, `FOR UPDATE SKIP LOCKED` claiming.
*
* WHY A SEPARATE DAEMON / HOST: provisioning a per-tenant DB runs `pg` DDL
* (CREATE ROLE/DATABASE) against the PRIVATE tenant Postgres, which is only
* reachable on the apps private network — somewhere the agent control-plane node
* (a different Hetzner project) is not. So this daemon is meant to run on a
* TRUSTED node that sits ON the apps private net (reaches the tenant DB
* privately) but runs NO untrusted user containers (those live on the separate
* app node) — keeping the cluster ADMIN DSN off any box that runs user code.
*
* Deploy target is therefore the apps-control node (NOT the agent control plane,
* NOT the untrusted app node). Pin the main worker to `PROVISIONING_JOB_LANES=agent`
* once this is live so it stops claiming-and-failing apps jobs it can't run.
*
* Usage:
* npx tsx packages/scripts/cloud/admin/daemons/apps-provisioning-worker.ts
* npx tsx packages/scripts/cloud/admin/daemons/apps-provisioning-worker.ts --once
*
* Required env (in /opt/eliza/cloud/.env.local, same shape arm-apps-daemon writes):
* APPS_DEPLOY_ENABLED=1 arm gate (without it this daemon idles)
* DATABASE_URL=... the cloud Postgres (Neon) — the jobs queue
* APPS_TENANT_ADMIN_DSN=... admin DSN of the tenant DB cluster (private IP)
* CONTAINERS_DOCKER_NODES=... the app node(s) the deploy runner SSHes to
* CONTAINERS_SSH_USER, APPS_CADDY_ADMIN_URL, CONTAINERS_PUBLIC_BASE_DOMAIN, ...
*/
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { APPS_JOB_TYPES } from "@elizaos/cloud-shared/lib/services/provisioning-job-types";
import type { ProcessingResult } from "@elizaos/cloud-shared/lib/services/provisioning-jobs";
import { loadLocalEnv } from "./shared/load-env";
type WorkerLogger =
typeof import("@elizaos/cloud-shared/lib/utils/logger").logger;
type WorkerService =
typeof import("@elizaos/cloud-shared/lib/services/provisioning-jobs").provisioningJobService;
interface AppsWorkerDeps {
logger: WorkerLogger;
provisioningJobService: WorkerService;
}
export interface AppsWorkerConfig {
pollIntervalMs: number;
batchSize: number;
runOnce: boolean;
}
const DEFAULT_POLL_INTERVAL_MS = 30_000;
const DEFAULT_BATCH_SIZE = 3;
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function hasFlag(argv: readonly string[], flag: string): boolean {
return argv.includes(flag);
}
export function readAppsWorkerConfig(
env: NodeJS.ProcessEnv = process.env,
argv: readonly string[] = process.argv.slice(2),
): AppsWorkerConfig {
return {
pollIntervalMs: parsePositiveInt(
env.WORKER_POLL_INTERVAL,
DEFAULT_POLL_INTERVAL_MS,
),
batchSize: parsePositiveInt(env.WORKER_BATCH_SIZE, DEFAULT_BATCH_SIZE),
runOnce: env.WORKER_RUN_ONCE === "1" || hasFlag(argv, "--once"),
};
}
let depsPromise: Promise<AppsWorkerDeps> | null = null;
async function loadDeps(): Promise<AppsWorkerDeps> {
if (!depsPromise) {
depsPromise = Promise.all([
import("@elizaos/cloud-shared/lib/services/provisioning-jobs"),
import("@elizaos/cloud-shared/lib/utils/logger"),
]).then(([jobsModule, loggerModule]) => ({
provisioningJobService: jobsModule.provisioningJobService,
logger: loggerModule.logger,
}));
}
return depsPromise;
}
/**
* Arm the node deploy backend (per-tenant DB provisioning + container executor +
* build-from-repo resolver). Mirrors `armAppsDeployBackendIfEnabled` in the main
* worker. Gated on `APPS_DEPLOY_ENABLED=1`: returns false when unset so a
* deployed-but-not-armed daemon idles instead of half-running.
*/
export async function armAppsDeployBackend(
logger: WorkerLogger,
): Promise<boolean> {
if (process.env.APPS_DEPLOY_ENABLED !== "1") {
logger.warn(
"[apps-worker] APPS_DEPLOY_ENABLED is not '1' — deploy backend NOT armed; " +
"daemon will idle (claims no jobs). Set APPS_DEPLOY_ENABLED=1 to arm.",
);
return false;
}
const { configureAppsDeployBackend } = await import(
"@elizaos/cloud-shared/lib/services/apps-deploy-backend"
);
const port = process.env.APPS_DEPLOY_PORT
? Number(process.env.APPS_DEPLOY_PORT)
: undefined;
// APPS_IMAGE_REGISTRY set → BUILD-FROM-REPO (buildx on the app node, push to
// this registry). Unset → prebuilt images (imageTag / APP_DEFAULT_IMAGE).
const registry = process.env.APPS_IMAGE_REGISTRY;
configureAppsDeployBackend({ port, registry });
logger.info("[apps-worker] apps deploy backend armed", {
tenantDbAdminDsn: process.env.APPS_TENANT_ADMIN_DSN
? "env-sourced"
: "encrypted",
images: registry
? "build-from-repo"
: "prebuilt (imageTag/APP_DEFAULT_IMAGE)",
registry: registry ?? null,
port: port ?? 3000,
dockerNodes: process.env.CONTAINERS_DOCKER_NODES ?? "(unset)",
});
return true;
}
function resultContext(result: ProcessingResult): Record<string, unknown> {
return {
claimed: result.claimed,
succeeded: result.succeeded,
failed: result.failed,
errors: result.errors,
};
}
let running = true;
let armed = false;
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* One work cycle: claim + process the apps lane only. No heartbeat, no infra
* maintenance — those are the agent control plane's job, not ours.
*/
async function pollCycle(
logger: WorkerLogger,
config: AppsWorkerConfig,
): Promise<void> {
if (!armed) return; // not armed → nothing to claim; stay a quiet no-op.
try {
const { provisioningJobService } = await loadDeps();
const result = await provisioningJobService.processPendingJobs(
config.batchSize,
{ jobTypes: APPS_JOB_TYPES },
);
if (result.claimed > 0 || result.failed > 0) {
logger.info("[apps-worker] cycle complete", resultContext(result));
}
} catch (error) {
logger.error("[apps-worker] cycle failed", {
error: error instanceof Error ? error.message : String(error),
});
}
}
async function main(): Promise<void> {
loadLocalEnv(import.meta.url);
const config = readAppsWorkerConfig();
const { logger } = await loadDeps();
logger.info("[apps-worker] starting", {
pollIntervalMs: config.pollIntervalMs,
batchSize: config.batchSize,
runOnce: config.runOnce,
lane: "apps",
jobTypes: APPS_JOB_TYPES,
});
armed = await armAppsDeployBackend(logger);
if (config.runOnce) {
await pollCycle(logger, config);
return;
}
while (running) {
await pollCycle(logger, config);
if (running) {
await sleep(config.pollIntervalMs);
}
}
logger.info("[apps-worker] stopped");
}
function isMainModule(): boolean {
const entry = process.argv[1];
return entry ? path.resolve(entry) === fileURLToPath(import.meta.url) : false;
}
process.on("SIGINT", () => {
running = false;
});
process.on("SIGTERM", () => {
running = false;
});
process.on("unhandledRejection", (reason) => {
const reasonStr = reason instanceof Error ? reason.message : String(reason);
// Fall back to stderr if the logger import itself rejects (e.g. a rejection
// around startup before deps are loaded) so the original reason is never
// silently swallowed.
void loadDeps()
.then(({ logger }) => {
logger.error("[apps-worker] unhandled rejection", { error: reasonStr });
})
.catch(() => {
process.stderr.write(`[apps-worker] unhandled rejection: ${reasonStr}\n`);
});
});
if (isMainModule()) {
main().catch((error) => {
process.stderr.write(
`[apps-worker] fatal: ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
}