@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.
@@ -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';
@@ -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] = { software: validateSoftwareRequirements(profile.software, options) };
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.");
@@ -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] = { software: validateSoftwareRequirements(profile.software, options) };
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.");
@@ -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;