@pasko70/pibo 2.3.0 → 2.4.1
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.
- package/dist/agent-runtime/routed-session.js +1 -0
- package/dist/agent-runtimes/codex-native/turn.js +3 -1
- package/dist/agent-runtimes/omp/turn.js +36 -7
- package/dist/apps/chat/web-app.js +6 -8
- package/dist/apps/chat-ui/assets/{dist-CUcAofmV.js → dist-3YG57JXi.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-Byygd1lH.js → dist-BeqHbnGN.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DusFwy0L.js → dist-CrDtveZB.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-C9BrS7sL.js → dist-Cw9po47P.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-D4RU6xu3.js → dist-DTRjeLwO.js} +1 -1
- package/dist/apps/chat-ui/assets/{index-Bifi_kjN.js → index-AjnP3ci-.js} +89 -89
- package/dist/apps/chat-ui/index.html +1 -1
- package/dist/apps/chat-vscode-web/assets/{index-WsLm1mo3.js → index-DvTSSvzN.js} +5 -5
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/apps/vscode-artifacts/latest.vsix +0 -0
- package/dist/apps/vscode-artifacts/{pibo-vscode-ext-2.3.0.vsix → pibo-vscode-ext-2.4.1.vsix} +0 -0
- package/dist/compute/cli.js +13 -0
- package/dist/compute/pool/artifacts.js +116 -0
- package/dist/compute/pool/cli.js +156 -0
- package/dist/compute/pool/config.js +101 -0
- package/dist/compute/pool/docker.js +157 -0
- package/dist/compute/pool/seeds.js +201 -0
- package/dist/compute/pool/service.js +402 -0
- package/dist/compute/pool/store.js +239 -0
- package/dist/compute/pool/types.js +1 -0
- package/dist/gateway/web.js +8 -1
- package/dist/loops/accounting.js +27 -0
- package/dist/loops/cli.js +6 -5
- package/dist/loops/prompts.js +13 -5
- package/dist/loops/service.js +3 -1
- package/dist/loops/store.js +10 -6
- package/dist/loops/tools.js +7 -4
- package/dist/mcp/config-command.js +3 -2
- package/dist/mcp/config.js +10 -4
- package/dist/mcp/errors.js +1 -1
- package/dist/mcp/index.js +19 -33
- package/dist/resources/lifecycle.js +22 -2
- package/dist/resources/reaper.js +1 -0
- package/dist/session-ui/sessionActivity.js +6 -2
- package/dist/signals/status.js +9 -4
- package/dist/tools/guides.js +1 -1
- package/dist/web/channel.js +19 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/builtin/loop/SKILL.md +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { chmod, cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { backup, DatabaseSync } from "node:sqlite";
|
|
5
|
+
const SQLITE_SUFFIXES = [".sqlite", ".sqlite-shm", ".sqlite-wal"];
|
|
6
|
+
const COMMON_EXCLUDED_NAMES = new Set([
|
|
7
|
+
"gateway.pid",
|
|
8
|
+
"resource-reaper-state.json",
|
|
9
|
+
"resource-reaper-state.json.lock",
|
|
10
|
+
"compute-image-hash",
|
|
11
|
+
"compute-dep-hash",
|
|
12
|
+
]);
|
|
13
|
+
const FULL_EXCLUDED_TOP_LEVEL = new Set([
|
|
14
|
+
"agent-runtimes",
|
|
15
|
+
"backups",
|
|
16
|
+
"candidate-packages",
|
|
17
|
+
"debug",
|
|
18
|
+
"migration-reports",
|
|
19
|
+
"quarantine",
|
|
20
|
+
"secrets",
|
|
21
|
+
"skill-backups",
|
|
22
|
+
"tools",
|
|
23
|
+
"validation",
|
|
24
|
+
"vscode",
|
|
25
|
+
]);
|
|
26
|
+
const MEDIUM_ALLOWED_TOP_LEVEL = new Set([
|
|
27
|
+
"config.json",
|
|
28
|
+
"machine-keys.json",
|
|
29
|
+
"model-defaults.json",
|
|
30
|
+
"base-prompt.json",
|
|
31
|
+
"base-prompt.md",
|
|
32
|
+
"gateway-settings.json",
|
|
33
|
+
"user-settings.json",
|
|
34
|
+
"user-skills.json",
|
|
35
|
+
"chat-agents.sqlite",
|
|
36
|
+
"pibo.sqlite",
|
|
37
|
+
"pibo-events.sqlite",
|
|
38
|
+
"web-projects.sqlite",
|
|
39
|
+
"web-annotations.sqlite",
|
|
40
|
+
"context-files",
|
|
41
|
+
"user-skills",
|
|
42
|
+
"projects",
|
|
43
|
+
]);
|
|
44
|
+
const FRESH_ALLOWED_TOP_LEVEL = new Set([
|
|
45
|
+
"config.json",
|
|
46
|
+
"machine-keys.json",
|
|
47
|
+
"model-defaults.json",
|
|
48
|
+
"base-prompt.json",
|
|
49
|
+
"base-prompt.md",
|
|
50
|
+
"gateway-settings.json",
|
|
51
|
+
"user-settings.json",
|
|
52
|
+
"user-skills.json",
|
|
53
|
+
"context-files",
|
|
54
|
+
"user-skills",
|
|
55
|
+
]);
|
|
56
|
+
export async function prepareDeploymentSeed(input) {
|
|
57
|
+
const slotRoot = resolve(input.config.slotsRoot, input.slotId);
|
|
58
|
+
const activeRoot = resolve(slotRoot, "active");
|
|
59
|
+
const stagingRoot = resolve(slotRoot, `.staging-${process.pid}-${Date.now()}`);
|
|
60
|
+
const homePath = resolve(stagingRoot, "pibo-home");
|
|
61
|
+
const piHomePath = resolve(stagingRoot, "pi-home");
|
|
62
|
+
const workspacePath = resolve(stagingRoot, "workspace");
|
|
63
|
+
await mkdir(slotRoot, { recursive: true, mode: 0o700 });
|
|
64
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
65
|
+
await mkdir(homePath, { recursive: true, mode: 0o700 });
|
|
66
|
+
await mkdir(piHomePath, { recursive: true, mode: 0o700 });
|
|
67
|
+
await mkdir(workspacePath, { recursive: true, mode: 0o700 });
|
|
68
|
+
try {
|
|
69
|
+
await copySeedNonDatabaseFiles(input.config.seedSourceHome, homePath, input.mode, input.config.root);
|
|
70
|
+
const copiedDatabases = await backupSeedDatabases(input.config.seedSourceHome, homePath, input.mode);
|
|
71
|
+
if (input.config.seedSourcePiHome && existsSync(input.config.seedSourcePiHome)) {
|
|
72
|
+
await copyPiRuntimeAuth(input.config.seedSourcePiHome, piHomePath);
|
|
73
|
+
}
|
|
74
|
+
if (input.mode === "full" && input.config.seedSourceWorkspace && existsSync(input.config.seedSourceWorkspace)) {
|
|
75
|
+
await cp(input.config.seedSourceWorkspace, workspacePath, { recursive: true, force: true, preserveTimestamps: true });
|
|
76
|
+
}
|
|
77
|
+
await configureSlotAuth(homePath, input.publicUrl);
|
|
78
|
+
await writeFile(resolve(stagingRoot, "seed.json"), `${JSON.stringify({ mode: input.mode, createdAt: new Date().toISOString(), sourceHome: input.config.seedSourceHome, copiedDatabases }, null, 2)}\n`, { mode: 0o600 });
|
|
79
|
+
await rm(activeRoot, { recursive: true, force: true });
|
|
80
|
+
await mkdir(dirname(activeRoot), { recursive: true, mode: 0o700 });
|
|
81
|
+
await import("node:fs/promises").then(({ rename }) => rename(stagingRoot, activeRoot));
|
|
82
|
+
await chmod(resolve(activeRoot, "pibo-home"), 0o700);
|
|
83
|
+
await chmod(resolve(activeRoot, "pi-home"), 0o700);
|
|
84
|
+
return {
|
|
85
|
+
homePath: resolve(activeRoot, "pibo-home"),
|
|
86
|
+
piHomePath: resolve(activeRoot, "pi-home"),
|
|
87
|
+
workspacePath: resolve(activeRoot, "workspace"),
|
|
88
|
+
mode: input.mode,
|
|
89
|
+
copiedDatabases,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function copySeedNonDatabaseFiles(sourceHome, destinationHome, mode, poolRoot) {
|
|
98
|
+
if (!existsSync(sourceHome))
|
|
99
|
+
throw new Error(`Deployment seed source home does not exist: ${sourceHome}`);
|
|
100
|
+
const sourceRoot = resolve(sourceHome);
|
|
101
|
+
const pool = resolve(poolRoot);
|
|
102
|
+
for (const entry of await readdir(sourceRoot, { withFileTypes: true })) {
|
|
103
|
+
if (!shouldCopyTopLevel(entry.name, mode))
|
|
104
|
+
continue;
|
|
105
|
+
const source = resolve(sourceRoot, entry.name);
|
|
106
|
+
if (isWithin(source, pool) || isWithin(pool, source))
|
|
107
|
+
continue;
|
|
108
|
+
if (SQLITE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix)))
|
|
109
|
+
continue;
|
|
110
|
+
await cp(source, resolve(destinationHome, entry.name), {
|
|
111
|
+
recursive: true,
|
|
112
|
+
force: true,
|
|
113
|
+
preserveTimestamps: true,
|
|
114
|
+
filter(candidate) {
|
|
115
|
+
const name = basename(candidate);
|
|
116
|
+
if (COMMON_EXCLUDED_NAMES.has(name))
|
|
117
|
+
return false;
|
|
118
|
+
if (SQLITE_SUFFIXES.some((suffix) => name.endsWith(suffix)))
|
|
119
|
+
return false;
|
|
120
|
+
if (/\.(pid|sock|lock)$/i.test(name))
|
|
121
|
+
return false;
|
|
122
|
+
return !isWithin(resolve(candidate), pool);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function shouldCopyTopLevel(name, mode) {
|
|
128
|
+
if (COMMON_EXCLUDED_NAMES.has(name))
|
|
129
|
+
return false;
|
|
130
|
+
if (mode === "fresh")
|
|
131
|
+
return FRESH_ALLOWED_TOP_LEVEL.has(name);
|
|
132
|
+
if (mode === "medium")
|
|
133
|
+
return MEDIUM_ALLOWED_TOP_LEVEL.has(name);
|
|
134
|
+
return !FULL_EXCLUDED_TOP_LEVEL.has(name) && name !== "compute-pool";
|
|
135
|
+
}
|
|
136
|
+
async function backupSeedDatabases(sourceHome, destinationHome, mode) {
|
|
137
|
+
if (mode === "fresh")
|
|
138
|
+
return [];
|
|
139
|
+
const allowed = mode === "medium"
|
|
140
|
+
? new Set(["chat-agents.sqlite", "pibo.sqlite", "pibo-events.sqlite", "web-projects.sqlite", "web-annotations.sqlite"])
|
|
141
|
+
: undefined;
|
|
142
|
+
const copied = [];
|
|
143
|
+
for (const entry of await readdir(sourceHome, { withFileTypes: true })) {
|
|
144
|
+
if (!entry.isFile() || !entry.name.endsWith(".sqlite"))
|
|
145
|
+
continue;
|
|
146
|
+
if (entry.name === "auth.sqlite" || entry.name === "previews.sqlite")
|
|
147
|
+
continue;
|
|
148
|
+
if (allowed && !allowed.has(entry.name))
|
|
149
|
+
continue;
|
|
150
|
+
const sourcePath = resolve(sourceHome, entry.name);
|
|
151
|
+
const destinationPath = resolve(destinationHome, entry.name);
|
|
152
|
+
const sourceDb = new DatabaseSync(sourcePath, { readOnly: true });
|
|
153
|
+
try {
|
|
154
|
+
await backup(sourceDb, destinationPath);
|
|
155
|
+
await chmod(destinationPath, 0o600);
|
|
156
|
+
copied.push(entry.name);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
sourceDb.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return copied.sort();
|
|
163
|
+
}
|
|
164
|
+
async function copyPiRuntimeAuth(sourcePiHome, destinationPiHome) {
|
|
165
|
+
const sourceAgent = resolve(sourcePiHome, "agent");
|
|
166
|
+
const destinationAgent = resolve(destinationPiHome, "agent");
|
|
167
|
+
await mkdir(destinationAgent, { recursive: true, mode: 0o700 });
|
|
168
|
+
for (const name of ["auth.json", "models-store.json"]) {
|
|
169
|
+
const source = resolve(sourceAgent, name);
|
|
170
|
+
if (!existsSync(source))
|
|
171
|
+
continue;
|
|
172
|
+
await cp(source, resolve(destinationAgent, name), { force: true, preserveTimestamps: true });
|
|
173
|
+
await chmod(resolve(destinationAgent, name), 0o600);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function configureSlotAuth(homePath, publicUrl) {
|
|
177
|
+
const path = resolve(homePath, "config.json");
|
|
178
|
+
let config = {};
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
181
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
182
|
+
config = parsed;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
const auth = config.auth && typeof config.auth === "object" && !Array.isArray(config.auth)
|
|
189
|
+
? { ...config.auth }
|
|
190
|
+
: {};
|
|
191
|
+
auth.mode = "better-auth";
|
|
192
|
+
auth.baseURL = new URL(publicUrl).origin;
|
|
193
|
+
const trusted = Array.isArray(auth.trustedOrigins) ? auth.trustedOrigins.filter((value) => typeof value === "string") : [];
|
|
194
|
+
auth.trustedOrigins = [...new Set([...trusted, new URL(publicUrl).origin])];
|
|
195
|
+
config.auth = auth;
|
|
196
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
197
|
+
}
|
|
198
|
+
function isWithin(parent, child) {
|
|
199
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
200
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
201
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, stat, statfs, writeFile } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { deploymentSlotDefinitions, requireDeploymentPoolBaseURL, resolveDeploymentPoolConfig } from "./config.js";
|
|
6
|
+
import { dockerImageExists, listDeploymentContainers, removeDeploymentContainer, startDeploymentContainer, waitForDeploymentHealth } from "./docker.js";
|
|
7
|
+
import { prepareDeploymentSeed } from "./seeds.js";
|
|
8
|
+
import { DeploymentPoolStore } from "./store.js";
|
|
9
|
+
export async function acquireDeployment(options) {
|
|
10
|
+
const config = options.config ?? resolveDeploymentPoolConfig();
|
|
11
|
+
const baseURL = requireDeploymentPoolBaseURL(config);
|
|
12
|
+
await ensurePoolDirectories(config);
|
|
13
|
+
const expiredPlan = await planDeploymentPoolReap({ config });
|
|
14
|
+
if (expiredPlan.summary.selectedLeases > 0
|
|
15
|
+
|| expiredPlan.summary.selectedOrphanContainers > 0
|
|
16
|
+
|| expiredPlan.summary.selectedDirtySlots > 0
|
|
17
|
+
|| expiredPlan.summary.selectedFailureSnapshots > 0
|
|
18
|
+
|| expiredPlan.summary.selectedArtifacts > 0) {
|
|
19
|
+
await applyDeploymentPoolReapPlan(expiredPlan, config);
|
|
20
|
+
}
|
|
21
|
+
await assertHostCapacity(config);
|
|
22
|
+
if (!(await dockerImageExists(config.runtimeImage)))
|
|
23
|
+
throw new Error(`Deployment runtime image was not found: ${config.runtimeImage}`);
|
|
24
|
+
const holder = options.holder.trim();
|
|
25
|
+
if (!holder)
|
|
26
|
+
throw new Error("Deployment holder is required");
|
|
27
|
+
const ttlMinutes = options.ttlMinutes ?? config.defaultTtlMinutes;
|
|
28
|
+
if (!Number.isInteger(ttlMinutes) || ttlMinutes < 1)
|
|
29
|
+
throw new Error("Deployment TTL must be a positive integer");
|
|
30
|
+
const now = new Date();
|
|
31
|
+
const leaseId = `lease_${randomBytes(9).toString("hex")}`;
|
|
32
|
+
const expiresAt = new Date(now.getTime() + ttlMinutes * 60_000).toISOString();
|
|
33
|
+
const store = openStore(config);
|
|
34
|
+
let lease;
|
|
35
|
+
let slotId;
|
|
36
|
+
try {
|
|
37
|
+
const reserved = store.reserveLease({
|
|
38
|
+
id: leaseId,
|
|
39
|
+
holder,
|
|
40
|
+
seedMode: options.seedMode,
|
|
41
|
+
artifactSha256: options.artifact.sha256,
|
|
42
|
+
artifactRuntimePath: options.artifact.runtimePath,
|
|
43
|
+
packageVersion: options.artifact.packageVersion,
|
|
44
|
+
commit: options.commit,
|
|
45
|
+
createdAt: now.toISOString(),
|
|
46
|
+
expiresAt,
|
|
47
|
+
maxActive: config.maxActive,
|
|
48
|
+
});
|
|
49
|
+
lease = reserved.lease;
|
|
50
|
+
slotId = reserved.slot.id;
|
|
51
|
+
const prepared = await prepareDeploymentSeed({
|
|
52
|
+
config,
|
|
53
|
+
slotId: reserved.slot.id,
|
|
54
|
+
mode: options.seedMode,
|
|
55
|
+
publicUrl: reserved.slot.publicUrl ?? new URL(`${reserved.slot.id}.${baseURL.hostname}`, baseURL).toString(),
|
|
56
|
+
});
|
|
57
|
+
await startDeploymentContainer({ config, slot: reserved.slot, lease, homePath: prepared.homePath, piHomePath: prepared.piHomePath, workspacePath: prepared.workspacePath });
|
|
58
|
+
await waitForDeploymentHealth(reserved.slot.webPort);
|
|
59
|
+
return store.markReady(lease.id);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (lease && slotId) {
|
|
63
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
let containerClean = true;
|
|
65
|
+
try {
|
|
66
|
+
await removeDeploymentContainer(lease.containerName);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
containerClean = false;
|
|
70
|
+
}
|
|
71
|
+
let snapshotPath;
|
|
72
|
+
try {
|
|
73
|
+
snapshotPath = await retainFailedSlot(config, slotId, lease.id, message);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
containerClean = false;
|
|
77
|
+
}
|
|
78
|
+
store.markFailed(lease.id, message, snapshotPath, { slotClean: containerClean });
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
store.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function getDeploymentPoolStatus(config = resolveDeploymentPoolConfig()) {
|
|
87
|
+
const store = openStore(config);
|
|
88
|
+
try {
|
|
89
|
+
const slots = store.listSlots();
|
|
90
|
+
const leases = store.listLeases();
|
|
91
|
+
const leaseById = new Map(leases.map((lease) => [lease.id, lease]));
|
|
92
|
+
const activeLeases = leases.filter((lease) => ["provisioning", "ready", "releasing"].includes(lease.status));
|
|
93
|
+
const nearestExpiry = activeLeases.map((lease) => lease.expiresAt).sort()[0];
|
|
94
|
+
return {
|
|
95
|
+
generatedAt: new Date().toISOString(),
|
|
96
|
+
configured: Boolean(config.baseURL),
|
|
97
|
+
maxActive: config.maxActive,
|
|
98
|
+
active: activeLeases.length,
|
|
99
|
+
free: slots.filter((slot) => slot.state === "free").length,
|
|
100
|
+
nearestExpiry,
|
|
101
|
+
slots: slots.map((slot) => ({ ...slot, lease: slot.activeLeaseId ? leaseById.get(slot.activeLeaseId) : undefined })),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
store.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function renewDeploymentLease(input) {
|
|
109
|
+
const config = input.config ?? resolveDeploymentPoolConfig();
|
|
110
|
+
const ttlMinutes = input.ttlMinutes ?? config.defaultTtlMinutes;
|
|
111
|
+
if (!Number.isInteger(ttlMinutes) || ttlMinutes < 1)
|
|
112
|
+
throw new Error("Deployment TTL must be a positive integer");
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const store = openStore(config);
|
|
115
|
+
try {
|
|
116
|
+
return store.renewLease(input.leaseId, input.holder.trim(), new Date(now.getTime() + ttlMinutes * 60_000).toISOString(), now.toISOString());
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
store.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export async function releaseDeploymentLease(input) {
|
|
123
|
+
const config = input.config ?? resolveDeploymentPoolConfig();
|
|
124
|
+
const store = openStore(config);
|
|
125
|
+
try {
|
|
126
|
+
const lease = store.getLease(input.leaseId);
|
|
127
|
+
if (!lease)
|
|
128
|
+
throw new Error(`Deployment lease "${input.leaseId}" was not found`);
|
|
129
|
+
if (!input.force && lease.holder !== input.holder?.trim())
|
|
130
|
+
throw new Error(`Deployment lease "${input.leaseId}" is held by another holder`);
|
|
131
|
+
if (["released", "expired"].includes(lease.status))
|
|
132
|
+
return lease;
|
|
133
|
+
store.markReleasing(lease.id);
|
|
134
|
+
await removeDeploymentContainer(lease.containerName);
|
|
135
|
+
await rm(resolve(config.slotsRoot, lease.slotId, "active"), { recursive: true, force: true });
|
|
136
|
+
return store.markReleased(lease.id, input.expired ? "expired" : "released");
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const lease = store.getLease(input.leaseId);
|
|
140
|
+
if (lease && !["released", "expired", "failed"].includes(lease.status)) {
|
|
141
|
+
store.markFailed(lease.id, error instanceof Error ? error.message : String(error), lease.failureSnapshotPath, { slotClean: false });
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
store.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function planDeploymentPoolReap(input = {}) {
|
|
150
|
+
const config = input.config ?? resolveDeploymentPoolConfig();
|
|
151
|
+
const now = input.now ?? new Date();
|
|
152
|
+
const store = openStore(config);
|
|
153
|
+
try {
|
|
154
|
+
const slots = store.listSlots();
|
|
155
|
+
const leases = store.listLeases();
|
|
156
|
+
let containers;
|
|
157
|
+
try {
|
|
158
|
+
containers = await (input.listContainers ?? listDeploymentContainers)();
|
|
159
|
+
}
|
|
160
|
+
catch { /* Docker unavailable: do not infer missing containers. */ }
|
|
161
|
+
const containerByLease = new Map((containers ?? []).filter((container) => container.leaseId).map((container) => [container.leaseId, container]));
|
|
162
|
+
const items = leases.map((lease) => {
|
|
163
|
+
const reasons = [];
|
|
164
|
+
if (Date.parse(lease.expiresAt) <= now.getTime())
|
|
165
|
+
reasons.push("expired");
|
|
166
|
+
const container = containerByLease.get(lease.id);
|
|
167
|
+
const provisioningGraceElapsed = lease.status !== "provisioning" || Date.parse(lease.createdAt) + 5 * 60_000 <= now.getTime();
|
|
168
|
+
if (containers && !container && provisioningGraceElapsed)
|
|
169
|
+
reasons.push("container-missing");
|
|
170
|
+
if (container && container.state !== "running")
|
|
171
|
+
reasons.push(`container-${container.state}`);
|
|
172
|
+
return { lease, action: reasons.length ? "release" : "skip", reasons };
|
|
173
|
+
});
|
|
174
|
+
const activeLeaseById = new Map(leases.map((lease) => [lease.id, lease]));
|
|
175
|
+
const orphanContainers = (containers ?? []).map((container) => {
|
|
176
|
+
const lease = container.leaseId ? activeLeaseById.get(container.leaseId) : undefined;
|
|
177
|
+
const keep = Boolean(lease && lease.containerName === container.name);
|
|
178
|
+
return {
|
|
179
|
+
name: container.name,
|
|
180
|
+
leaseId: container.leaseId,
|
|
181
|
+
slotId: container.slotId,
|
|
182
|
+
action: keep ? "keep" : "remove",
|
|
183
|
+
reason: keep ? "active-lease" : "no-active-registry-lease",
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
const dirtySlots = slots.filter((slot) => slot.state === "dirty").map((slot) => ({
|
|
187
|
+
slotId: slot.id,
|
|
188
|
+
leaseId: slot.activeLeaseId,
|
|
189
|
+
action: containers ? "clean" : "keep",
|
|
190
|
+
reason: containers ? "dirty-slot-reconciled" : "docker-unavailable",
|
|
191
|
+
}));
|
|
192
|
+
const snapshots = await listFailureSnapshots(config);
|
|
193
|
+
const ordered = [...snapshots].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
194
|
+
const failureSnapshots = ordered.map((snapshot, index) => {
|
|
195
|
+
if (Date.parse(snapshot.expiresAt) <= now.getTime())
|
|
196
|
+
return { ...snapshot, action: "remove", reason: "retention-expired" };
|
|
197
|
+
if (index >= config.maxFailedSnapshots)
|
|
198
|
+
return { ...snapshot, action: "remove", reason: "snapshot-cap-exceeded" };
|
|
199
|
+
return { ...snapshot, action: "keep", reason: "within-retention" };
|
|
200
|
+
});
|
|
201
|
+
const activeArtifactHashes = new Set(leases.map((lease) => lease.artifactSha256));
|
|
202
|
+
const artifactEntries = await listDeploymentArtifactEntries(config, false);
|
|
203
|
+
const unreferenced = artifactEntries.filter((artifact) => !activeArtifactHashes.has(artifact.sha256));
|
|
204
|
+
const artifactExpiry = now.getTime() - config.artifactRetentionHours * 60 * 60_000;
|
|
205
|
+
const artifacts = artifactEntries.map((artifact) => {
|
|
206
|
+
if (activeArtifactHashes.has(artifact.sha256))
|
|
207
|
+
return { ...artifact, action: "keep", reason: "active-lease" };
|
|
208
|
+
const unreferencedIndex = unreferenced.findIndex((candidate) => candidate.sha256 === artifact.sha256);
|
|
209
|
+
if (Date.parse(artifact.modifiedAt) <= artifactExpiry)
|
|
210
|
+
return { ...artifact, action: "remove", reason: "retention-expired" };
|
|
211
|
+
if (unreferencedIndex >= config.maxArtifacts)
|
|
212
|
+
return { ...artifact, action: "remove", reason: "artifact-cap-exceeded" };
|
|
213
|
+
return { ...artifact, action: "keep", reason: "within-retention" };
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
createdAt: now.toISOString(),
|
|
217
|
+
dryRun: true,
|
|
218
|
+
items,
|
|
219
|
+
orphanContainers,
|
|
220
|
+
dirtySlots,
|
|
221
|
+
failureSnapshots,
|
|
222
|
+
artifacts,
|
|
223
|
+
summary: {
|
|
224
|
+
selectedLeases: items.filter((item) => item.action === "release").length,
|
|
225
|
+
selectedOrphanContainers: orphanContainers.filter((item) => item.action === "remove").length,
|
|
226
|
+
selectedDirtySlots: dirtySlots.filter((item) => item.action === "clean").length,
|
|
227
|
+
selectedFailureSnapshots: failureSnapshots.filter((item) => item.action === "remove").length,
|
|
228
|
+
selectedArtifacts: artifacts.filter((item) => item.action === "remove").length,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
store.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export async function applyDeploymentPoolReapPlan(_plan, config = resolveDeploymentPoolConfig()) {
|
|
237
|
+
const confirmed = await planDeploymentPoolReap({ config, now: new Date() });
|
|
238
|
+
const removedOrphanContainers = [];
|
|
239
|
+
for (const container of confirmed.orphanContainers) {
|
|
240
|
+
if (container.action !== "remove")
|
|
241
|
+
continue;
|
|
242
|
+
await removeDeploymentContainer(container.name);
|
|
243
|
+
removedOrphanContainers.push(container.name);
|
|
244
|
+
}
|
|
245
|
+
const cleanedDirtySlots = [];
|
|
246
|
+
const reconcileStore = openStore(config);
|
|
247
|
+
try {
|
|
248
|
+
for (const slot of confirmed.dirtySlots) {
|
|
249
|
+
if (slot.action !== "clean")
|
|
250
|
+
continue;
|
|
251
|
+
await rm(resolve(config.slotsRoot, slot.slotId, "active"), { recursive: true, force: true });
|
|
252
|
+
reconcileStore.freeDirtySlot(slot.slotId);
|
|
253
|
+
cleanedDirtySlots.push(slot.slotId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
reconcileStore.close();
|
|
258
|
+
}
|
|
259
|
+
const releasedLeases = [];
|
|
260
|
+
for (const item of confirmed.items) {
|
|
261
|
+
if (item.action !== "release")
|
|
262
|
+
continue;
|
|
263
|
+
await releaseDeploymentLease({ leaseId: item.lease.id, force: true, expired: item.reasons.includes("expired"), config });
|
|
264
|
+
releasedLeases.push(item.lease.id);
|
|
265
|
+
}
|
|
266
|
+
const removedFailureSnapshots = [];
|
|
267
|
+
for (const snapshot of confirmed.failureSnapshots) {
|
|
268
|
+
if (snapshot.action !== "remove")
|
|
269
|
+
continue;
|
|
270
|
+
await rm(snapshot.path, { recursive: true, force: true });
|
|
271
|
+
removedFailureSnapshots.push(snapshot.path);
|
|
272
|
+
}
|
|
273
|
+
const removedArtifacts = [];
|
|
274
|
+
for (const artifact of confirmed.artifacts) {
|
|
275
|
+
if (artifact.action !== "remove")
|
|
276
|
+
continue;
|
|
277
|
+
await rm(artifact.path, { recursive: true, force: true });
|
|
278
|
+
removedArtifacts.push(artifact.path);
|
|
279
|
+
}
|
|
280
|
+
return { applied: true, plan: confirmed, releasedLeases, removedOrphanContainers, cleanedDirtySlots, removedFailureSnapshots, removedArtifacts };
|
|
281
|
+
}
|
|
282
|
+
export async function getDeploymentPoolDoctor(config = resolveDeploymentPoolConfig()) {
|
|
283
|
+
const containers = await listDeploymentContainers().catch(() => []);
|
|
284
|
+
const status = getDeploymentPoolStatus(config);
|
|
285
|
+
const reapPlan = await planDeploymentPoolReap({ config });
|
|
286
|
+
return {
|
|
287
|
+
generatedAt: new Date().toISOString(),
|
|
288
|
+
configured: status.configured,
|
|
289
|
+
baseURL: config.baseURL?.toString(),
|
|
290
|
+
root: config.root,
|
|
291
|
+
runtimeImage: config.runtimeImage,
|
|
292
|
+
runtimeImageAvailable: await dockerImageExists(config.runtimeImage),
|
|
293
|
+
seedSourceHome: config.seedSourceHome,
|
|
294
|
+
seedSourceHomeAvailable: existsSync(config.seedSourceHome),
|
|
295
|
+
envFileConfigured: Boolean(config.envFile),
|
|
296
|
+
envFileAvailable: config.envFile ? existsSync(config.envFile) : undefined,
|
|
297
|
+
maxActive: config.maxActive,
|
|
298
|
+
slotCount: config.slotCount,
|
|
299
|
+
status,
|
|
300
|
+
containers,
|
|
301
|
+
reconciliation: reapPlan.summary,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
export async function listDeploymentArtifacts(config = resolveDeploymentPoolConfig()) {
|
|
305
|
+
return listDeploymentArtifactEntries(config, true);
|
|
306
|
+
}
|
|
307
|
+
function openStore(config) {
|
|
308
|
+
return new DeploymentPoolStore(config.databasePath, deploymentSlotDefinitions(config));
|
|
309
|
+
}
|
|
310
|
+
async function ensurePoolDirectories(config) {
|
|
311
|
+
await Promise.all([
|
|
312
|
+
mkdir(config.root, { recursive: true, mode: 0o700 }),
|
|
313
|
+
mkdir(config.artifactRoot, { recursive: true, mode: 0o700 }),
|
|
314
|
+
mkdir(config.slotsRoot, { recursive: true, mode: 0o700 }),
|
|
315
|
+
mkdir(config.failuresRoot, { recursive: true, mode: 0o700 }),
|
|
316
|
+
]);
|
|
317
|
+
}
|
|
318
|
+
async function assertHostCapacity(config) {
|
|
319
|
+
let availableBytes;
|
|
320
|
+
try {
|
|
321
|
+
const meminfo = await readFile("/proc/meminfo", "utf8");
|
|
322
|
+
const match = meminfo.match(/^MemAvailable:\s+(\d+)\s+kB$/m);
|
|
323
|
+
if (match)
|
|
324
|
+
availableBytes = Number(match[1]) * 1024;
|
|
325
|
+
}
|
|
326
|
+
catch { /* non-Linux tests may not expose meminfo */ }
|
|
327
|
+
const minimumMemoryBytes = config.minMemoryAvailableMb * 1024 * 1024;
|
|
328
|
+
if (availableBytes !== undefined && availableBytes < minimumMemoryBytes) {
|
|
329
|
+
throw new Error(`Deployment pool host memory reserve would be violated: available=${availableBytes} required=${minimumMemoryBytes}`);
|
|
330
|
+
}
|
|
331
|
+
const filesystem = await statfs(config.root);
|
|
332
|
+
const availableDiskBytes = Number(filesystem.bavail) * Number(filesystem.bsize);
|
|
333
|
+
const minimumDiskBytes = config.minDiskAvailableGb * 1024 ** 3;
|
|
334
|
+
if (availableDiskBytes < minimumDiskBytes) {
|
|
335
|
+
throw new Error(`Deployment pool disk reserve would be violated: available=${availableDiskBytes} required=${minimumDiskBytes}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function retainFailedSlot(config, slotId, leaseId, error) {
|
|
339
|
+
const active = resolve(config.slotsRoot, slotId, "active");
|
|
340
|
+
if (!existsSync(active))
|
|
341
|
+
return undefined;
|
|
342
|
+
await mkdir(config.failuresRoot, { recursive: true, mode: 0o700 });
|
|
343
|
+
const path = resolve(config.failuresRoot, `${new Date().toISOString().replace(/[:.]/g, "-")}-${leaseId}`);
|
|
344
|
+
await rename(active, path);
|
|
345
|
+
const createdAt = new Date();
|
|
346
|
+
await writeFile(resolve(path, "failure.json"), `${JSON.stringify({ leaseId, slotId, error, createdAt: createdAt.toISOString(), expiresAt: new Date(createdAt.getTime() + config.failedRetentionMinutes * 60_000).toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
347
|
+
return path;
|
|
348
|
+
}
|
|
349
|
+
async function listFailureSnapshots(config) {
|
|
350
|
+
let names;
|
|
351
|
+
try {
|
|
352
|
+
names = await readdir(config.failuresRoot);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
const snapshots = [];
|
|
358
|
+
for (const name of names) {
|
|
359
|
+
const path = resolve(config.failuresRoot, name);
|
|
360
|
+
try {
|
|
361
|
+
const metadata = JSON.parse(await readFile(resolve(path, "failure.json"), "utf8"));
|
|
362
|
+
if (typeof metadata.leaseId === "string" && typeof metadata.createdAt === "string" && typeof metadata.expiresAt === "string") {
|
|
363
|
+
snapshots.push({ leaseId: metadata.leaseId, path, createdAt: metadata.createdAt, expiresAt: metadata.expiresAt });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch { /* malformed snapshots are left for operator inspection */ }
|
|
367
|
+
}
|
|
368
|
+
return snapshots;
|
|
369
|
+
}
|
|
370
|
+
async function listDeploymentArtifactEntries(config, includeSize) {
|
|
371
|
+
let names;
|
|
372
|
+
try {
|
|
373
|
+
names = await readdir(config.artifactRoot);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
const rows = [];
|
|
379
|
+
for (const name of names) {
|
|
380
|
+
if (name.startsWith(".staging-"))
|
|
381
|
+
continue;
|
|
382
|
+
const path = resolve(config.artifactRoot, name);
|
|
383
|
+
try {
|
|
384
|
+
const info = await stat(path);
|
|
385
|
+
if (info.isDirectory())
|
|
386
|
+
rows.push({ sha256: name, path, bytes: includeSize ? await directorySize(path) : 0, modifiedAt: info.mtime.toISOString() });
|
|
387
|
+
}
|
|
388
|
+
catch { /* disappeared */ }
|
|
389
|
+
}
|
|
390
|
+
return rows.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
391
|
+
}
|
|
392
|
+
async function directorySize(root) {
|
|
393
|
+
let total = 0;
|
|
394
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
395
|
+
const path = resolve(root, entry.name);
|
|
396
|
+
if (entry.isDirectory())
|
|
397
|
+
total += await directorySize(path);
|
|
398
|
+
else if (entry.isFile())
|
|
399
|
+
total += (await stat(path)).size;
|
|
400
|
+
}
|
|
401
|
+
return total;
|
|
402
|
+
}
|