@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.
@@ -0,0 +1,239 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { chmodSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ function slotFromRow(row) {
6
+ return {
7
+ id: row.id,
8
+ ordinal: row.ordinal,
9
+ webPort: row.web_port,
10
+ gatewayPort: row.gateway_port,
11
+ publicUrl: row.public_url ?? undefined,
12
+ state: row.state,
13
+ activeLeaseId: row.active_lease_id ?? undefined,
14
+ dirtyReason: row.dirty_reason ?? undefined,
15
+ updatedAt: row.updated_at,
16
+ };
17
+ }
18
+ function leaseFromRow(row) {
19
+ return {
20
+ id: row.id,
21
+ slotId: row.slot_id,
22
+ holder: row.holder,
23
+ seedMode: row.seed_mode,
24
+ artifactSha256: row.artifact_sha256,
25
+ artifactRuntimePath: row.artifact_runtime_path,
26
+ packageVersion: row.package_version ?? undefined,
27
+ commit: row.commit_sha ?? undefined,
28
+ containerName: row.container_name,
29
+ publicUrl: row.public_url ?? undefined,
30
+ status: row.status,
31
+ createdAt: row.created_at,
32
+ expiresAt: row.expires_at,
33
+ renewedAt: row.renewed_at ?? undefined,
34
+ releasedAt: row.released_at ?? undefined,
35
+ failedAt: row.failed_at ?? undefined,
36
+ failureSnapshotPath: row.failure_snapshot_path ?? undefined,
37
+ lastError: row.last_error ?? undefined,
38
+ };
39
+ }
40
+ export class DeploymentPoolStore {
41
+ path;
42
+ db;
43
+ constructor(path, slots) {
44
+ this.path = path === ":memory:" ? path : resolve(path);
45
+ if (this.path !== ":memory:")
46
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
47
+ this.db = new DatabaseSync(this.path);
48
+ this.db.exec("PRAGMA busy_timeout = 10000");
49
+ if (this.path !== ":memory:")
50
+ this.db.exec("PRAGMA journal_mode = WAL");
51
+ this.applySchema();
52
+ this.ensureSlots(slots);
53
+ if (this.path !== ":memory:")
54
+ chmodSync(this.path, 0o600);
55
+ }
56
+ applySchema() {
57
+ this.db.exec(`
58
+ CREATE TABLE IF NOT EXISTS deployment_pool_slots (
59
+ id TEXT PRIMARY KEY,
60
+ ordinal INTEGER NOT NULL UNIQUE,
61
+ web_port INTEGER NOT NULL UNIQUE,
62
+ gateway_port INTEGER NOT NULL UNIQUE,
63
+ public_url TEXT,
64
+ state TEXT NOT NULL,
65
+ active_lease_id TEXT,
66
+ dirty_reason TEXT,
67
+ updated_at TEXT NOT NULL
68
+ );
69
+ CREATE TABLE IF NOT EXISTS deployment_pool_leases (
70
+ id TEXT PRIMARY KEY,
71
+ slot_id TEXT NOT NULL,
72
+ holder TEXT NOT NULL,
73
+ seed_mode TEXT NOT NULL,
74
+ artifact_sha256 TEXT NOT NULL,
75
+ artifact_runtime_path TEXT NOT NULL,
76
+ package_version TEXT,
77
+ commit_sha TEXT,
78
+ container_name TEXT NOT NULL,
79
+ public_url TEXT,
80
+ status TEXT NOT NULL,
81
+ created_at TEXT NOT NULL,
82
+ expires_at TEXT NOT NULL,
83
+ renewed_at TEXT,
84
+ released_at TEXT,
85
+ failed_at TEXT,
86
+ failure_snapshot_path TEXT,
87
+ last_error TEXT
88
+ );
89
+ CREATE INDEX IF NOT EXISTS deployment_pool_leases_status_idx
90
+ ON deployment_pool_leases (status, expires_at);
91
+ CREATE INDEX IF NOT EXISTS deployment_pool_leases_slot_idx
92
+ ON deployment_pool_leases (slot_id, created_at DESC);
93
+ `);
94
+ }
95
+ ensureSlots(slots) {
96
+ const now = new Date().toISOString();
97
+ const insert = this.db.prepare(`
98
+ INSERT INTO deployment_pool_slots (id, ordinal, web_port, gateway_port, public_url, state, updated_at)
99
+ VALUES (?, ?, ?, ?, ?, 'free', ?)
100
+ ON CONFLICT(id) DO UPDATE SET
101
+ ordinal=excluded.ordinal,
102
+ web_port=excluded.web_port,
103
+ gateway_port=excluded.gateway_port,
104
+ public_url=excluded.public_url
105
+ `);
106
+ for (const slot of slots)
107
+ insert.run(slot.id, slot.ordinal, slot.webPort, slot.gatewayPort, slot.publicUrl ?? null, now);
108
+ }
109
+ listSlots() {
110
+ return this.db.prepare("SELECT * FROM deployment_pool_slots ORDER BY ordinal").all().map(slotFromRow);
111
+ }
112
+ getSlot(id) {
113
+ const row = this.db.prepare("SELECT * FROM deployment_pool_slots WHERE id = ?").get(id);
114
+ return row ? slotFromRow(row) : undefined;
115
+ }
116
+ listLeases(options = {}) {
117
+ const where = options.includeInactive ? "" : "WHERE status IN ('provisioning', 'ready', 'releasing')";
118
+ return this.db.prepare(`SELECT * FROM deployment_pool_leases ${where} ORDER BY created_at DESC`).all().map(leaseFromRow);
119
+ }
120
+ getLease(id) {
121
+ const row = this.db.prepare("SELECT * FROM deployment_pool_leases WHERE id = ?").get(id);
122
+ return row ? leaseFromRow(row) : undefined;
123
+ }
124
+ reserveLease(input) {
125
+ this.db.exec("BEGIN IMMEDIATE");
126
+ try {
127
+ const active = this.db.prepare("SELECT COUNT(*) AS count FROM deployment_pool_slots WHERE state IN ('provisioning', 'ready', 'releasing')").get();
128
+ if (Number(active.count) >= input.maxActive) {
129
+ const nearest = this.db.prepare(`
130
+ SELECT MIN(l.expires_at) AS nearest_expiry
131
+ FROM deployment_pool_slots s
132
+ JOIN deployment_pool_leases l ON l.id = s.active_lease_id
133
+ WHERE s.state IN ('provisioning', 'ready', 'releasing')
134
+ `).get();
135
+ throw new Error(`Deployment pool capacity reached (${input.maxActive} active)${nearest.nearest_expiry ? `; nearest expiry ${nearest.nearest_expiry}` : ""}`);
136
+ }
137
+ const row = this.db.prepare("SELECT * FROM deployment_pool_slots WHERE state = 'free' ORDER BY ordinal LIMIT 1").get();
138
+ if (!row)
139
+ throw new Error("No free deployment pool slot is available");
140
+ const slot = slotFromRow(row);
141
+ const containerName = `pibo-pool-${slot.id}`;
142
+ this.db.prepare(`
143
+ INSERT INTO deployment_pool_leases (
144
+ id, slot_id, holder, seed_mode, artifact_sha256, artifact_runtime_path,
145
+ package_version, commit_sha, container_name, public_url, status, created_at, expires_at
146
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'provisioning', ?, ?)
147
+ `).run(input.id, slot.id, input.holder, input.seedMode, input.artifactSha256, input.artifactRuntimePath, input.packageVersion ?? null, input.commit ?? null, containerName, slot.publicUrl ?? null, input.createdAt, input.expiresAt);
148
+ this.db.prepare("UPDATE deployment_pool_slots SET state='provisioning', active_lease_id=?, dirty_reason=NULL, updated_at=? WHERE id=?")
149
+ .run(input.id, input.createdAt, slot.id);
150
+ this.db.exec("COMMIT");
151
+ return { slot: this.getSlot(slot.id), lease: this.getLease(input.id) };
152
+ }
153
+ catch (error) {
154
+ this.db.exec("ROLLBACK");
155
+ throw error;
156
+ }
157
+ }
158
+ markReady(leaseId, now = new Date().toISOString()) {
159
+ const lease = this.requireLease(leaseId);
160
+ this.db.exec("BEGIN IMMEDIATE");
161
+ try {
162
+ this.db.prepare("UPDATE deployment_pool_leases SET status='ready', last_error=NULL WHERE id=?").run(leaseId);
163
+ this.db.prepare("UPDATE deployment_pool_slots SET state='ready', dirty_reason=NULL, updated_at=? WHERE id=? AND active_lease_id=?")
164
+ .run(now, lease.slotId, leaseId);
165
+ this.db.exec("COMMIT");
166
+ return this.requireLease(leaseId);
167
+ }
168
+ catch (error) {
169
+ this.db.exec("ROLLBACK");
170
+ throw error;
171
+ }
172
+ }
173
+ markReleasing(leaseId, now = new Date().toISOString()) {
174
+ const lease = this.requireLease(leaseId);
175
+ this.db.prepare("UPDATE deployment_pool_leases SET status='releasing' WHERE id=? AND status IN ('provisioning','ready','releasing')").run(leaseId);
176
+ this.db.prepare("UPDATE deployment_pool_slots SET state='releasing', updated_at=? WHERE id=? AND active_lease_id=?").run(now, lease.slotId, leaseId);
177
+ return this.requireLease(leaseId);
178
+ }
179
+ markReleased(leaseId, status, now = new Date().toISOString()) {
180
+ const lease = this.requireLease(leaseId);
181
+ this.db.exec("BEGIN IMMEDIATE");
182
+ try {
183
+ this.db.prepare("UPDATE deployment_pool_leases SET status=?, released_at=?, last_error=NULL WHERE id=?").run(status, now, leaseId);
184
+ this.db.prepare("UPDATE deployment_pool_slots SET state='free', active_lease_id=NULL, dirty_reason=NULL, updated_at=? WHERE id=? AND active_lease_id=?")
185
+ .run(now, lease.slotId, leaseId);
186
+ this.db.exec("COMMIT");
187
+ return this.requireLease(leaseId);
188
+ }
189
+ catch (error) {
190
+ this.db.exec("ROLLBACK");
191
+ throw error;
192
+ }
193
+ }
194
+ markFailed(leaseId, error, snapshotPath, options = { slotClean: false }) {
195
+ const lease = this.requireLease(leaseId);
196
+ const now = options.now ?? new Date().toISOString();
197
+ this.db.exec("BEGIN IMMEDIATE");
198
+ try {
199
+ this.db.prepare("UPDATE deployment_pool_leases SET status='failed', failed_at=?, failure_snapshot_path=?, last_error=? WHERE id=?")
200
+ .run(now, snapshotPath ?? null, error, leaseId);
201
+ if (options.slotClean) {
202
+ this.db.prepare("UPDATE deployment_pool_slots SET state='free', active_lease_id=NULL, dirty_reason=NULL, updated_at=? WHERE id=? AND active_lease_id=?")
203
+ .run(now, lease.slotId, leaseId);
204
+ }
205
+ else {
206
+ this.db.prepare("UPDATE deployment_pool_slots SET state='dirty', dirty_reason=?, updated_at=? WHERE id=? AND active_lease_id=?")
207
+ .run(error, now, lease.slotId, leaseId);
208
+ }
209
+ this.db.exec("COMMIT");
210
+ return this.requireLease(leaseId);
211
+ }
212
+ catch (caught) {
213
+ this.db.exec("ROLLBACK");
214
+ throw caught;
215
+ }
216
+ }
217
+ renewLease(leaseId, holder, expiresAt, now = new Date().toISOString()) {
218
+ const result = this.db.prepare(`
219
+ UPDATE deployment_pool_leases SET expires_at=?, renewed_at=?
220
+ WHERE id=? AND holder=? AND status='ready'
221
+ `).run(expiresAt, now, leaseId, holder);
222
+ if (Number(result.changes ?? 0) !== 1)
223
+ throw new Error(`Active deployment lease "${leaseId}" for holder "${holder}" was not found`);
224
+ return this.requireLease(leaseId);
225
+ }
226
+ freeDirtySlot(slotId, now = new Date().toISOString()) {
227
+ this.db.prepare("UPDATE deployment_pool_slots SET state='free', active_lease_id=NULL, dirty_reason=NULL, updated_at=? WHERE id=?")
228
+ .run(now, slotId);
229
+ }
230
+ requireLease(id) {
231
+ const lease = this.getLease(id);
232
+ if (!lease)
233
+ throw new Error(`Deployment lease "${id}" was not found`);
234
+ return lease;
235
+ }
236
+ close() {
237
+ this.db.close();
238
+ }
239
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -6,6 +6,8 @@ import { promisify } from "node:util";
6
6
  import { applyComputeWorkerReapPlan, buildComputeWorkerReapPlan, planReapWorkers, } from "../compute/docker.js";
7
7
  import { defaultBrowserPoolRoot, defaultBrowserUseHome, getComputeResourceHealth, parseProcessList, } from "../compute/resource-health.js";
8
8
  import { loadBrowserPoolState, reapIdleBrowserPool, } from "../tools/browser-pool.js";
9
+ import { resolveDeploymentPoolConfig } from "../compute/pool/config.js";
10
+ import { applyDeploymentPoolReapPlan, planDeploymentPoolReap } from "../compute/pool/service.js";
9
11
  const execFileAsync = promisify(execFile);
10
12
  export async function collectManagedBrowserPools(rootDir) {
11
13
  const records = [];
@@ -56,7 +58,7 @@ export async function getActiveResourceLeases(browserPoolRoot = defaultBrowserPo
56
58
  export async function planResourceReap(options = {}) {
57
59
  const now = options.now ?? new Date();
58
60
  const resolved = resolveReapOptions(options);
59
- const [records, staleFiles, compute, health] = await Promise.all([
61
+ const [records, staleFiles, compute, health, deploymentPool] = await Promise.all([
60
62
  collectManagedBrowserPools(resolved.browserPoolRoot),
61
63
  planStaleCdpFiles(resolved.browserUseHome),
62
64
  planComputeReapSafely({ includeDev: resolved.includeDev, maxAgeMinutes: resolved.maxAgeMinutes, now }),
@@ -66,9 +68,10 @@ export async function planResourceReap(options = {}) {
66
68
  browserUseHome: resolved.browserUseHome,
67
69
  exemptBrowserUserDataDirs: [],
68
70
  }),
71
+ planDeploymentPoolReapSafely(now),
69
72
  ]);
70
73
  const unmanagedBrowsers = buildUnmanagedBrowserPlanItems(health.browserProcesses.unassignedMainProcessDetails, resolved.unmanagedBrowserGraceMinutes, new Set(resolved.exemptBrowserPids), new Set(resolved.exemptBrowserUserDataDirs), resolved.browserUseHome);
71
- return buildResourceReapPlan({ now, options: resolved, records, staleFiles, unmanagedBrowsers, compute });
74
+ return buildResourceReapPlan({ now, options: resolved, records, staleFiles, unmanagedBrowsers, compute, deploymentPool });
72
75
  }
73
76
  export function buildResourceReapPlan(input) {
74
77
  const browserItems = input.records.map((record) => buildBrowserReapPlanItem(record, input.now, input.options.idleTimeoutMinutes));
@@ -89,6 +92,7 @@ export function buildResourceReapPlan(input) {
89
92
  skipped: unmanagedBrowsers.filter((item) => item.action === "skip").length,
90
93
  },
91
94
  compute: input.compute,
95
+ deploymentPool: input.deploymentPool,
92
96
  worktreesPreserved: true,
93
97
  };
94
98
  }
@@ -112,6 +116,9 @@ export async function applyResourceReapPlan(plan, dependencies = {}) {
112
116
  }
113
117
  const removedStaleFiles = await applyStaleCdpFilePlan(confirmed.staleFiles.items, dependencies.isPidAlive);
114
118
  const removedComputeWorkers = await (dependencies.applyCompute ?? applyComputeWorkerReapPlan)(confirmed.compute);
119
+ const deploymentPoolResult = confirmed.deploymentPool
120
+ ? await (dependencies.applyDeploymentPool ?? applyDeploymentPoolReapPlan)(confirmed.deploymentPool)
121
+ : undefined;
115
122
  return {
116
123
  applied: true,
117
124
  plan: confirmed,
@@ -119,6 +126,8 @@ export async function applyResourceReapPlan(plan, dependencies = {}) {
119
126
  terminatedUnmanagedBrowsers,
120
127
  removedStaleFiles,
121
128
  removedComputeWorkers,
129
+ deploymentPoolResult,
130
+ removedDeploymentLeases: deploymentPoolResult?.releasedLeases ?? [],
122
131
  worktreesPreserved: true,
123
132
  };
124
133
  }
@@ -287,6 +296,17 @@ export async function planComputeReapSafely(options, planCompute = planReapWorke
287
296
  return plan;
288
297
  }
289
298
  }
299
+ async function planDeploymentPoolReapSafely(now) {
300
+ const config = resolveDeploymentPoolConfig();
301
+ if (!existsSync(config.databasePath))
302
+ return undefined;
303
+ try {
304
+ return await planDeploymentPoolReap({ config, now });
305
+ }
306
+ catch {
307
+ return undefined;
308
+ }
309
+ }
290
310
  async function planStaleCdpFiles(browserUseHome, isPidAlive = defaultIsPidAlive) {
291
311
  const stateDir = join(browserUseHome, "pibo-cdp");
292
312
  let files;
@@ -95,6 +95,7 @@ export class ResourceReaperService {
95
95
  unmanagedBrowsers: result.terminatedUnmanagedBrowsers.length,
96
96
  staleFiles: result.removedStaleFiles.length,
97
97
  computeWorkers: result.removedComputeWorkers.length,
98
+ ...(result.deploymentPoolResult ? { deploymentLeases: result.removedDeploymentLeases?.length ?? 0 } : {}),
98
99
  },
99
100
  lastError: undefined,
100
101
  };
@@ -78,7 +78,13 @@ export function stripSocketPeerHeaderFromResponse(response) {
78
78
  headers,
79
79
  });
80
80
  }
81
- function createRequestBaseURL(nodeRequest, host, port) {
81
+ function createRequestBaseURL(nodeRequest, host, port, canonicalBaseURL) {
82
+ const requestHost = firstHeaderValue(nodeRequest.headers.host);
83
+ if (canonicalBaseURL && requestHost) {
84
+ const canonical = new URL(canonicalBaseURL);
85
+ if (requestHost.toLowerCase() === canonical.host.toLowerCase())
86
+ return canonical.origin;
87
+ }
82
88
  if (isLoopbackAddress(nodeRequest.socket.remoteAddress)) {
83
89
  const forwardedHost = firstHeaderValue(nodeRequest.headers["x-forwarded-host"]);
84
90
  const forwardedProto = firstHeaderValue(nodeRequest.headers["x-forwarded-proto"]);
@@ -86,7 +92,7 @@ function createRequestBaseURL(nodeRequest, host, port) {
86
92
  return `${forwardedProto}://${forwardedHost}`;
87
93
  }
88
94
  }
89
- return `http://${nodeRequest.headers.host ?? `${host}:${port}`}`;
95
+ return `http://${requestHost ?? `${host}:${port}`}`;
90
96
  }
91
97
  function isActiveRunStatus(status) {
92
98
  return typeof status === "string" && ["queued", "starting", "running", "streaming", "waiting", "blocked", "retrying", "compacting", "pausing"].includes(status);
@@ -235,7 +241,7 @@ export function createWebHostChannel(options = {}) {
235
241
  };
236
242
  const handleRequest = async (nodeRequest, nodeResponse) => {
237
243
  try {
238
- const baseURL = createRequestBaseURL(nodeRequest, host, port);
244
+ const baseURL = createRequestBaseURL(nodeRequest, host, port, options.canonicalBaseURL);
239
245
  const baseRequest = await nodeRequestToWebRequest(nodeRequest, baseURL);
240
246
  // Inject the TCP socket peer into every request so the local auth
241
247
  // plugin can apply the same loopback predicate from `getSession`
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@pasko70/pibo",
9
- "version": "2.3.0",
9
+ "version": "2.4.0",
10
10
  "workspaces": [
11
11
  "packages/workflows"
12
12
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  "packages/workflows"