@pasko70/pibo 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -199,13 +199,26 @@ export async function runComputeCli(argv) {
199
199
  Examples:
200
200
  $ pibo compute spawn
201
201
  $ pibo compute dev spawn --worktree my-fix
202
+ $ pibo compute pool status
202
203
  $ pibo compute list
203
204
  $ pibo compute release pibo-worker-abc123
204
205
 
205
206
  Next:
206
207
  $ pibo compute spawn --help
207
208
  $ pibo compute dev --help
209
+ $ pibo compute pool --help
208
210
  `);
211
+ program
212
+ .command("pool")
213
+ .description("Lease isolated Pibo deployment slots")
214
+ .helpOption(false)
215
+ .allowUnknownOption(true)
216
+ .allowExcessArguments(true)
217
+ .argument("[args...]")
218
+ .action(async (args) => {
219
+ const { runComputePoolCli } = await import("./pool/cli.js");
220
+ await runComputePoolCli([argv[0] ?? "node", "pibo compute pool", ...args]);
221
+ });
209
222
  program
210
223
  .command("spawn")
211
224
  .description("Create a one-time worker from the current Docker image")
@@ -0,0 +1,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, existsSync } from "node:fs";
3
+ import { lstat, mkdir, readFile, readdir, readlink, rename, rm, utimes, writeFile } from "node:fs/promises";
4
+ import { basename, resolve } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ const execFileAsync = promisify(execFile);
8
+ const PACKAGE_RELATIVE_PATH = "node_modules/@pasko70/pibo/package.json";
9
+ const BINARY_RELATIVE_PATH = "node_modules/@pasko70/pibo/dist/bin/pibo.js";
10
+ export async function ensureDeploymentArtifact(input) {
11
+ if (Boolean(input.archivePath) === Boolean(input.runtimePath)) {
12
+ throw new Error("Provide exactly one of archivePath or runtimePath");
13
+ }
14
+ if (input.runtimePath)
15
+ return inspectRuntimeArtifact(resolve(input.runtimePath));
16
+ const archivePath = resolve(input.archivePath);
17
+ if (!existsSync(archivePath))
18
+ throw new Error(`Deployment artifact archive does not exist: ${archivePath}`);
19
+ const sha256 = await sha256File(archivePath);
20
+ const artifactRoot = resolve(input.config.artifactRoot, sha256);
21
+ const runtimePath = resolve(artifactRoot, "runtime");
22
+ if (existsSync(resolve(runtimePath, BINARY_RELATIVE_PATH))) {
23
+ const now = new Date();
24
+ await utimes(artifactRoot, now, now);
25
+ return { ...(await inspectRuntimeArtifact(runtimePath)), sha256, reused: true };
26
+ }
27
+ await mkdir(input.config.artifactRoot, { recursive: true, mode: 0o700 });
28
+ const staging = resolve(input.config.artifactRoot, `.staging-${sha256}-${process.pid}`);
29
+ await rm(staging, { recursive: true, force: true });
30
+ await mkdir(resolve(staging, "runtime"), { recursive: true, mode: 0o700 });
31
+ await writeFile(resolve(staging, "runtime", "package.json"), '{"name":"pibo-deployment-pool-runtime","private":true}\n', { mode: 0o600 });
32
+ try {
33
+ await execFileAsync("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", archivePath], {
34
+ cwd: resolve(staging, "runtime"),
35
+ maxBuffer: 20 * 1024 * 1024,
36
+ });
37
+ if (!existsSync(resolve(staging, "runtime", BINARY_RELATIVE_PATH)))
38
+ throw new Error("Installed package does not contain the Pibo binary");
39
+ await writeFile(resolve(staging, "manifest.json"), `${JSON.stringify({ sha256, source: basename(archivePath), installedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
40
+ if (existsSync(artifactRoot))
41
+ await rm(staging, { recursive: true, force: true });
42
+ else
43
+ await rename(staging, artifactRoot);
44
+ }
45
+ catch (error) {
46
+ await rm(staging, { recursive: true, force: true });
47
+ throw error;
48
+ }
49
+ return { ...(await inspectRuntimeArtifact(runtimePath)), sha256, reused: false };
50
+ }
51
+ export async function inspectRuntimeArtifact(runtimePath) {
52
+ const resolved = resolve(runtimePath);
53
+ const binaryPath = resolve(resolved, BINARY_RELATIVE_PATH);
54
+ if (!existsSync(binaryPath))
55
+ throw new Error(`Pibo runtime binary was not found: ${binaryPath}`);
56
+ let packageVersion;
57
+ let packageText = "";
58
+ try {
59
+ packageText = await readFile(resolve(resolved, PACKAGE_RELATIVE_PATH), "utf8");
60
+ const parsed = JSON.parse(packageText);
61
+ if (parsed.name !== "@pasko70/pibo")
62
+ throw new Error(`Unexpected package name in ${resolved}`);
63
+ if (typeof parsed.version === "string")
64
+ packageVersion = parsed.version;
65
+ }
66
+ catch (error) {
67
+ if (error instanceof Error && error.message.startsWith("Unexpected package"))
68
+ throw error;
69
+ }
70
+ const packageRoot = resolve(resolved, "node_modules/@pasko70/pibo");
71
+ return { sha256: await sha256Directory(packageRoot), runtimePath: resolved, binaryPath, packageVersion, reused: true };
72
+ }
73
+ async function sha256Directory(root) {
74
+ const hash = createHash("sha256");
75
+ await hashDirectoryEntries(hash, root, "");
76
+ return hash.digest("hex");
77
+ }
78
+ async function hashDirectoryEntries(hash, root, relativeDirectory) {
79
+ const directory = relativeDirectory ? resolve(root, relativeDirectory) : root;
80
+ const entries = await readdir(directory, { withFileTypes: true });
81
+ entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
82
+ for (const entry of entries) {
83
+ const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
84
+ const absolutePath = resolve(root, relativePath);
85
+ const stats = await lstat(absolutePath);
86
+ if (entry.isDirectory()) {
87
+ hash.update(`directory\0${relativePath}\0${stats.mode & 0o777}\0`);
88
+ await hashDirectoryEntries(hash, root, relativePath);
89
+ continue;
90
+ }
91
+ if (entry.isFile()) {
92
+ hash.update(`file\0${relativePath}\0${stats.mode & 0o777}\0${stats.size}\0`);
93
+ await hashFileContents(hash, absolutePath);
94
+ hash.update("\0");
95
+ continue;
96
+ }
97
+ if (entry.isSymbolicLink()) {
98
+ hash.update(`symlink\0${relativePath}\0${await readlink(absolutePath)}\0`);
99
+ continue;
100
+ }
101
+ throw new Error(`Unsupported runtime artifact entry: ${absolutePath}`);
102
+ }
103
+ }
104
+ async function hashFileContents(hash, path) {
105
+ await new Promise((resolvePromise, reject) => {
106
+ const stream = createReadStream(path);
107
+ stream.on("data", (chunk) => hash.update(chunk));
108
+ stream.once("end", resolvePromise);
109
+ stream.once("error", reject);
110
+ });
111
+ }
112
+ async function sha256File(path) {
113
+ const hash = createHash("sha256");
114
+ await hashFileContents(hash, path);
115
+ return hash.digest("hex");
116
+ }
@@ -0,0 +1,156 @@
1
+ import { Command } from "commander";
2
+ import { ensureDeploymentArtifact } from "./artifacts.js";
3
+ import { resolveDeploymentPoolConfig } from "./config.js";
4
+ import { acquireDeployment, applyDeploymentPoolReapPlan, getDeploymentPoolDoctor, getDeploymentPoolStatus, listDeploymentArtifacts, planDeploymentPoolReap, releaseDeploymentLease, renewDeploymentLease, } from "./service.js";
5
+ function printJson(value) {
6
+ console.log(JSON.stringify(value, null, 2));
7
+ }
8
+ function parsePositiveInteger(value) {
9
+ const parsed = Number(value);
10
+ if (!Number.isInteger(parsed) || parsed < 1)
11
+ throw new Error("Value must be a positive integer");
12
+ return parsed;
13
+ }
14
+ function parseSeedMode(value) {
15
+ if (value === "full" || value === "medium" || value === "fresh")
16
+ return value;
17
+ throw new Error("Seed mode must be full, medium, or fresh");
18
+ }
19
+ function printDiscovery() {
20
+ console.log(`pibo compute pool - lease isolated Pibo deployment slots
21
+
22
+ Commands:
23
+ status Show slots and active leases
24
+ acquire Install an exact runtime into a free slot
25
+ renew <lease-id> Extend an active lease
26
+ release <lease-id> Stop and free a lease
27
+ reap Preview or apply expired-lease cleanup
28
+ doctor Check pool, Docker, seed, and container state
29
+ artifacts List installed checksum-addressed runtimes
30
+ seed Explain available seed modes
31
+
32
+ Next:
33
+ pibo compute pool acquire --help
34
+ `);
35
+ }
36
+ export async function runComputePoolCli(argv) {
37
+ if (argv.length <= 2 || argv[2] === "--help" || argv[2] === "-h") {
38
+ printDiscovery();
39
+ return;
40
+ }
41
+ const program = new Command();
42
+ program.name("pibo compute pool").description("Lease isolated Pibo deployment slots").showHelpAfterError();
43
+ program.command("status")
44
+ .option("--json", "Print machine-readable status")
45
+ .action((options) => {
46
+ const status = getDeploymentPoolStatus();
47
+ if (options.json)
48
+ printJson(status);
49
+ else {
50
+ console.log(`Deployment pool: ${status.active}/${status.maxActive} active, ${status.free} free`);
51
+ for (const slot of status.slots)
52
+ console.log(`${slot.id}\t${slot.state}\t${slot.publicUrl ?? "-"}\t${slot.lease?.holder ?? "-"}\t${slot.lease?.expiresAt ?? "-"}`);
53
+ }
54
+ });
55
+ program.command("acquire")
56
+ .requiredOption("--holder <holder>", "Pibo Session ID or other stable holder")
57
+ .option("--artifact <path>", "Server-local npm package archive")
58
+ .option("--runtime <path>", "Server-local installed Pibo runtime directory")
59
+ .option("--seed <mode>", "Seed mode: full, medium, or fresh", parseSeedMode, "medium")
60
+ .option("--ttl-minutes <minutes>", "Lease lifetime", parsePositiveInteger)
61
+ .option("--commit <sha>", "Source commit metadata")
62
+ .option("--json", "Print machine-readable lease")
63
+ .addHelpText("after", `
64
+ Provide exactly one source:
65
+ pibo compute pool acquire --holder ps_... --artifact /path/pibo.tgz --seed medium
66
+ pibo compute pool acquire --holder ps_... --runtime /opt/pibo-candidates/name/commit/runtime --seed fresh
67
+ `)
68
+ .action(async (options) => {
69
+ const config = resolveDeploymentPoolConfig();
70
+ const artifact = await ensureDeploymentArtifact({ config, archivePath: options.artifact, runtimePath: options.runtime });
71
+ const lease = await acquireDeployment({ holder: options.holder, seedMode: options.seed, artifact, ttlMinutes: options.ttlMinutes, commit: options.commit, config });
72
+ if (options.json)
73
+ printJson(lease);
74
+ else {
75
+ console.log(`${lease.id}\t${lease.status}\t${lease.slotId}\t${lease.publicUrl ?? "-"}`);
76
+ console.log(`expires\t${lease.expiresAt}`);
77
+ console.log(`renew\tpibo compute pool renew ${lease.id} --holder ${lease.holder}`);
78
+ console.log(`release\tpibo compute pool release ${lease.id} --holder ${lease.holder}`);
79
+ }
80
+ });
81
+ program.command("renew")
82
+ .argument("<lease-id>")
83
+ .requiredOption("--holder <holder>")
84
+ .option("--ttl-minutes <minutes>", "New lifetime from now", parsePositiveInteger)
85
+ .option("--json")
86
+ .action((leaseId, options) => {
87
+ const lease = renewDeploymentLease({ leaseId, holder: options.holder, ttlMinutes: options.ttlMinutes });
88
+ if (options.json)
89
+ printJson(lease);
90
+ else
91
+ console.log(`${lease.id}\tready\texpires=${lease.expiresAt}`);
92
+ });
93
+ program.command("release")
94
+ .argument("<lease-id>")
95
+ .option("--holder <holder>")
96
+ .option("--force", "Operator release without holder match")
97
+ .option("--json")
98
+ .action(async (leaseId, options) => {
99
+ if (!options.force && !options.holder)
100
+ throw new Error("--holder is required unless --force is used");
101
+ const lease = await releaseDeploymentLease({ leaseId, holder: options.holder, force: options.force });
102
+ if (options.json)
103
+ printJson(lease);
104
+ else
105
+ console.log(`${lease.id}\t${lease.status}`);
106
+ });
107
+ program.command("reap")
108
+ .option("--dry-run", "Preview cleanup", true)
109
+ .option("--apply", "Apply cleanup")
110
+ .option("--json")
111
+ .action(async (options) => {
112
+ const plan = await planDeploymentPoolReap();
113
+ const result = options.apply ? await applyDeploymentPoolReapPlan(plan) : { applied: false, plan };
114
+ if (options.json)
115
+ printJson(result);
116
+ else {
117
+ console.log(`Deployment pool reap ${options.apply ? "apply" : "dry-run"}: ${plan.summary.selectedLeases} lease(s), ${plan.summary.selectedOrphanContainers} orphan container(s), ${plan.summary.selectedDirtySlots} dirty slot(s), ${plan.summary.selectedFailureSnapshots} failure snapshot(s), ${plan.summary.selectedArtifacts} artifact(s)`);
118
+ for (const item of plan.items)
119
+ console.log(`${item.action}\t${item.lease.id}\t${item.reasons.join("+") || "active"}`);
120
+ if (!options.apply)
121
+ console.log("Dry-run only. Apply with: pibo compute pool reap --apply");
122
+ }
123
+ });
124
+ program.command("doctor")
125
+ .option("--json")
126
+ .action(async (options) => {
127
+ const result = await getDeploymentPoolDoctor();
128
+ if (options.json)
129
+ printJson(result);
130
+ else {
131
+ console.log(`configured\t${result.configured}`);
132
+ console.log(`runtime-image\t${result.runtimeImageAvailable ? "ok" : "missing"}\t${result.runtimeImage}`);
133
+ console.log(`seed-source\t${result.seedSourceHomeAvailable ? "ok" : "missing"}\t${result.seedSourceHome}`);
134
+ console.log("Next: pibo compute pool status --json");
135
+ }
136
+ });
137
+ program.command("artifacts")
138
+ .option("--json")
139
+ .action(async (options) => {
140
+ const rows = await listDeploymentArtifacts();
141
+ if (options.json)
142
+ printJson({ artifacts: rows });
143
+ else if (!rows.length)
144
+ console.log("No deployment pool artifacts.");
145
+ else
146
+ for (const row of rows)
147
+ console.log(`${row.sha256}\t${row.bytes}\t${row.modifiedAt}\t${row.path}`);
148
+ });
149
+ program.command("seed")
150
+ .action(() => console.log(`Deployment seed modes:
151
+ full Nearly complete Pibo home plus configured workspace; excludes active runtime, lock, browser, debug, and pool state.
152
+ medium Operational config, selected product databases, projects, contexts, agents, and user skills; excludes heavy payload/tool/browser/debug state.
153
+ fresh Operational config, Google/Machine auth configuration, model defaults, contexts, and user skills; no existing product databases.
154
+ `));
155
+ await program.parseAsync(argv);
156
+ }
@@ -0,0 +1,101 @@
1
+ import { resolve } from "node:path";
2
+ import { getPiboHome, piboHomePath } from "../../core/pibo-home.js";
3
+ export function resolveDeploymentPoolConfig(options = {}) {
4
+ const env = options.env ?? process.env;
5
+ const root = resolve(options.root ?? env.PIBO_COMPUTE_POOL_ROOT ?? piboHomePath("compute-pool"));
6
+ const baseURL = parseBaseURL(options.baseURL ?? env.PIBO_COMPUTE_POOL_BASE_URL);
7
+ const slotCount = positiveInteger(env.PIBO_COMPUTE_POOL_SLOT_COUNT, 10);
8
+ const maxActive = Math.min(slotCount, positiveInteger(env.PIBO_COMPUTE_POOL_MAX_ACTIVE, 3));
9
+ const portBase = validPort(env.PIBO_COMPUTE_POOL_PORT_BASE, 5000);
10
+ const portStride = positiveInteger(env.PIBO_COMPUTE_POOL_PORT_STRIDE, 10);
11
+ if (portBase + (slotCount - 1) * portStride + 1 > 65535) {
12
+ throw new Error("Deployment pool slot ports exceed 65535");
13
+ }
14
+ return {
15
+ root,
16
+ databasePath: resolve(root, "pool.sqlite"),
17
+ artifactRoot: resolve(root, "artifacts"),
18
+ slotsRoot: resolve(root, "slots"),
19
+ failuresRoot: resolve(root, "failures"),
20
+ baseURL,
21
+ slotCount,
22
+ maxActive,
23
+ portBase,
24
+ portStride,
25
+ defaultTtlMinutes: positiveInteger(env.PIBO_COMPUTE_POOL_TTL_MINUTES, 60),
26
+ failedRetentionMinutes: positiveInteger(env.PIBO_COMPUTE_POOL_FAILED_RETENTION_MINUTES, 120),
27
+ maxFailedSnapshots: positiveInteger(env.PIBO_COMPUTE_POOL_MAX_FAILED_SNAPSHOTS, 3),
28
+ artifactRetentionHours: positiveInteger(env.PIBO_COMPUTE_POOL_ARTIFACT_RETENTION_HOURS, 24),
29
+ maxArtifacts: positiveInteger(env.PIBO_COMPUTE_POOL_MAX_ARTIFACTS, 10),
30
+ minMemoryAvailableMb: positiveInteger(env.PIBO_COMPUTE_POOL_MIN_MEMORY_AVAILABLE_MB, 1536),
31
+ minDiskAvailableGb: positiveInteger(env.PIBO_COMPUTE_POOL_MIN_DISK_AVAILABLE_GB, 10),
32
+ runtimeImage: nonEmpty(env.PIBO_COMPUTE_POOL_RUNTIME_IMAGE, "pibo:latest"),
33
+ envFile: optionalResolved(env.PIBO_COMPUTE_POOL_ENV_FILE),
34
+ seedSourceHome: resolve(env.PIBO_COMPUTE_POOL_SEED_SOURCE_HOME ?? getPiboHome()),
35
+ seedSourcePiHome: optionalResolved(env.PIBO_COMPUTE_POOL_SEED_SOURCE_PI_HOME),
36
+ seedSourceWorkspace: optionalResolved(env.PIBO_COMPUTE_POOL_SEED_SOURCE_WORKSPACE),
37
+ };
38
+ }
39
+ export function requireDeploymentPoolBaseURL(config) {
40
+ if (!config.baseURL) {
41
+ throw new Error("PIBO_COMPUTE_POOL_BASE_URL is required for deployment acquire");
42
+ }
43
+ return config.baseURL;
44
+ }
45
+ export function deploymentSlotDefinitions(config) {
46
+ return Array.from({ length: config.slotCount }, (_, index) => {
47
+ const ordinal = index + 1;
48
+ const id = `slot-${String(ordinal).padStart(2, "0")}`;
49
+ const webPort = config.portBase + index * config.portStride;
50
+ const gatewayPort = webPort + 1;
51
+ return {
52
+ id,
53
+ ordinal,
54
+ webPort,
55
+ gatewayPort,
56
+ publicUrl: config.baseURL ? deploymentSlotURL(id, config.baseURL).toString() : undefined,
57
+ };
58
+ });
59
+ }
60
+ export function deploymentSlotURL(slotId, baseURL) {
61
+ if (!/^slot-\d{2}$/.test(slotId))
62
+ throw new Error(`Invalid deployment slot id "${slotId}"`);
63
+ const url = new URL(baseURL.toString());
64
+ url.hostname = `${slotId}.${baseURL.hostname}`;
65
+ return url;
66
+ }
67
+ function parseBaseURL(value) {
68
+ if (!value?.trim())
69
+ return undefined;
70
+ let url;
71
+ try {
72
+ url = new URL(value);
73
+ }
74
+ catch {
75
+ throw new Error("PIBO_COMPUTE_POOL_BASE_URL must be an absolute HTTP or HTTPS URL");
76
+ }
77
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
78
+ throw new Error("PIBO_COMPUTE_POOL_BASE_URL must contain only scheme, hostname, and optional port");
79
+ }
80
+ return url;
81
+ }
82
+ function positiveInteger(value, fallback) {
83
+ if (!value?.trim())
84
+ return fallback;
85
+ const parsed = Number(value);
86
+ if (!Number.isInteger(parsed) || parsed < 1)
87
+ throw new Error(`Expected positive integer, received "${value}"`);
88
+ return parsed;
89
+ }
90
+ function validPort(value, fallback) {
91
+ const port = positiveInteger(value, fallback);
92
+ if (port > 65535)
93
+ throw new Error(`Invalid port ${port}`);
94
+ return port;
95
+ }
96
+ function nonEmpty(value, fallback) {
97
+ return value?.trim() || fallback;
98
+ }
99
+ function optionalResolved(value) {
100
+ return value?.trim() ? resolve(value) : undefined;
101
+ }
@@ -0,0 +1,157 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { promisify } from "node:util";
4
+ const execFileAsync = promisify(execFile);
5
+ export const DEPLOYMENT_POOL_LABEL = "pibo.deploymentPool";
6
+ export const DEPLOYMENT_LEASE_LABEL = "pibo.deployment.leaseId";
7
+ export const DEPLOYMENT_SLOT_LABEL = "pibo.deployment.slotId";
8
+ export const DEPLOYMENT_HOLDER_LABEL = "pibo.deployment.holder";
9
+ export const DEPLOYMENT_EXPIRES_LABEL = "pibo.deployment.expiresAt";
10
+ export const DEPLOYMENT_ARTIFACT_LABEL = "pibo.deployment.artifactSha256";
11
+ export const DEPLOYMENT_SEED_LABEL = "pibo.deployment.seedMode";
12
+ export function buildDeploymentContainerArgs(input) {
13
+ const command = [
14
+ "set -eu",
15
+ "export DISPLAY=:99",
16
+ "if command -v Xvfb >/dev/null 2>&1 && ! pgrep -x Xvfb >/dev/null 2>&1; then Xvfb :99 -screen 0 1920x1080x24 -ac -nolisten tcp >/tmp/xvfb.log 2>&1 & fi",
17
+ "exec node /opt/pibo-runtime/node_modules/@pasko70/pibo/dist/bin/pibo.js gateway:web --web-host 0.0.0.0 --web-port 4788 --gateway-port 4789",
18
+ ].join("; ");
19
+ return [
20
+ "run",
21
+ "-d",
22
+ "--name",
23
+ input.lease.containerName,
24
+ "--hostname",
25
+ input.slot.id,
26
+ "--memory",
27
+ "1536m",
28
+ "--memory-swap",
29
+ "1536m",
30
+ "--cpus",
31
+ "1.0",
32
+ "--pids-limit",
33
+ "512",
34
+ "--shm-size",
35
+ "512m",
36
+ "--init",
37
+ "--restart",
38
+ "no",
39
+ "--log-driver",
40
+ "json-file",
41
+ "--log-opt",
42
+ "max-size=10m",
43
+ "--log-opt",
44
+ "max-file=3",
45
+ "-e",
46
+ "NODE_ENV=production",
47
+ "-e",
48
+ "PIBO_HOME=/root/.pibo",
49
+ "-e",
50
+ "HOME=/root",
51
+ "-e",
52
+ "PIBO_COMPUTE_POOL=1",
53
+ "-e",
54
+ `PIBO_COMPUTE_POOL_LEASE_ID=${input.lease.id}`,
55
+ ...(input.config.envFile && existsSync(input.config.envFile) ? ["--env-file", input.config.envFile] : []),
56
+ "-p",
57
+ `127.0.0.1:${input.slot.webPort}:4788`,
58
+ "-p",
59
+ `127.0.0.1:${input.slot.gatewayPort}:4789`,
60
+ "-v",
61
+ `${input.lease.artifactRuntimePath}:/opt/pibo-runtime:ro`,
62
+ "-v",
63
+ `${input.homePath}:/root/.pibo`,
64
+ "-v",
65
+ `${input.piHomePath}:/root/.pi`,
66
+ "-v",
67
+ `${input.workspacePath}:/workspace`,
68
+ "-w",
69
+ "/workspace",
70
+ "--label",
71
+ `${DEPLOYMENT_POOL_LABEL}=true`,
72
+ "--label",
73
+ `${DEPLOYMENT_LEASE_LABEL}=${input.lease.id}`,
74
+ "--label",
75
+ `${DEPLOYMENT_SLOT_LABEL}=${input.slot.id}`,
76
+ "--label",
77
+ `${DEPLOYMENT_HOLDER_LABEL}=${safeLabelValue(input.lease.holder)}`,
78
+ "--label",
79
+ `${DEPLOYMENT_EXPIRES_LABEL}=${input.lease.expiresAt}`,
80
+ "--label",
81
+ `${DEPLOYMENT_ARTIFACT_LABEL}=${input.lease.artifactSha256}`,
82
+ "--label",
83
+ `${DEPLOYMENT_SEED_LABEL}=${input.lease.seedMode}`,
84
+ "--entrypoint",
85
+ "/bin/sh",
86
+ input.config.runtimeImage,
87
+ "-lc",
88
+ command,
89
+ ];
90
+ }
91
+ export async function startDeploymentContainer(input) {
92
+ await execFileAsync("docker", buildDeploymentContainerArgs(input), { maxBuffer: 10 * 1024 * 1024 });
93
+ }
94
+ export async function removeDeploymentContainer(containerName) {
95
+ try {
96
+ await execFileAsync("docker", ["stop", "-t", "10", containerName]);
97
+ }
98
+ catch {
99
+ // Container may already be stopped or missing.
100
+ }
101
+ try {
102
+ await execFileAsync("docker", ["rm", "-f", containerName]);
103
+ }
104
+ catch (error) {
105
+ if (!String(error?.stderr ?? error?.message ?? error).includes("No such container"))
106
+ throw error;
107
+ }
108
+ }
109
+ export async function deploymentContainerExists(containerName) {
110
+ try {
111
+ await execFileAsync("docker", ["inspect", "--type=container", containerName]);
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ export async function listDeploymentContainers() {
119
+ const { stdout } = await execFileAsync("docker", [
120
+ "ps", "--all", "--filter", `label=${DEPLOYMENT_POOL_LABEL}=true`,
121
+ "--format", "{{.ID}}\t{{.Names}}\t{{.State}}\t{{.Label \"pibo.deployment.leaseId\"}}\t{{.Label \"pibo.deployment.slotId\"}}",
122
+ ]);
123
+ return stdout.trim().split("\n").filter(Boolean).map((line) => {
124
+ const [id, name, state, leaseId, slotId] = line.split("\t");
125
+ return { id: id, name: name, state: state ?? "unknown", leaseId: leaseId || undefined, slotId: slotId || undefined };
126
+ });
127
+ }
128
+ export async function waitForDeploymentHealth(webPort, options = {}) {
129
+ const deadline = Date.now() + (options.timeoutMs ?? 90_000);
130
+ let lastError = "not ready";
131
+ while (Date.now() < deadline) {
132
+ try {
133
+ const response = await fetch(`http://127.0.0.1:${webPort}/health`, { signal: AbortSignal.timeout(2_000) });
134
+ if (response.ok)
135
+ return;
136
+ lastError = `HTTP ${response.status}`;
137
+ }
138
+ catch (error) {
139
+ lastError = error instanceof Error ? error.message : String(error);
140
+ }
141
+ await new Promise((resolve) => setTimeout(resolve, options.intervalMs ?? 500));
142
+ }
143
+ throw new Error(`Deployment slot health check timed out on port ${webPort}: ${lastError}`);
144
+ }
145
+ export async function dockerImageExists(image) {
146
+ try {
147
+ await execFileAsync("docker", ["inspect", "--type=image", image]);
148
+ return true;
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
154
+ function safeLabelValue(value) {
155
+ const safe = value.replace(/[^A-Za-z0-9._:@-]/g, "-").slice(0, 128);
156
+ return safe || "unknown";
157
+ }