deepline 0.2.30 → 0.2.32

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.
@@ -2155,6 +2155,9 @@ export class DeeplineClient {
2155
2155
  // defaults to absurd; callers normally omit this field.
2156
2156
  ...(request.profile ? { profile: request.profile } : {}),
2157
2157
  ...(integrationMode ? { integrationMode } : {}),
2158
+ ...(request.fixtureBehavior
2159
+ ? { fixtureBehavior: request.fixtureBehavior }
2160
+ : {}),
2158
2161
  ...(runtime ? { runtime } : {}),
2159
2162
  ...(testPolicyOverrides ? { testPolicyOverrides } : {}),
2160
2163
  },
@@ -2225,6 +2228,9 @@ export class DeeplineClient {
2225
2228
  : {}),
2226
2229
  ...(request.profile ? { profile: request.profile } : {}),
2227
2230
  ...(integrationMode ? { integrationMode } : {}),
2231
+ ...(request.fixtureBehavior
2232
+ ? { fixtureBehavior: request.fixtureBehavior }
2233
+ : {}),
2228
2234
  ...(runtime ? { runtime } : {}),
2229
2235
  ...(testPolicyOverrides ? { testPolicyOverrides } : {}),
2230
2236
  };
@@ -160,7 +160,7 @@ export const SDK_RELEASE = {
160
160
  // 0.2.0 makes Dataset Handles uniformly async-only after 0.1.320 briefly
161
161
  // exposed storage-dependent synchronous access. This deliberate minor
162
162
  // release keeps lazy paging semantics independent of row residency.
163
- version: '0.2.30',
163
+ version: '0.2.32',
164
164
  contracts: {
165
165
  api: {
166
166
  name: 'sdk-http-api',
@@ -1394,6 +1394,8 @@ export interface StartPlayRunRequest {
1394
1394
  profile?: string;
1395
1395
  /** Optional per-run provider execution mode for eval/smoke runs. */
1396
1396
  integrationMode?: 'live' | 'eval_stub' | 'fixture';
1397
+ /** Fixture-only provider response timing and outcome simulation. */
1398
+ fixtureBehavior?: import('../../shared_libs/play-runtime/fixture-behavior').FixtureBehavior;
1397
1399
  /** Internal runtime estate selection. The app host remains unchanged. */
1398
1400
  runtime?: PlayRuntimeSelection;
1399
1401
  /** Internal/dev-only runtime policy overrides for black-box durability tests. */
@@ -992,6 +992,9 @@ async function postAppRuntimeApi<TResponse>(
992
992
  : AbortSignal.timeout(requestTimeoutMs),
993
993
  });
994
994
  } catch (error) {
995
+ if (context.signal?.aborted) {
996
+ throw context.signal.reason ?? error;
997
+ }
995
998
  if (
996
999
  attempt < maxAttempts &&
997
1000
  isRetryableAppRuntimeFetchError({ action: body.action, error })
@@ -35,6 +35,12 @@ import {
35
35
  runtimeLeaseHeartbeatIntervalFromExpiry,
36
36
  } from './lease-policy';
37
37
  import { createRuntimeReceiptHeartbeatSupervisor } from './receipt-heartbeat-supervisor';
38
+ import {
39
+ shouldRouteFixtureProvider,
40
+ shouldRouteFixtureToolId,
41
+ validateFixtureBehavior,
42
+ waitForFixtureResponseDelay,
43
+ } from './fixture-behavior';
38
44
  import { dispatchBoundedSettled } from './bounded-dispatch';
39
45
  import type { PlayQueueHint } from './governor/rate-state-backend';
40
46
  import type { MapRowOutcome } from './durability-store';
@@ -639,6 +645,24 @@ function isDeeplineDeveloperTunnelOrigin502(input: {
639
645
  }
640
646
  }
641
647
 
648
+ function isUnmarkedExecutionGateway502(input: {
649
+ url: string;
650
+ status: number;
651
+ responseHeaders: Headers;
652
+ }): boolean {
653
+ if (
654
+ input.status !== 502 ||
655
+ input.responseHeaders.has('x-deepline-request-id')
656
+ ) {
657
+ return false;
658
+ }
659
+ try {
660
+ return new URL(input.url).pathname.endsWith('/execute-fenced-v1');
661
+ } catch {
662
+ return false;
663
+ }
664
+ }
665
+
642
666
  function loadSafeFetch(): Promise<SafeFetchModule> {
643
667
  safeFetchModule ??= import('@shared_libs/security/safe-fetch');
644
668
  return safeFetchModule;
@@ -1622,6 +1646,22 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
1622
1646
  };
1623
1647
 
1624
1648
  constructor(options: ContextOptions) {
1649
+ const fixtureBehavior = validateFixtureBehavior(options.fixtureBehavior);
1650
+ if (fixtureBehavior.ok === false) {
1651
+ throw new Error(fixtureBehavior.error);
1652
+ }
1653
+ if (
1654
+ fixtureBehavior.behavior !== null &&
1655
+ options.integrationMode !== 'fixture'
1656
+ ) {
1657
+ throw new Error(
1658
+ 'fixtureBehavior is only valid when integrationMode is fixture.',
1659
+ );
1660
+ }
1661
+ options = {
1662
+ ...options,
1663
+ fixtureBehavior: fixtureBehavior.behavior,
1664
+ };
1625
1665
  this.#options = options;
1626
1666
  this.checkpoint = options.checkpoint ?? emptyCheckpoint();
1627
1667
  this.durableMappedToolResultsBackedByReceipts = Boolean(
@@ -6720,6 +6760,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6720
6760
  }
6721
6761
  }
6722
6762
 
6763
+ private fixtureProviderPacingDisabled(): boolean {
6764
+ return (
6765
+ this.#options.integrationMode === 'fixture' &&
6766
+ this.#options.enforceFixtureProviderPacing !== true
6767
+ );
6768
+ }
6769
+
6723
6770
  private toolDispatchLane(request: ToolCallRequest): {
6724
6771
  key: string;
6725
6772
  readyCount: number;
@@ -8983,15 +9030,16 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
8983
9030
  getPayload: (request: ToolCallRequest) => request.input,
8984
9031
  });
8985
9032
 
9033
+ const batchParallelismCeiling =
9034
+ this.governor.policy.pacing.workerToolBatchDefaultParallelism;
8986
9035
  const batchSize =
8987
- compiledBatches.length > 0
9036
+ compiledBatches.length > 0 &&
9037
+ !this.fixtureProviderPacingDisabled()
8988
9038
  ? await this.resourceGovernor.suggestedToolParallelism(
8989
9039
  compiledBatches[0]!.batchOperation,
8990
- this.governor.policy.pacing
8991
- .workerToolBatchDefaultParallelism,
9040
+ batchParallelismCeiling,
8992
9041
  )
8993
- : this.governor.policy.pacing
8994
- .workerToolBatchDefaultParallelism;
9042
+ : batchParallelismCeiling;
8995
9043
  await executeChunkedRequests({
8996
9044
  requests: compiledBatches,
8997
9045
  batchSize,
@@ -9182,11 +9230,12 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9182
9230
  // each in-flight call, so this only trims the launch burst.
9183
9231
  const toolCallConcurrencyCeiling =
9184
9232
  this.governor.policy.concurrency.toolCalls;
9185
- const shapedToolParallelism =
9186
- await this.resourceGovernor.suggestedToolParallelism(
9187
- toolId,
9188
- toolCallConcurrencyCeiling,
9189
- );
9233
+ const shapedToolParallelism = this.fixtureProviderPacingDisabled()
9234
+ ? toolCallConcurrencyCeiling
9235
+ : await this.resourceGovernor.suggestedToolParallelism(
9236
+ toolId,
9237
+ toolCallConcurrencyCeiling,
9238
+ );
9190
9239
  const dispatchWidth = Math.min(
9191
9240
  toolCallConcurrencyCeiling,
9192
9241
  Math.max(1, shapedToolParallelism),
@@ -9563,6 +9612,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9563
9612
  });
9564
9613
  let transportAttempt = 0;
9565
9614
  let invocationAttempt = 0;
9615
+ let physicalAttempt = 0;
9566
9616
  const retryToolTransportFailure = async (input: {
9567
9617
  error: unknown;
9568
9618
  elapsedMs: number;
@@ -9626,11 +9676,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9626
9676
  };
9627
9677
 
9628
9678
  while (true) {
9679
+ physicalAttempt += 1;
9629
9680
  let response: Response | null = null;
9630
9681
  let responseData: Record<string, unknown> | null = null;
9631
9682
  let responseErrorText: string | null = null;
9632
9683
  let providerCallStartedAt: number | null = null;
9633
9684
  let providerCallElapsedMs: number | null = null;
9685
+ let integrationFetchStartedAt: number | null = null;
9634
9686
  let fetchDispatched = false;
9635
9687
  // Receipt ownership is a liveness contract, not a one-time check.
9636
9688
  // Keep it alive for the whole provider HTTP request. The cadence
@@ -9641,15 +9693,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9641
9693
  timeoutMs || hasReceiptHeartbeat ? new AbortController() : null;
9642
9694
  const receiptHeartbeat = options?.heartbeatReceipt;
9643
9695
  let heartbeatFailure: unknown = null;
9644
- const timeoutHandle = timeoutMs
9645
- ? setTimeout(() => {
9646
- abortController?.abort(
9647
- new Error(
9648
- `Tool ${toolId} runtime API call timed out after ${timeoutMs}ms.`,
9649
- ),
9650
- );
9651
- }, timeoutMs)
9652
- : null;
9696
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
9653
9697
  try {
9654
9698
  const ownershipStartedAt = Date.now();
9655
9699
  await options?.beforeProviderCall?.();
@@ -9705,7 +9749,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9705
9749
  fixtureExecution &&
9706
9750
  this.#options.enforceFixtureProviderPacing === true;
9707
9751
  const fixtureOnlyExecution =
9708
- fixtureExecution && !enforceFixtureProviderPacing;
9752
+ this.fixtureProviderPacingDisabled();
9709
9753
  if (
9710
9754
  fixtureOnlyExecution &&
9711
9755
  !this.fixtureProviderPacingBypassLogged
@@ -9730,69 +9774,181 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9730
9774
  toolId,
9731
9775
  signal: abortController?.signal,
9732
9776
  });
9733
- const integrationFetchStartedAt = Date.now();
9734
- providerCallStartedAt = integrationFetchStartedAt;
9735
9777
  try {
9736
- fetchDispatched = true;
9737
- response = await fetch(url, {
9738
- method: 'POST',
9739
- signal: abortController?.signal,
9740
- headers: {
9741
- 'Content-Type': 'application/json',
9742
- Authorization: `Bearer ${this.#options.executorToken}`,
9743
- [EXECUTE_RESPONSE_CONTRACT_HEADER]:
9744
- V2_EXECUTE_RESPONSE_CONTRACT,
9745
- [EXECUTE_RESPONSE_INTENT_HEADER]: 'dataset',
9746
- [EXECUTE_TOOL_METADATA_HEADER]: 'true',
9747
- [TOOL_EXECUTION_ERROR_SCHEMA_HEADER]: String(
9748
- toolErrorSchemaVersion,
9749
- ),
9750
- 'x-deepline-request-id': deeplineRequestId,
9751
- ...(providerIdempotencyKey
9752
- ? {
9753
- 'x-deepline-idempotency-key':
9754
- providerIdempotencyKey,
9755
- }
9756
- : {}),
9757
- ...protectionHeaders,
9758
- ...(this.#options.runtimeTestFaultHeader
9759
- ? {
9760
- 'x-deepline-test-fault':
9761
- this.#options.runtimeTestFaultHeader,
9762
- }
9763
- : {}),
9764
- },
9765
- body: serializedRequestBody(invocationAttempt),
9766
- });
9767
- if (response.ok) {
9768
- try {
9769
- responseData = await readToolExecuteResponseBody({
9770
- toolId,
9771
- abortController,
9772
- read: () =>
9773
- response!.json() as Promise<Record<string, unknown>>,
9774
- });
9775
- } catch (error) {
9776
- if (error instanceof SyntaxError) {
9777
- throw new ToolExecuteInvalidJsonError(error);
9778
+ // Provider admission is our queue, not provider execution.
9779
+ // Start the tool deadline only after admission so a busy
9780
+ // runtime cannot consume the remote-call budget while this
9781
+ // attempt is still waiting for permission to leave.
9782
+ if (
9783
+ timeoutMs &&
9784
+ abortController &&
9785
+ !abortController.signal.aborted
9786
+ ) {
9787
+ timeoutHandle = setTimeout(() => {
9788
+ abortController.abort(
9789
+ new Error(
9790
+ `Tool ${toolId} runtime API call timed out after ${timeoutMs}ms.`,
9791
+ ),
9792
+ );
9793
+ }, timeoutMs);
9794
+ }
9795
+ const fixtureBehavior = this.#options.fixtureBehavior;
9796
+ if (
9797
+ fixtureExecution &&
9798
+ fixtureBehavior &&
9799
+ shouldRouteFixtureToolId(toolId)
9800
+ ) {
9801
+ const canonicalProvider =
9802
+ (await this.#options.getToolProvider?.(toolId))?.trim() ||
9803
+ provider;
9804
+ if (shouldRouteFixtureProvider(canonicalProvider)) {
9805
+ if (!durableCallReceiptKey) {
9806
+ throw new Error(
9807
+ 'Configured fixture response timing requires a durable call receipt key.',
9808
+ );
9809
+ }
9810
+ const canonicalOperation =
9811
+ (
9812
+ await this.#options.getToolOperation?.(toolId)
9813
+ )?.trim() || toolId;
9814
+ const stableRequestKey = `${canonicalProvider}:${canonicalOperation}:${durableCallReceiptKey}`;
9815
+ const behaviorDigest = stableDigest(
9816
+ JSON.stringify(fixtureBehavior),
9817
+ );
9818
+ const requestKeyDigest = stableDigest(stableRequestKey);
9819
+ let selected: {
9820
+ delayMs: number;
9821
+ sampleIndex: number;
9822
+ } | null = null;
9823
+ const delayStartedAt = Date.now();
9824
+ providerCallStartedAt = delayStartedAt;
9825
+ try {
9826
+ selected = await waitForFixtureResponseDelay({
9827
+ behavior: fixtureBehavior,
9828
+ stableRequestKey,
9829
+ signal: abortController?.signal,
9830
+ onSelected: (value) => {
9831
+ selected = value;
9832
+ this.log(
9833
+ `[fixture.response_delay] ${JSON.stringify({
9834
+ outcome: 'scheduled',
9835
+ provider: canonicalProvider,
9836
+ operation: canonicalOperation,
9837
+ behavior_digest: `sha256_${behaviorDigest}`,
9838
+ request_key_digest: `sha256_${requestKeyDigest}`,
9839
+ delay_ms: value.delayMs,
9840
+ sample_index: value.sampleIndex,
9841
+ physical_attempt: physicalAttempt,
9842
+ })}`,
9843
+ );
9844
+ },
9845
+ });
9846
+ } catch (error) {
9847
+ this.log(
9848
+ `[fixture.response_delay] ${JSON.stringify({
9849
+ outcome: 'aborted',
9850
+ provider: canonicalProvider,
9851
+ operation: canonicalOperation,
9852
+ behavior_digest: `sha256_${behaviorDigest}`,
9853
+ request_key_digest: `sha256_${requestKeyDigest}`,
9854
+ delay_ms: selected?.delayMs ?? null,
9855
+ sample_index: selected?.sampleIndex ?? null,
9856
+ physical_attempt: physicalAttempt,
9857
+ elapsed_ms: Date.now() - delayStartedAt,
9858
+ })}`,
9859
+ );
9860
+ throw error;
9778
9861
  }
9779
- throw new ToolExecuteResponseBodyTransportError(error);
9862
+ this.log(
9863
+ `[fixture.response_delay] ${JSON.stringify({
9864
+ outcome: 'completed',
9865
+ provider: canonicalProvider,
9866
+ operation: canonicalOperation,
9867
+ behavior_digest: `sha256_${behaviorDigest}`,
9868
+ request_key_digest: `sha256_${requestKeyDigest}`,
9869
+ delay_ms: selected.delayMs,
9870
+ sample_index: selected.sampleIndex,
9871
+ physical_attempt: physicalAttempt,
9872
+ elapsed_ms: Date.now() - delayStartedAt,
9873
+ })}`,
9874
+ );
9780
9875
  }
9781
- } else {
9782
- try {
9783
- responseErrorText = await readToolExecuteResponseBody({
9784
- toolId,
9785
- abortController,
9786
- read: () => response!.text(),
9787
- });
9788
- } catch (error) {
9789
- throw new ToolExecuteResponseBodyTransportError(error);
9876
+ }
9877
+ const integrationRequestLease =
9878
+ await this.governor.acquireIntegrationRequestSlot({
9879
+ signal: abortController?.signal,
9880
+ });
9881
+ try {
9882
+ integrationFetchStartedAt = Date.now();
9883
+ providerCallStartedAt ??= integrationFetchStartedAt;
9884
+ fetchDispatched = true;
9885
+ response = await fetch(url, {
9886
+ method: 'POST',
9887
+ signal: abortController?.signal,
9888
+ headers: {
9889
+ 'Content-Type': 'application/json',
9890
+ Authorization: `Bearer ${this.#options.executorToken}`,
9891
+ [EXECUTE_RESPONSE_CONTRACT_HEADER]:
9892
+ V2_EXECUTE_RESPONSE_CONTRACT,
9893
+ [EXECUTE_RESPONSE_INTENT_HEADER]: 'dataset',
9894
+ [EXECUTE_TOOL_METADATA_HEADER]: 'true',
9895
+ [TOOL_EXECUTION_ERROR_SCHEMA_HEADER]: String(
9896
+ toolErrorSchemaVersion,
9897
+ ),
9898
+ 'x-deepline-request-id': deeplineRequestId,
9899
+ ...(providerIdempotencyKey
9900
+ ? {
9901
+ 'x-deepline-idempotency-key':
9902
+ providerIdempotencyKey,
9903
+ }
9904
+ : {}),
9905
+ ...protectionHeaders,
9906
+ ...(this.#options.runtimeTestFaultHeader
9907
+ ? {
9908
+ 'x-deepline-test-fault':
9909
+ this.#options.runtimeTestFaultHeader,
9910
+ }
9911
+ : {}),
9912
+ },
9913
+ body: serializedRequestBody(invocationAttempt),
9914
+ });
9915
+ if (response.ok) {
9916
+ try {
9917
+ responseData = await readToolExecuteResponseBody({
9918
+ toolId,
9919
+ abortController,
9920
+ read: () =>
9921
+ response!.json() as Promise<
9922
+ Record<string, unknown>
9923
+ >,
9924
+ });
9925
+ } catch (error) {
9926
+ if (error instanceof SyntaxError) {
9927
+ throw new ToolExecuteInvalidJsonError(error);
9928
+ }
9929
+ throw new ToolExecuteResponseBodyTransportError(error);
9930
+ }
9931
+ } else {
9932
+ try {
9933
+ responseErrorText = await readToolExecuteResponseBody({
9934
+ toolId,
9935
+ abortController,
9936
+ read: () => response!.text(),
9937
+ });
9938
+ } catch (error) {
9939
+ throw new ToolExecuteResponseBodyTransportError(error);
9940
+ }
9790
9941
  }
9942
+ } finally {
9943
+ integrationRequestLease.release();
9791
9944
  }
9792
9945
  } finally {
9793
9946
  providerPermit.release();
9794
9947
  }
9795
- if (runtimeReceiptReadTraceEnabled) {
9948
+ if (
9949
+ runtimeReceiptReadTraceEnabled &&
9950
+ integrationFetchStartedAt !== null
9951
+ ) {
9796
9952
  this.log(
9797
9953
  `[perf] tool call id=${toolId} phase=integration_fetch_body elapsed_ms=${Date.now() - integrationFetchStartedAt} status=${response.status}`,
9798
9954
  );
@@ -9877,8 +10033,15 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9877
10033
  durableFenceVerificationSignal,
9878
10034
  )
9879
10035
  .catch(() => false)) === true);
10036
+ // Fixture execution has no provider-side effect. Replaying the
10037
+ // same stable request after our own transport fails is always
10038
+ // safe, even when this runner lacks a live-provider fence.
10039
+ const fixtureReplaySafe =
10040
+ this.#options.integrationMode === 'fixture';
9880
10041
  const transportReplaySafe =
9881
- !ambiguousDispatchedFailure || durableInvocationFenceVerified;
10042
+ !ambiguousDispatchedFailure ||
10043
+ durableInvocationFenceVerified ||
10044
+ fixtureReplaySafe;
9882
10045
  if (!transportReplaySafe) {
9883
10046
  const diagnostic = describeTransportError(transportError);
9884
10047
  this.log(
@@ -9946,16 +10109,25 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9946
10109
 
9947
10110
  if (!response.ok) {
9948
10111
  const text = responseErrorText ?? '';
9949
- if (
10112
+ const developerTunnelOrigin502 =
9950
10113
  isDeeplineDeveloperTunnelOrigin502({
9951
10114
  url,
9952
10115
  status: response.status,
9953
10116
  bodyText: text,
9954
- })
9955
- ) {
10117
+ });
10118
+ const unmarkedExecutionGateway502 = isUnmarkedExecutionGateway502(
10119
+ {
10120
+ url,
10121
+ status: response.status,
10122
+ responseHeaders: response.headers,
10123
+ },
10124
+ );
10125
+ if (developerTunnelOrigin502 || unmarkedExecutionGateway502) {
9956
10126
  await retryToolTransportFailure({
9957
10127
  error: new Error(
9958
- 'the Deepline development tunnel returned a branded 502 before reaching the app origin',
10128
+ developerTunnelOrigin502
10129
+ ? 'the Deepline development tunnel returned a branded 502 before reaching the app origin'
10130
+ : 'the execution gateway ingress returned an unmarked 502 before reaching the durable invocation handler',
9959
10131
  ),
9960
10132
  elapsedMs: providerCallElapsedMs ?? 0,
9961
10133
  requestId: deeplineRequestId,
@@ -42,6 +42,7 @@ import type {
42
42
  ToolExecutionErrorSchemaVersion,
43
43
  ToolExecutionFailureV1,
44
44
  } from '../tool-execution-error';
45
+ import type { FixtureBehavior } from './fixture-behavior';
45
46
 
46
47
  export interface RowState {
47
48
  results: Map<string, unknown>;
@@ -512,6 +513,8 @@ export interface ContextOptions {
512
513
  vercelProtectionBypassToken?: string | null;
513
514
  /** Optional per-run integration execution mode for provider calls. */
514
515
  integrationMode?: 'live' | 'eval_stub' | 'fixture';
516
+ /** Internal fixture-only simulation of provider response residence. */
517
+ fixtureBehavior?: FixtureBehavior | null;
515
518
  /** Preview/dev test seam that applies provider pacing to fixture responses. */
516
519
  enforceFixtureProviderPacing?: boolean;
517
520
  orgId?: string;
@@ -641,6 +644,7 @@ export interface ContextOptions {
641
644
  resolvePlay?: (playRef: string) => Promise<ResolvedPlayExecution | null>;
642
645
  getToolQueueHints?: (toolId: string) => Promise<readonly PlayQueueHint[]>;
643
646
  getToolProvider?: (toolId: string) => Promise<string | null>;
647
+ getToolOperation?: (toolId: string) => Promise<string | null>;
644
648
  getToolRetryPolicy?: (
645
649
  toolId: string,
646
650
  input: Record<string, unknown>,
@@ -1,3 +1,8 @@
1
+ import {
2
+ validateFixtureBehavior,
3
+ type FixtureBehavior,
4
+ } from './fixture-behavior';
5
+
1
6
  export const RUNTIME_EXECUTION_CAPABILITIES = [
2
7
  'artifact.read',
3
8
  'file.read',
@@ -37,6 +42,7 @@ export type RuntimeAuthorityDescriptor = {
37
42
  allowedSecrets: string[];
38
43
  maxCreditsPerRun?: number | null;
39
44
  integrationMode?: 'live' | 'eval_stub' | 'fixture' | null;
45
+ fixtureBehavior?: FixtureBehavior | null;
40
46
  synthetic?: boolean | null;
41
47
  actorUserId?: string | null;
42
48
  actorEmail?: string | null;
@@ -91,11 +97,15 @@ export function requireRuntimeAuthorityDescriptor(
91
97
  const capabilities = normalizeRuntimeExecutionCapabilities(
92
98
  descriptor.capabilities,
93
99
  );
100
+ const fixtureBehavior = validateFixtureBehavior(descriptor.fixtureBehavior);
94
101
  if (
95
102
  requiredStrings.some(
96
103
  (entry) => typeof entry !== 'string' || entry.trim().length === 0,
97
104
  ) ||
98
- capabilities.length === 0
105
+ capabilities.length === 0 ||
106
+ !fixtureBehavior.ok ||
107
+ (fixtureBehavior.behavior !== null &&
108
+ descriptor.integrationMode !== 'fixture')
99
109
  ) {
100
110
  throw new Error(
101
111
  'Durable play launch contains an invalid runtimeAuthority descriptor.',
@@ -114,6 +124,7 @@ export function requireRuntimeAuthorityDescriptor(
114
124
  : [],
115
125
  maxCreditsPerRun: descriptor.maxCreditsPerRun ?? null,
116
126
  integrationMode: descriptor.integrationMode ?? null,
127
+ fixtureBehavior: fixtureBehavior.ok ? fixtureBehavior.behavior : null,
117
128
  synthetic: descriptor.synthetic === true,
118
129
  actorUserId: descriptor.actorUserId ?? null,
119
130
  actorEmail: descriptor.actorEmail ?? null,