@loadstrike/loadstrike-sdk 1.0.18901 → 1.0.21101

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,6 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { EndpointAdapterFactory } from "./transports.js";
3
3
  export class LocalClusterCoordinator {
4
+ /**
5
+ * Exposes the public constructor operation.
6
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
7
+ */
4
8
  constructor(options = {}) {
5
9
  this.options = options;
6
10
  }
@@ -59,6 +63,9 @@ export class LocalClusterCoordinator {
59
63
  return result;
60
64
  }
61
65
  }
66
+ /**
67
+ * Exposes the plan agent scenario assignments operation. Use this when interacting with the SDK through this surface.
68
+ */
62
69
  export function planAgentScenarioAssignments(scenarioNames, agentCount, options = {}) {
63
70
  const totalAgents = Math.max(agentCount, 0);
64
71
  const assignments = Array.from({ length: totalAgents }, () => []);
@@ -114,10 +121,14 @@ export function planAgentScenarioAssignments(scenarioNames, agentCount, options
114
121
  return assignments;
115
122
  }
116
123
  export class DistributedClusterCoordinator {
124
+ /**
125
+ * Exposes the public constructor operation.
126
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
127
+ */
117
128
  constructor(options) {
118
129
  this.options = options;
119
130
  }
120
- async dispatch(assignments) {
131
+ async dispatch(assignments, buildAgentRunToken) {
121
132
  const expected = Math.max(this.options.expectedAgentResults, 0);
122
133
  const timeoutMs = Math.max(this.options.commandTimeoutMs ?? 120000, 1);
123
134
  const runSubject = this.buildRunSubject();
@@ -139,6 +150,9 @@ export class DistributedClusterCoordinator {
139
150
  targetScenarios: assignments[i] ?? [],
140
151
  replySubject
141
152
  };
153
+ if (buildAgentRunToken) {
154
+ command.agentRunToken = await buildAgentRunToken(command);
155
+ }
142
156
  await commandProducer.produce({
143
157
  headers: {
144
158
  "x-cluster-command-id": commandId,
@@ -199,6 +213,10 @@ export class DistributedClusterCoordinator {
199
213
  }
200
214
  }
201
215
  export class DistributedClusterAgent {
216
+ /**
217
+ * Exposes the public constructor operation.
218
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
219
+ */
202
220
  constructor(options) {
203
221
  this.commandConsumer = null;
204
222
  this.options = options;
@@ -248,7 +266,13 @@ export class DistributedClusterAgent {
248
266
  try {
249
267
  let result;
250
268
  try {
251
- const nodeResult = await execute({ scenarioNames: command.targetScenarios });
269
+ const nodeResult = await execute({
270
+ scenarioNames: command.targetScenarios,
271
+ commandId: command.commandId,
272
+ agentRunToken: command.agentRunToken,
273
+ agentIndex: command.agentIndex,
274
+ agentCount: command.agentCount
275
+ });
252
276
  result = {
253
277
  commandId: command.commandId,
254
278
  agentId: this.options.agentId,
@@ -298,6 +322,7 @@ function parseRunCommand(value) {
298
322
  agentIndex: numberOrDefault(value.agentIndex, numberOrDefault(value.AgentIndex, 0)),
299
323
  agentCount: numberOrDefault(value.agentCount, numberOrDefault(value.AgentCount, 0)),
300
324
  targetScenarios,
325
+ agentRunToken: stringOrDefault(value.agentRunToken, stringOrDefault(value.AgentRunToken, "")),
301
326
  replySubject: stringOrDefault(value.replySubject, stringOrDefault(value.ReplySubject, ""))
302
327
  };
303
328
  }
@@ -1,6 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createClient } from "redis";
3
3
  export class TrackingPayloadBuilder {
4
+ /**
5
+ * Exposes the public constructor operation.
6
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
7
+ */
4
8
  constructor() {
5
9
  this.Headers = {};
6
10
  this.body = new Uint8Array();
@@ -8,12 +12,24 @@ export class TrackingPayloadBuilder {
8
12
  get Body() {
9
13
  return new Uint8Array(this.body);
10
14
  }
15
+ /**
16
+ * Sets the payload body on the builder.
17
+ * Use this when a tracking payload should be created from raw content.
18
+ */
11
19
  setBody(body) {
12
20
  this.body = normalizeTrackingBodyToBytes(body);
13
21
  }
22
+ /**
23
+ * Sets the payload body on the builder.
24
+ * Use this when a tracking payload should be created from raw content.
25
+ */
14
26
  SetBody(body) {
15
27
  this.setBody(body);
16
28
  }
29
+ /**
30
+ * Builds the configured payload or helper object.
31
+ * Use this when all builder inputs are ready to be materialized.
32
+ */
17
33
  build() {
18
34
  return createTrackingPayload({
19
35
  headers: { ...this.Headers },
@@ -24,6 +40,10 @@ export class TrackingPayloadBuilder {
24
40
  jsonConvertSettings: cloneRecord(this.JsonConvertSettings)
25
41
  });
26
42
  }
43
+ /**
44
+ * Builds the configured payload or helper object.
45
+ * Use this when all builder inputs are ready to be materialized.
46
+ */
27
47
  Build() {
28
48
  return this.build();
29
49
  }
@@ -41,6 +61,10 @@ export class RedisCorrelationStoreOptions {
41
61
  set EntryTtl(value) {
42
62
  this.EntryTtlSeconds = value;
43
63
  }
64
+ /**
65
+ * Exposes the public validate operation.
66
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
67
+ */
44
68
  validate() {
45
69
  if (!this.ConnectionString.trim()) {
46
70
  throw new Error("Redis ConnectionString must be provided.");
@@ -52,6 +76,10 @@ export class RedisCorrelationStoreOptions {
52
76
  throw new RangeError("Redis EntryTtl must be greater than zero.");
53
77
  }
54
78
  }
79
+ /**
80
+ * Exposes the public Validate operation.
81
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
82
+ */
55
83
  Validate() {
56
84
  this.validate();
57
85
  }
@@ -60,12 +88,24 @@ export class CorrelationStoreConfiguration {
60
88
  constructor() {
61
89
  this.Kind = "InMemory";
62
90
  }
91
+ /**
92
+ * Creates an in-memory correlation store configuration.
93
+ * Use this when tracking state can stay local to the process.
94
+ */
63
95
  static inMemory() {
64
96
  return new CorrelationStoreConfiguration();
65
97
  }
98
+ /**
99
+ * Creates an in-memory correlation store configuration.
100
+ * Use this when tracking state can stay local to the process.
101
+ */
66
102
  static InMemory() {
67
103
  return CorrelationStoreConfiguration.inMemory();
68
104
  }
105
+ /**
106
+ * Creates a Redis-backed correlation store configuration.
107
+ * Use this when tracking state must survive across processes or cluster nodes.
108
+ */
69
109
  static redisStore(options) {
70
110
  if (!(options instanceof RedisCorrelationStoreOptions)) {
71
111
  throw new TypeError("Redis correlation store options must be provided.");
@@ -75,9 +115,17 @@ export class CorrelationStoreConfiguration {
75
115
  configuration.Redis = options;
76
116
  return configuration;
77
117
  }
118
+ /**
119
+ * Creates a Redis-backed correlation store configuration.
120
+ * Use this when tracking state must survive across processes or cluster nodes.
121
+ */
78
122
  static RedisStore(options) {
79
123
  return CorrelationStoreConfiguration.redisStore(options);
80
124
  }
125
+ /**
126
+ * Exposes the public validate operation.
127
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
128
+ */
81
129
  validate() {
82
130
  if (this.Kind === "Redis") {
83
131
  if (!(this.Redis instanceof RedisCorrelationStoreOptions)) {
@@ -86,6 +134,10 @@ export class CorrelationStoreConfiguration {
86
134
  this.Redis.validate();
87
135
  }
88
136
  }
137
+ /**
138
+ * Exposes the public Validate operation.
139
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
140
+ */
89
141
  Validate() {
90
142
  this.validate();
91
143
  }
@@ -97,6 +149,10 @@ export class InMemoryCorrelationStore {
97
149
  this.sourceOccurrences = new Map();
98
150
  this.destinationOccurrences = new Map();
99
151
  }
152
+ /**
153
+ * Sets the current gauge value.
154
+ * Use this when the latest observed value should replace the previous one.
155
+ */
100
156
  set(entry) {
101
157
  const normalized = normalizeCorrelationEntry(entry);
102
158
  const queue = this.pendingByTrackingId.get(normalized.trackingId) ?? [];
@@ -104,10 +160,18 @@ export class InMemoryCorrelationStore {
104
160
  this.pendingByTrackingId.set(normalized.trackingId, queue);
105
161
  this.pendingByEventId.set(normalized.eventId ?? "", normalized);
106
162
  }
163
+ /**
164
+ * Exposes the public get operation.
165
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
166
+ */
107
167
  get(trackingId) {
108
168
  const queue = this.pendingByTrackingId.get(trackingId);
109
169
  return queue && queue.length > 0 ? hydrateCorrelationEntry(queue[0]) : null;
110
170
  }
171
+ /**
172
+ * Exposes the public delete operation.
173
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
174
+ */
111
175
  delete(trackingId) {
112
176
  const queue = this.pendingByTrackingId.get(trackingId);
113
177
  if (!queue || queue.length === 0) {
@@ -123,6 +187,10 @@ export class InMemoryCorrelationStore {
123
187
  }
124
188
  this.pendingByTrackingId.set(trackingId, queue);
125
189
  }
190
+ /**
191
+ * Exposes the public all operation.
192
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
193
+ */
126
194
  all() {
127
195
  const entries = [];
128
196
  for (const queue of this.pendingByTrackingId.values()) {
@@ -132,21 +200,37 @@ export class InMemoryCorrelationStore {
132
200
  }
133
201
  return entries;
134
202
  }
203
+ /**
204
+ * Exposes the public collectExpired operation.
205
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
206
+ */
135
207
  collectExpired(nowMs = Date.now(), maxCount = 0) {
136
208
  void nowMs;
137
209
  void maxCount;
138
210
  return [];
139
211
  }
212
+ /**
213
+ * Exposes the public incrementSourceOccurrences operation.
214
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
215
+ */
140
216
  incrementSourceOccurrences(trackingId) {
141
217
  const next = (this.sourceOccurrences.get(trackingId) ?? 0) + 1;
142
218
  this.sourceOccurrences.set(trackingId, next);
143
219
  return next;
144
220
  }
221
+ /**
222
+ * Exposes the public incrementDestinationOccurrences operation.
223
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
224
+ */
145
225
  incrementDestinationOccurrences(trackingId) {
146
226
  const next = (this.destinationOccurrences.get(trackingId) ?? 0) + 1;
147
227
  this.destinationOccurrences.set(trackingId, next);
148
228
  return next;
149
229
  }
230
+ /**
231
+ * Exposes the public registerSource operation.
232
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
233
+ */
150
234
  registerSource(trackingId, sourceTimestampUtc) {
151
235
  const entry = normalizeCorrelationEntry({
152
236
  trackingId,
@@ -159,6 +243,10 @@ export class InMemoryCorrelationStore {
159
243
  this.set(entry);
160
244
  return entry;
161
245
  }
246
+ /**
247
+ * Exposes the public tryMatchDestination operation.
248
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
249
+ */
162
250
  tryMatchDestination(trackingId, destinationTimestampUtc) {
163
251
  const source = this.get(trackingId);
164
252
  if (!source) {
@@ -170,6 +258,10 @@ export class InMemoryCorrelationStore {
170
258
  destinationTimestampUtc
171
259
  });
172
260
  }
261
+ /**
262
+ * Exposes the public tryExpire operation.
263
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
264
+ */
173
265
  tryExpire(eventId) {
174
266
  const entry = this.pendingByEventId.get(eventId);
175
267
  if (!entry) {
@@ -510,10 +602,18 @@ export class RedisCorrelationStore {
510
602
  }
511
603
  }
512
604
  export class TrackingFieldSelector {
605
+ /**
606
+ * Exposes the public constructor operation.
607
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
608
+ */
513
609
  constructor(location, path) {
514
610
  this.kind = normalizeTrackingFieldLocation(location);
515
611
  this.path = String(path ?? "");
516
612
  }
613
+ /**
614
+ * Parses a selector or configuration expression.
615
+ * Use this when a tracking selector should be created from its text form.
616
+ */
517
617
  static parse(value) {
518
618
  const normalized = (value ?? "").trim();
519
619
  const [kindRaw, ...rest] = normalized.split(":");
@@ -523,9 +623,17 @@ export class TrackingFieldSelector {
523
623
  }
524
624
  return new TrackingFieldSelector(kindRaw.trim(), path);
525
625
  }
626
+ /**
627
+ * Parses a selector or configuration expression.
628
+ * Use this when a tracking selector should be created from its text form.
629
+ */
526
630
  static Parse(value) {
527
631
  return TrackingFieldSelector.parse(value);
528
632
  }
633
+ /**
634
+ * Attempts to parse a selector or configuration expression.
635
+ * Use this when invalid input should be handled without throwing.
636
+ */
529
637
  static tryParse(value) {
530
638
  try {
531
639
  return {
@@ -540,6 +648,10 @@ export class TrackingFieldSelector {
540
648
  };
541
649
  }
542
650
  }
651
+ /**
652
+ * Attempts to parse a selector or configuration expression.
653
+ * Use this when invalid input should be handled without throwing.
654
+ */
543
655
  static TryParse(value) {
544
656
  return TrackingFieldSelector.tryParse(value);
545
657
  }
@@ -549,6 +661,10 @@ export class TrackingFieldSelector {
549
661
  get Path() {
550
662
  return this.path;
551
663
  }
664
+ /**
665
+ * Extracts a value from the provided payload.
666
+ * Use this when a tracking selector should read data from a transport payload.
667
+ */
552
668
  extract(payload) {
553
669
  if (this.kind === "header") {
554
670
  const headers = payload.headers ?? {};
@@ -578,9 +694,17 @@ export class TrackingFieldSelector {
578
694
  }
579
695
  return String(current);
580
696
  }
697
+ /**
698
+ * Exposes the public toString operation.
699
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
700
+ */
581
701
  toString() {
582
702
  return `${this.kind}:${this.path}`;
583
703
  }
704
+ /**
705
+ * Exposes the public ToString operation.
706
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
707
+ */
584
708
  ToString() {
585
709
  return this.toString();
586
710
  }
@@ -593,6 +717,10 @@ function normalizeTrackingFieldLocation(location) {
593
717
  throw new Error("Tracking field location must be Header or Json.");
594
718
  }
595
719
  export class CrossPlatformTrackingRuntime {
720
+ /**
721
+ * Exposes the public constructor operation.
722
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
723
+ */
596
724
  constructor(options) {
597
725
  this.trackingIdDisplayByEventId = new Map();
598
726
  this.gatherByDisplayByComparisonKey = new Map();
@@ -810,6 +938,10 @@ export class CrossPlatformTrackingRuntime {
810
938
  this.gatherByDisplayByComparisonKey.set(comparisonKey, gatherKey);
811
939
  return gatherKey;
812
940
  }
941
+ /**
942
+ * Exposes the public isTimeoutCountedAsFailure operation.
943
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
944
+ */
813
945
  isTimeoutCountedAsFailure() {
814
946
  return this.timeoutCountsAsFailure;
815
947
  }
package/dist/esm/local.js CHANGED
@@ -47,6 +47,10 @@ const CI_ENVIRONMENT_VARIABLES = [
47
47
  "HEROKU_TEST_RUN_ID"
48
48
  ];
49
49
  export class LoadStrikeLocalClient {
50
+ /**
51
+ * Exposes the public constructor operation.
52
+ * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
53
+ */
50
54
  constructor(options = {}) {
51
55
  this.signingKeyCache = new Map();
52
56
  assertNoDisableLicenseEnforcementOption(options, "LoadStrikeLocalClient");
@@ -113,6 +117,17 @@ export class LoadStrikeLocalClient {
113
117
  const context = asRecord(request.Context);
114
118
  const nodeType = normalizeNodeType(pickValue(context, "NodeType", "nodeType"));
115
119
  if (nodeType === "agent") {
120
+ const agentExecutionToken = stringOrDefault(pickValue(context, "AgentExecutionToken", "agentExecutionToken"), "").trim();
121
+ if (!agentExecutionToken) {
122
+ throw new Error("Agent node type cannot run directly. Start it from a coordinator session after controller license validation.");
123
+ }
124
+ const agentCommandId = stringOrDefault(pickValue(context, "AgentCommandId", "agentCommandId"), "").trim();
125
+ if (!agentCommandId) {
126
+ throw new Error("Agent execution is missing the coordinator command identifier.");
127
+ }
128
+ const requestedFeatures = collectRequestedFeatures(request);
129
+ const sessionId = stringOrDefault(pickValue(context, "SessionId", "sessionId"), "").trim();
130
+ await this.verifySignedAgentExecutionToken(agentExecutionToken, request, requestedFeatures, sessionId, agentCommandId, resolveTargetScenarioNames(context));
116
131
  return {};
117
132
  }
118
133
  const runnerKey = stringOrDefault(context.RunnerKey, "").trim();
@@ -219,6 +234,92 @@ export class LoadStrikeLocalClient {
219
234
  enforceEntitlementClaims(parsed.payload, requestedFeatures);
220
235
  enforceRuntimePolicyClaims(parsed.payload, request);
221
236
  }
237
+ async createAgentExecutionToken(controllerRunToken, sessionId, commandId, agentIndex, agentCount, targetScenarios) {
238
+ const controller = new AbortController();
239
+ const timer = setTimeout(() => controller.abort(), this.licenseValidationTimeoutMs);
240
+ try {
241
+ const payload = {
242
+ ControllerRunToken: controllerRunToken,
243
+ SessionId: sessionId,
244
+ CommandId: commandId,
245
+ AgentIndex: agentIndex,
246
+ AgentCount: agentCount,
247
+ TargetScenarios: [...targetScenarios]
248
+ };
249
+ const { response, json } = await this.postLicensingRequest("/api/v1/licenses/agent-token", payload, controller.signal);
250
+ const agentPayload = json;
251
+ const isValid = pickValue(agentPayload, "IsValid", "isValid");
252
+ if (!response.ok || isValid !== true) {
253
+ const details = stringOrDefault(pickValue(agentPayload, "Message", "message"), "No additional details provided.");
254
+ const denialCode = stringOrDefault(pickValue(agentPayload, "DenialCode", "denialCode"), "unknown_denial");
255
+ throw new Error(`Agent execution authorization denied. Reason: ${denialCode}. ${details}`);
256
+ }
257
+ const agentRunToken = stringOrDefault(pickValue(agentPayload, "AgentRunToken", "agentRunToken"), "").trim();
258
+ if (!agentRunToken) {
259
+ throw new Error("Agent execution authorization failed: token is missing.");
260
+ }
261
+ return agentRunToken;
262
+ }
263
+ finally {
264
+ clearTimeout(timer);
265
+ }
266
+ }
267
+ async verifySignedAgentExecutionToken(runToken, request, requestedFeatures, sessionId, commandId, targetScenarios) {
268
+ const parsed = parseJwt(runToken);
269
+ const keyId = stringOrDefault(parsed.header.kid, "default");
270
+ const algorithm = stringOrDefault(parsed.header.alg, "RS256").toUpperCase();
271
+ if (algorithm !== "RS256") {
272
+ throw new Error("Agent execution authorization failed: invalid token signature.");
273
+ }
274
+ const keyRecord = await this.getSigningKey(keyId, algorithm);
275
+ if (!verifyJwtSignature(runToken, keyRecord, algorithm)) {
276
+ throw new Error("Agent execution authorization failed: invalid token signature.");
277
+ }
278
+ enforceJwtLifetime(parsed.payload);
279
+ const tokenIssuer = stringOrDefault(parsed.payload.iss, "").trim();
280
+ if (!keyRecord.issuer || tokenIssuer !== keyRecord.issuer) {
281
+ throw new Error("Agent execution authorization failed: token issuer does not match.");
282
+ }
283
+ const rawAudience = parsed.payload.aud;
284
+ const tokenAudiences = Array.isArray(rawAudience)
285
+ ? rawAudience.map((entry) => stringOrDefault(entry, "").trim()).filter((entry) => entry.length > 0)
286
+ : stringOrDefault(rawAudience, "").trim()
287
+ ? [stringOrDefault(rawAudience, "").trim()]
288
+ : [];
289
+ if (!keyRecord.audience || !tokenAudiences.includes(keyRecord.audience)) {
290
+ throw new Error("Agent execution authorization failed: token audience does not match.");
291
+ }
292
+ const tokenKind = stringOrDefault(parsed.payload.token_kind, "").trim();
293
+ if (tokenKind !== "agent_execution") {
294
+ throw new Error("Agent execution authorization failed: token kind is invalid.");
295
+ }
296
+ const tokenNodeType = stringOrDefault(parsed.payload.node_type, "").trim().toLowerCase();
297
+ if (tokenNodeType !== "agent") {
298
+ throw new Error("Agent execution authorization failed: token node type is invalid.");
299
+ }
300
+ const tokenSessionId = stringOrDefault(parsed.payload.session_id, "").trim();
301
+ if (tokenSessionId !== sessionId) {
302
+ throw new Error("Agent execution authorization failed: session id does not match.");
303
+ }
304
+ const tokenCommandId = stringOrDefault(parsed.payload.command_id, "").trim();
305
+ if (tokenCommandId !== commandId) {
306
+ throw new Error("Agent execution authorization failed: command id does not match.");
307
+ }
308
+ const tokenTargetScenarios = collectClaimStrings(parsed.payload, "target_scenario")
309
+ .map((value) => value.trim())
310
+ .filter((value) => value.length > 0)
311
+ .sort();
312
+ const expectedTargetScenarios = [...targetScenarios]
313
+ .map((value) => value.trim())
314
+ .filter((value) => value.length > 0)
315
+ .sort();
316
+ if (tokenTargetScenarios.length !== expectedTargetScenarios.length
317
+ || tokenTargetScenarios.some((value, index) => value !== expectedTargetScenarios[index])) {
318
+ throw new Error("Agent execution authorization failed: target scenarios do not match.");
319
+ }
320
+ enforceEntitlementClaims(parsed.payload, requestedFeatures);
321
+ enforceRuntimePolicyClaims(parsed.payload, request);
322
+ }
222
323
  async getSigningKey(keyId, algorithm) {
223
324
  const nowMs = Date.now();
224
325
  const normalizedKeyId = stringOrDefault(keyId, "default").trim() || "default";
@@ -400,6 +501,7 @@ function generateSessionId() {
400
501
  function collectRequestedFeatures(request) {
401
502
  const context = asRecord(request.Context);
402
503
  const scenarios = Array.isArray(request.Scenarios) ? request.Scenarios : [];
504
+ const nodeType = normalizeNodeType(pickValue(context, "NodeType", "nodeType"));
403
505
  const features = new Set([
404
506
  "core.runtime",
405
507
  "reporting.formats.standard"
@@ -413,7 +515,9 @@ function collectRequestedFeatures(request) {
413
515
  const hasAdvancedTargeting = normalizeStringArray(pickValue(context, "AgentTargetScenarios", "agentTargetScenarios")).length > 0
414
516
  || normalizeStringArray(pickValue(context, "CoordinatorTargetScenarios", "coordinatorTargetScenarios")).length > 0
415
517
  || resolveTimeoutSeconds(context, "ClusterCommandTimeoutSeconds", "clusterCommandTimeoutSeconds", "ClusterCommandTimeoutMs", "clusterCommandTimeoutMs", 120) !== 120;
416
- if (hasAdvancedTargeting) {
518
+ // Agent child contexts carry coordinator-assigned target slices. That internal
519
+ // state must not force the advanced targeting entitlement a second time.
520
+ if (hasAdvancedTargeting && nodeType !== "agent") {
417
521
  features.add("cluster.targeting_and_tuning.advanced");
418
522
  }
419
523
  if (countCustomWorkerPlugins(context) > 0) {
@@ -1850,13 +1954,16 @@ function toContractNodeType(value) {
1850
1954
  }
1851
1955
  }
1852
1956
  function countTargetedScenarios(context) {
1853
- const values = [
1854
- ...normalizeStringArray(pickValue(context, "TargetScenarios", "targetScenarios")),
1855
- ...normalizeStringArray(pickValue(context, "AgentTargetScenarios", "agentTargetScenarios")),
1856
- ...normalizeStringArray(pickValue(context, "CoordinatorTargetScenarios", "coordinatorTargetScenarios"))
1857
- ];
1957
+ const values = resolveTargetScenarioNames(context);
1858
1958
  return new Set(values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)).size;
1859
1959
  }
1960
+ function resolveTargetScenarioNames(context) {
1961
+ return [...new Set([
1962
+ ...normalizeStringArray(pickValue(context, "TargetScenarios", "targetScenarios")),
1963
+ ...normalizeStringArray(pickValue(context, "AgentTargetScenarios", "agentTargetScenarios")),
1964
+ ...normalizeStringArray(pickValue(context, "CoordinatorTargetScenarios", "coordinatorTargetScenarios"))
1965
+ ])];
1966
+ }
1860
1967
  function countCustomWorkerPlugins(context) {
1861
1968
  const workerPlugins = asList(pickValue(context, "WorkerPlugins", "workerPlugins"));
1862
1969
  let count = 0;
@@ -1031,6 +1031,9 @@ function buildDotnetHtmlTabs(nodeStats) {
1031
1031
  }
1032
1032
  return tabs;
1033
1033
  }
1034
+ /**
1035
+ * Exposes the build dotnet txt report operation. Use this when interacting with the SDK through this surface.
1036
+ */
1034
1037
  export function buildDotnetTxtReport(nodeStats) {
1035
1038
  const scenarios = sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"));
1036
1039
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
@@ -1077,6 +1080,9 @@ export function buildDotnetTxtReport(nodeStats) {
1077
1080
  }
1078
1081
  return reportLines(lines);
1079
1082
  }
1083
+ /**
1084
+ * Exposes the build dotnet csv report operation. Use this when interacting with the SDK through this surface.
1085
+ */
1080
1086
  export function buildDotnetCsvReport(nodeStats) {
1081
1087
  const lines = ["ScenarioName,Requests,Ok,Fail,DurationSeconds,Rps"];
1082
1088
  for (const scenario of sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"))) {
@@ -1086,6 +1092,9 @@ export function buildDotnetCsvReport(nodeStats) {
1086
1092
  }
1087
1093
  return reportLines(lines);
1088
1094
  }
1095
+ /**
1096
+ * Exposes the build dotnet markdown report operation. Use this when interacting with the SDK through this surface.
1097
+ */
1089
1098
  export function buildDotnetMarkdownReport(nodeStats) {
1090
1099
  const scenarios = sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"));
1091
1100
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
@@ -1125,6 +1134,9 @@ export function buildDotnetMarkdownReport(nodeStats) {
1125
1134
  }
1126
1135
  return reportLines(lines);
1127
1136
  }
1137
+ /**
1138
+ * Exposes the build dotnet html report operation. Use this when interacting with the SDK through this surface.
1139
+ */
1128
1140
  export function buildDotnetHtmlReport(nodeStats) {
1129
1141
  const tabs = buildDotnetHtmlTabs(nodeStats);
1130
1142
  const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${tabId}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");