@forgezero/agent 0.1.33 → 0.1.35
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/README.md +21 -0
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +128 -0
- package/dist/bootstrap.js +3361 -0
- package/dist/capacity-calibration.d.ts +51 -0
- package/dist/capacity-calibration.js +119 -0
- package/dist/cli/cloudflare-bootstrap.d.ts +11 -0
- package/dist/cloudflare-bootstrap.d.ts +218 -0
- package/dist/cloudflare-bootstrap.js +1196 -0
- package/dist/cloudflare-edge.d.ts +265 -0
- package/dist/cloudflare-edge.js +424 -0
- package/dist/definition.d.ts +3 -0
- package/dist/definition.js +174 -2
- package/dist/deploy-file.js +174 -2
- package/dist/deployment.d.ts +11 -0
- package/dist/fz-agent.js +1855 -3370
- package/dist/fz.js +8092 -6437
- package/dist/index.d.ts +2 -0
- package/dist/metal-bootstrap.d.ts +52 -0
- package/dist/metal-bootstrap.js +942 -0
- package/dist/platform-bootstrap-runtime.d.ts +161 -0
- package/dist/platform-bootstrap-runtime.js +462 -0
- package/dist/provision.js +5 -4
- package/dist/version.d.ts +1 -1
- package/package.json +26 -2
- package/schema/deploy-v2.json +14 -0
package/dist/definition.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Pipeline, PipelineStep } from './pipeline';
|
|
2
2
|
import { type DeploymentChannel, type SoftwareRequirement } from './software';
|
|
3
|
+
import { type CapacityCalibrationOptions } from './capacity-calibration';
|
|
3
4
|
/**
|
|
4
5
|
* Version two separates a repository's deploy recipe from the computes that use
|
|
5
6
|
* it. A target chooses one named profile in the control plane; compute names,
|
|
@@ -9,6 +10,8 @@ export declare const PIPELINE_VERSION: 2;
|
|
|
9
10
|
export declare const DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
|
|
10
11
|
export interface PipelineProfile {
|
|
11
12
|
software: readonly SoftwareRequirement[];
|
|
13
|
+
/** Explicit opt-in, provider-neutral loopback GET probe run after target health. */
|
|
14
|
+
capacityCalibration?: CapacityCalibrationOptions;
|
|
12
15
|
}
|
|
13
16
|
export interface DeployStep extends PipelineStep {
|
|
14
17
|
phase: 'build' | 'release' | 'migrate' | 'health';
|
package/dist/definition.js
CHANGED
|
@@ -104,6 +104,121 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
|
104
104
|
return results;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// src/capacity-calibration.ts
|
|
108
|
+
var percentile95 = (values) => {
|
|
109
|
+
if (values.length === 0)
|
|
110
|
+
return Number.POSITIVE_INFINITY;
|
|
111
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
112
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
|
|
113
|
+
};
|
|
114
|
+
function localCalibrationEndpoint(value) {
|
|
115
|
+
const endpoint = new URL(value);
|
|
116
|
+
if (endpoint.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) || !endpoint.port || endpoint.username || endpoint.password || endpoint.hash) {
|
|
117
|
+
throw new Error("Capacity calibration requires an explicit loopback HTTP endpoint and port.");
|
|
118
|
+
}
|
|
119
|
+
return endpoint;
|
|
120
|
+
}
|
|
121
|
+
function validateCapacityCalibrationOptions(options) {
|
|
122
|
+
const endpoint = localCalibrationEndpoint(options.endpoint);
|
|
123
|
+
const maxConcurrency = options.maxConcurrency ?? 256;
|
|
124
|
+
const requestsPerWorker = options.requestsPerWorker ?? 8;
|
|
125
|
+
const maxP95Ms = options.maxP95Ms ?? 250;
|
|
126
|
+
const maxErrorRate = options.maxErrorRate ?? 0.01;
|
|
127
|
+
const headroomRatio = options.headroomRatio ?? 0.8;
|
|
128
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 5000;
|
|
129
|
+
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4096 || !Number.isSafeInteger(requestsPerWorker) || requestsPerWorker < 2 || requestsPerWorker > 100 || !Number.isFinite(maxP95Ms) || maxP95Ms < 1 || !Number.isFinite(maxErrorRate) || maxErrorRate < 0 || maxErrorRate > 0.2 || !Number.isFinite(headroomRatio) || headroomRatio < 0.25 || headroomRatio > 0.95 || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 30000) {
|
|
130
|
+
throw new Error("Capacity calibration bounds are invalid.");
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
endpoint,
|
|
134
|
+
maxConcurrency,
|
|
135
|
+
requestsPerWorker,
|
|
136
|
+
maxP95Ms,
|
|
137
|
+
maxErrorRate,
|
|
138
|
+
headroomRatio,
|
|
139
|
+
requestTimeoutMs
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher) {
|
|
143
|
+
const latencies = [];
|
|
144
|
+
let succeeded = 0;
|
|
145
|
+
let failed = 0;
|
|
146
|
+
let overloaded = 0;
|
|
147
|
+
const started = performance.now();
|
|
148
|
+
await Promise.all(Array.from({ length: concurrency }, async () => {
|
|
149
|
+
for (let request = 0;request < requestsPerWorker; request += 1) {
|
|
150
|
+
const requestStarted = performance.now();
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetcher(endpoint, {
|
|
153
|
+
method: "GET",
|
|
154
|
+
headers: { accept: "application/json", "user-agent": "forgezero-capacity-calibration/1" },
|
|
155
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
156
|
+
redirect: "error"
|
|
157
|
+
});
|
|
158
|
+
await response.body?.cancel();
|
|
159
|
+
if (response.ok)
|
|
160
|
+
succeeded += 1;
|
|
161
|
+
else {
|
|
162
|
+
failed += 1;
|
|
163
|
+
if (response.status === 503)
|
|
164
|
+
overloaded += 1;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
failed += 1;
|
|
168
|
+
} finally {
|
|
169
|
+
latencies.push(performance.now() - requestStarted);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}));
|
|
173
|
+
const elapsedSeconds = Math.max((performance.now() - started) / 1000, 0.001);
|
|
174
|
+
return {
|
|
175
|
+
concurrency,
|
|
176
|
+
requests: concurrency * requestsPerWorker,
|
|
177
|
+
succeeded,
|
|
178
|
+
failed,
|
|
179
|
+
overloaded,
|
|
180
|
+
throughputPerSecond: Number(((succeeded + failed) / elapsedSeconds).toFixed(2)),
|
|
181
|
+
p95Ms: Number(percentile95(latencies).toFixed(2))
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async function calibrateHttpConcurrency(options, fetcher = fetch) {
|
|
185
|
+
const {
|
|
186
|
+
endpoint,
|
|
187
|
+
maxConcurrency,
|
|
188
|
+
requestsPerWorker,
|
|
189
|
+
maxP95Ms,
|
|
190
|
+
maxErrorRate,
|
|
191
|
+
headroomRatio,
|
|
192
|
+
requestTimeoutMs: timeoutMs
|
|
193
|
+
} = validateCapacityCalibrationOptions(options);
|
|
194
|
+
const stages = [];
|
|
195
|
+
let lastSafe = 1;
|
|
196
|
+
let stopReason = "maximum-tested";
|
|
197
|
+
for (let concurrency = 1;; concurrency = Math.min(maxConcurrency, concurrency * 2)) {
|
|
198
|
+
const measured = await stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher);
|
|
199
|
+
stages.push(measured);
|
|
200
|
+
const errorRate = measured.failed / measured.requests;
|
|
201
|
+
const previous = stages.at(-2);
|
|
202
|
+
const throughputRegressed = Boolean(previous && concurrency > 1 && measured.throughputPerSecond < previous.throughputPerSecond * 0.9);
|
|
203
|
+
if (measured.overloaded > 0 || errorRate > maxErrorRate)
|
|
204
|
+
stopReason = "errors";
|
|
205
|
+
else if (measured.p95Ms > maxP95Ms)
|
|
206
|
+
stopReason = "latency";
|
|
207
|
+
else if (throughputRegressed)
|
|
208
|
+
stopReason = "throughput-regression";
|
|
209
|
+
else
|
|
210
|
+
lastSafe = concurrency;
|
|
211
|
+
if (stopReason !== "maximum-tested" || concurrency === maxConcurrency)
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
endpoint: endpoint.toString(),
|
|
216
|
+
recommendedConcurrency: Math.max(1, Math.floor(lastSafe * headroomRatio)),
|
|
217
|
+
stopReason,
|
|
218
|
+
stages
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
107
222
|
// src/definition.ts
|
|
108
223
|
var PIPELINE_VERSION = 2;
|
|
109
224
|
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
|
|
@@ -144,6 +259,53 @@ var RESERVED_STEP_ENV = new Set([
|
|
|
144
259
|
"GIT_SSH",
|
|
145
260
|
"GIT_SSH_COMMAND"
|
|
146
261
|
]);
|
|
262
|
+
function capacityCalibration(value, where) {
|
|
263
|
+
const calibration = record(value, where);
|
|
264
|
+
exactKeys(calibration, [
|
|
265
|
+
"endpoint",
|
|
266
|
+
"maxConcurrency",
|
|
267
|
+
"requestsPerWorker",
|
|
268
|
+
"maxP95Ms",
|
|
269
|
+
"maxErrorRate",
|
|
270
|
+
"headroomRatio",
|
|
271
|
+
"requestTimeoutMs"
|
|
272
|
+
], where);
|
|
273
|
+
const endpoint = text(calibration.endpoint, `${where}.endpoint`);
|
|
274
|
+
try {
|
|
275
|
+
localCalibrationEndpoint(endpoint);
|
|
276
|
+
} catch (cause) {
|
|
277
|
+
throw new DefinitionError(cause instanceof Error ? cause.message : `${where}.endpoint is invalid.`);
|
|
278
|
+
}
|
|
279
|
+
const optionalNumber = (name) => {
|
|
280
|
+
const raw = calibration[name];
|
|
281
|
+
if (raw === undefined)
|
|
282
|
+
return;
|
|
283
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
284
|
+
throw new DefinitionError(`${where}.${name} must be a finite number.`);
|
|
285
|
+
}
|
|
286
|
+
return raw;
|
|
287
|
+
};
|
|
288
|
+
const parsed = {
|
|
289
|
+
endpoint,
|
|
290
|
+
...Object.fromEntries([
|
|
291
|
+
"maxConcurrency",
|
|
292
|
+
"requestsPerWorker",
|
|
293
|
+
"maxP95Ms",
|
|
294
|
+
"maxErrorRate",
|
|
295
|
+
"headroomRatio",
|
|
296
|
+
"requestTimeoutMs"
|
|
297
|
+
].flatMap((name) => {
|
|
298
|
+
const found = optionalNumber(name);
|
|
299
|
+
return found === undefined ? [] : [[name, found]];
|
|
300
|
+
}))
|
|
301
|
+
};
|
|
302
|
+
try {
|
|
303
|
+
validateCapacityCalibrationOptions(parsed);
|
|
304
|
+
} catch (cause) {
|
|
305
|
+
throw new DefinitionError(cause instanceof Error ? cause.message : `${where} bounds are invalid.`);
|
|
306
|
+
}
|
|
307
|
+
return parsed;
|
|
308
|
+
}
|
|
147
309
|
function parseDeployDefinition(value, options = {}) {
|
|
148
310
|
const root = record(value, "pipeline");
|
|
149
311
|
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
@@ -169,11 +331,16 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
169
331
|
if (!NAME.test(name2))
|
|
170
332
|
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
171
333
|
const profile = record(raw, `profiles.${name2}`);
|
|
172
|
-
exactKeys(profile, ["software"], `profiles.${name2}`);
|
|
334
|
+
exactKeys(profile, ["software", "capacityCalibration"], `profiles.${name2}`);
|
|
173
335
|
if (!Array.isArray(profile.software)) {
|
|
174
336
|
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
175
337
|
}
|
|
176
|
-
profiles[name2] = {
|
|
338
|
+
profiles[name2] = {
|
|
339
|
+
software: validateSoftwareRequirements(profile.software, options),
|
|
340
|
+
...profile.capacityCalibration === undefined ? {} : {
|
|
341
|
+
capacityCalibration: capacityCalibration(profile.capacityCalibration, `profiles.${name2}.capacityCalibration`)
|
|
342
|
+
}
|
|
343
|
+
};
|
|
177
344
|
}
|
|
178
345
|
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
179
346
|
const steps = root.steps.map((raw, index) => {
|
|
@@ -239,6 +406,11 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
239
406
|
if (new Set(steps.map((step) => step.name)).size !== steps.length) {
|
|
240
407
|
throw new DefinitionError("pipeline.steps must have unique names.");
|
|
241
408
|
}
|
|
409
|
+
for (const [profile, selected] of Object.entries(profiles)) {
|
|
410
|
+
if (selected.capacityCalibration && !steps.some((step) => step.phase === "health" && step.scope === "target" && (!step.profiles || step.profiles.includes(profile)))) {
|
|
411
|
+
throw new DefinitionError(`profiles.${profile}.capacityCalibration requires a target-scoped health step.`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
242
414
|
const name = text(root.name, "pipeline.name");
|
|
243
415
|
if (name.length > 120)
|
|
244
416
|
throw new DefinitionError("pipeline.name must be at most 120 characters.");
|
package/dist/deploy-file.js
CHANGED
|
@@ -104,6 +104,121 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
|
104
104
|
return results;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// src/capacity-calibration.ts
|
|
108
|
+
var percentile95 = (values) => {
|
|
109
|
+
if (values.length === 0)
|
|
110
|
+
return Number.POSITIVE_INFINITY;
|
|
111
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
112
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
|
|
113
|
+
};
|
|
114
|
+
function localCalibrationEndpoint(value) {
|
|
115
|
+
const endpoint = new URL(value);
|
|
116
|
+
if (endpoint.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) || !endpoint.port || endpoint.username || endpoint.password || endpoint.hash) {
|
|
117
|
+
throw new Error("Capacity calibration requires an explicit loopback HTTP endpoint and port.");
|
|
118
|
+
}
|
|
119
|
+
return endpoint;
|
|
120
|
+
}
|
|
121
|
+
function validateCapacityCalibrationOptions(options) {
|
|
122
|
+
const endpoint = localCalibrationEndpoint(options.endpoint);
|
|
123
|
+
const maxConcurrency = options.maxConcurrency ?? 256;
|
|
124
|
+
const requestsPerWorker = options.requestsPerWorker ?? 8;
|
|
125
|
+
const maxP95Ms = options.maxP95Ms ?? 250;
|
|
126
|
+
const maxErrorRate = options.maxErrorRate ?? 0.01;
|
|
127
|
+
const headroomRatio = options.headroomRatio ?? 0.8;
|
|
128
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 5000;
|
|
129
|
+
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4096 || !Number.isSafeInteger(requestsPerWorker) || requestsPerWorker < 2 || requestsPerWorker > 100 || !Number.isFinite(maxP95Ms) || maxP95Ms < 1 || !Number.isFinite(maxErrorRate) || maxErrorRate < 0 || maxErrorRate > 0.2 || !Number.isFinite(headroomRatio) || headroomRatio < 0.25 || headroomRatio > 0.95 || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 30000) {
|
|
130
|
+
throw new Error("Capacity calibration bounds are invalid.");
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
endpoint,
|
|
134
|
+
maxConcurrency,
|
|
135
|
+
requestsPerWorker,
|
|
136
|
+
maxP95Ms,
|
|
137
|
+
maxErrorRate,
|
|
138
|
+
headroomRatio,
|
|
139
|
+
requestTimeoutMs
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher) {
|
|
143
|
+
const latencies = [];
|
|
144
|
+
let succeeded = 0;
|
|
145
|
+
let failed = 0;
|
|
146
|
+
let overloaded = 0;
|
|
147
|
+
const started = performance.now();
|
|
148
|
+
await Promise.all(Array.from({ length: concurrency }, async () => {
|
|
149
|
+
for (let request = 0;request < requestsPerWorker; request += 1) {
|
|
150
|
+
const requestStarted = performance.now();
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetcher(endpoint, {
|
|
153
|
+
method: "GET",
|
|
154
|
+
headers: { accept: "application/json", "user-agent": "forgezero-capacity-calibration/1" },
|
|
155
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
156
|
+
redirect: "error"
|
|
157
|
+
});
|
|
158
|
+
await response.body?.cancel();
|
|
159
|
+
if (response.ok)
|
|
160
|
+
succeeded += 1;
|
|
161
|
+
else {
|
|
162
|
+
failed += 1;
|
|
163
|
+
if (response.status === 503)
|
|
164
|
+
overloaded += 1;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
failed += 1;
|
|
168
|
+
} finally {
|
|
169
|
+
latencies.push(performance.now() - requestStarted);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}));
|
|
173
|
+
const elapsedSeconds = Math.max((performance.now() - started) / 1000, 0.001);
|
|
174
|
+
return {
|
|
175
|
+
concurrency,
|
|
176
|
+
requests: concurrency * requestsPerWorker,
|
|
177
|
+
succeeded,
|
|
178
|
+
failed,
|
|
179
|
+
overloaded,
|
|
180
|
+
throughputPerSecond: Number(((succeeded + failed) / elapsedSeconds).toFixed(2)),
|
|
181
|
+
p95Ms: Number(percentile95(latencies).toFixed(2))
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async function calibrateHttpConcurrency(options, fetcher = fetch) {
|
|
185
|
+
const {
|
|
186
|
+
endpoint,
|
|
187
|
+
maxConcurrency,
|
|
188
|
+
requestsPerWorker,
|
|
189
|
+
maxP95Ms,
|
|
190
|
+
maxErrorRate,
|
|
191
|
+
headroomRatio,
|
|
192
|
+
requestTimeoutMs: timeoutMs
|
|
193
|
+
} = validateCapacityCalibrationOptions(options);
|
|
194
|
+
const stages = [];
|
|
195
|
+
let lastSafe = 1;
|
|
196
|
+
let stopReason = "maximum-tested";
|
|
197
|
+
for (let concurrency = 1;; concurrency = Math.min(maxConcurrency, concurrency * 2)) {
|
|
198
|
+
const measured = await stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher);
|
|
199
|
+
stages.push(measured);
|
|
200
|
+
const errorRate = measured.failed / measured.requests;
|
|
201
|
+
const previous = stages.at(-2);
|
|
202
|
+
const throughputRegressed = Boolean(previous && concurrency > 1 && measured.throughputPerSecond < previous.throughputPerSecond * 0.9);
|
|
203
|
+
if (measured.overloaded > 0 || errorRate > maxErrorRate)
|
|
204
|
+
stopReason = "errors";
|
|
205
|
+
else if (measured.p95Ms > maxP95Ms)
|
|
206
|
+
stopReason = "latency";
|
|
207
|
+
else if (throughputRegressed)
|
|
208
|
+
stopReason = "throughput-regression";
|
|
209
|
+
else
|
|
210
|
+
lastSafe = concurrency;
|
|
211
|
+
if (stopReason !== "maximum-tested" || concurrency === maxConcurrency)
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
endpoint: endpoint.toString(),
|
|
216
|
+
recommendedConcurrency: Math.max(1, Math.floor(lastSafe * headroomRatio)),
|
|
217
|
+
stopReason,
|
|
218
|
+
stages
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
107
222
|
// src/definition.ts
|
|
108
223
|
var PIPELINE_VERSION = 2;
|
|
109
224
|
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
|
|
@@ -144,6 +259,53 @@ var RESERVED_STEP_ENV = new Set([
|
|
|
144
259
|
"GIT_SSH",
|
|
145
260
|
"GIT_SSH_COMMAND"
|
|
146
261
|
]);
|
|
262
|
+
function capacityCalibration(value, where) {
|
|
263
|
+
const calibration = record(value, where);
|
|
264
|
+
exactKeys(calibration, [
|
|
265
|
+
"endpoint",
|
|
266
|
+
"maxConcurrency",
|
|
267
|
+
"requestsPerWorker",
|
|
268
|
+
"maxP95Ms",
|
|
269
|
+
"maxErrorRate",
|
|
270
|
+
"headroomRatio",
|
|
271
|
+
"requestTimeoutMs"
|
|
272
|
+
], where);
|
|
273
|
+
const endpoint = text(calibration.endpoint, `${where}.endpoint`);
|
|
274
|
+
try {
|
|
275
|
+
localCalibrationEndpoint(endpoint);
|
|
276
|
+
} catch (cause) {
|
|
277
|
+
throw new DefinitionError(cause instanceof Error ? cause.message : `${where}.endpoint is invalid.`);
|
|
278
|
+
}
|
|
279
|
+
const optionalNumber = (name) => {
|
|
280
|
+
const raw = calibration[name];
|
|
281
|
+
if (raw === undefined)
|
|
282
|
+
return;
|
|
283
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
284
|
+
throw new DefinitionError(`${where}.${name} must be a finite number.`);
|
|
285
|
+
}
|
|
286
|
+
return raw;
|
|
287
|
+
};
|
|
288
|
+
const parsed = {
|
|
289
|
+
endpoint,
|
|
290
|
+
...Object.fromEntries([
|
|
291
|
+
"maxConcurrency",
|
|
292
|
+
"requestsPerWorker",
|
|
293
|
+
"maxP95Ms",
|
|
294
|
+
"maxErrorRate",
|
|
295
|
+
"headroomRatio",
|
|
296
|
+
"requestTimeoutMs"
|
|
297
|
+
].flatMap((name) => {
|
|
298
|
+
const found = optionalNumber(name);
|
|
299
|
+
return found === undefined ? [] : [[name, found]];
|
|
300
|
+
}))
|
|
301
|
+
};
|
|
302
|
+
try {
|
|
303
|
+
validateCapacityCalibrationOptions(parsed);
|
|
304
|
+
} catch (cause) {
|
|
305
|
+
throw new DefinitionError(cause instanceof Error ? cause.message : `${where} bounds are invalid.`);
|
|
306
|
+
}
|
|
307
|
+
return parsed;
|
|
308
|
+
}
|
|
147
309
|
function parseDeployDefinition(value, options = {}) {
|
|
148
310
|
const root = record(value, "pipeline");
|
|
149
311
|
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
@@ -169,11 +331,16 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
169
331
|
if (!NAME.test(name2))
|
|
170
332
|
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
171
333
|
const profile = record(raw, `profiles.${name2}`);
|
|
172
|
-
exactKeys(profile, ["software"], `profiles.${name2}`);
|
|
334
|
+
exactKeys(profile, ["software", "capacityCalibration"], `profiles.${name2}`);
|
|
173
335
|
if (!Array.isArray(profile.software)) {
|
|
174
336
|
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
175
337
|
}
|
|
176
|
-
profiles[name2] = {
|
|
338
|
+
profiles[name2] = {
|
|
339
|
+
software: validateSoftwareRequirements(profile.software, options),
|
|
340
|
+
...profile.capacityCalibration === undefined ? {} : {
|
|
341
|
+
capacityCalibration: capacityCalibration(profile.capacityCalibration, `profiles.${name2}.capacityCalibration`)
|
|
342
|
+
}
|
|
343
|
+
};
|
|
177
344
|
}
|
|
178
345
|
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
179
346
|
const steps = root.steps.map((raw, index) => {
|
|
@@ -239,6 +406,11 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
239
406
|
if (new Set(steps.map((step) => step.name)).size !== steps.length) {
|
|
240
407
|
throw new DefinitionError("pipeline.steps must have unique names.");
|
|
241
408
|
}
|
|
409
|
+
for (const [profile, selected] of Object.entries(profiles)) {
|
|
410
|
+
if (selected.capacityCalibration && !steps.some((step) => step.phase === "health" && step.scope === "target" && (!step.profiles || step.profiles.includes(profile)))) {
|
|
411
|
+
throw new DefinitionError(`profiles.${profile}.capacityCalibration requires a target-scoped health step.`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
242
414
|
const name = text(root.name, "pipeline.name");
|
|
243
415
|
if (name.length > 120)
|
|
244
416
|
throw new DefinitionError("pipeline.name must be at most 120 characters.");
|
package/dist/deployment.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { SoftwareRequirement } from './software';
|
|
|
4
4
|
import { type RunResult } from './pipeline';
|
|
5
5
|
import type { SecretCache } from './cache';
|
|
6
6
|
import type { AttestationSource } from './socket';
|
|
7
|
+
import { type CapacityCalibration, type CapacityCalibrationOptions } from './capacity-calibration';
|
|
7
8
|
export interface CommandInput {
|
|
8
9
|
command: string;
|
|
9
10
|
cwd?: string;
|
|
@@ -32,6 +33,12 @@ export interface DeploymentResult {
|
|
|
32
33
|
release: string;
|
|
33
34
|
ok: boolean;
|
|
34
35
|
phases: readonly RunResult[];
|
|
36
|
+
capacity?: CapacityCalibration & {
|
|
37
|
+
evidencePath: string;
|
|
38
|
+
recommendedCoordinate: {
|
|
39
|
+
FZ_CONCURRENCY_LIMIT: string;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
35
42
|
}
|
|
36
43
|
/**
|
|
37
44
|
* How the credential-bearing agent may read one server-owned Git source.
|
|
@@ -70,6 +77,10 @@ export interface DeploymentOptions {
|
|
|
70
77
|
projectExec?: (input: CommandInput) => Promise<CommandResult>;
|
|
71
78
|
/** Root-owned fixed strategy helper; repository data contains coordinates only. */
|
|
72
79
|
ensureSoftware?: (requirements: readonly SoftwareRequirement[]) => Promise<unknown>;
|
|
80
|
+
/** Agent-owned directory; definitions cannot choose or overwrite this path. */
|
|
81
|
+
capacityEvidenceDirectory?: string;
|
|
82
|
+
/** Test/embedding seam. Production runs the bounded loopback GET calibrator. */
|
|
83
|
+
calibrateCapacity?: (options: CapacityCalibrationOptions) => Promise<CapacityCalibration>;
|
|
73
84
|
exec?: (input: CommandInput) => Promise<CommandResult>;
|
|
74
85
|
now?: () => number;
|
|
75
86
|
readDefinition?: (path: string) => unknown;
|