agent-scenario-loop 0.1.1 → 0.1.3

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.
Files changed (69) hide show
  1. package/README.md +15 -9
  2. package/app/profile-session.ts +98 -4
  3. package/dist/core/agent-summary.d.ts +3 -2
  4. package/dist/core/agent-summary.js +44 -2
  5. package/dist/core/artifact-contract.d.ts +22 -4
  6. package/dist/core/artifact-contract.js +512 -11
  7. package/dist/core/comparison.d.ts +57 -3
  8. package/dist/core/comparison.js +113 -1
  9. package/dist/core/planner.d.ts +32 -1
  10. package/dist/core/planner.js +144 -0
  11. package/dist/core/run-index.d.ts +4 -0
  12. package/dist/core/run-index.js +55 -1
  13. package/dist/core/schema-validator.d.ts +1 -0
  14. package/dist/core/schema-validator.js +1 -0
  15. package/dist/runner/compare-latest.d.ts +8 -4
  16. package/dist/runner/compare-latest.js +24 -5
  17. package/dist/runner/example-android-live.d.ts +10 -1
  18. package/dist/runner/example-android-live.js +55 -0
  19. package/dist/runner/example-ios-live.d.ts +10 -1
  20. package/dist/runner/example-ios-live.js +55 -0
  21. package/dist/runner/init-project.d.ts +4 -1
  22. package/dist/runner/init-project.js +26 -4
  23. package/dist/runner/ios-simctl.d.ts +5 -0
  24. package/dist/runner/ios-simctl.js +6 -0
  25. package/dist/runner/live-comparison.d.ts +2 -2
  26. package/dist/runner/live-comparison.js +2 -1
  27. package/dist/runner/live-proof-summary.d.ts +5 -4
  28. package/dist/runner/live-proof-summary.js +12 -2
  29. package/dist/runner/live-proof.d.ts +3 -2
  30. package/dist/runner/live-proof.js +9 -2
  31. package/dist/runner/profile-android.d.ts +5 -0
  32. package/dist/runner/profile-android.js +148 -24
  33. package/dist/runner/profile-ios.d.ts +11 -1
  34. package/dist/runner/profile-ios.js +128 -9
  35. package/dist/runner/profile-mobile.d.ts +8 -0
  36. package/dist/runner/profile-mobile.js +267 -28
  37. package/docs/adapters.md +4 -0
  38. package/docs/api.md +1 -1
  39. package/docs/architecture.md +90 -0
  40. package/docs/authoring.md +7 -1
  41. package/docs/concepts.md +3 -24
  42. package/docs/consumer-rehearsal.md +4 -0
  43. package/docs/contracts.md +30 -100
  44. package/docs/external-adapter-protocol.md +219 -0
  45. package/docs/live-proofs.md +83 -2
  46. package/docs/principles.md +9 -15
  47. package/examples/mobile-app/README.md +12 -0
  48. package/examples/mobile-app/runner-manifests/primary-runner.json +1 -0
  49. package/examples/runners/README.md +1 -0
  50. package/examples/runners/adb-android.json +1 -0
  51. package/examples/runners/agent-device-android.json +1 -0
  52. package/examples/runners/agent-device-ios.json +1 -0
  53. package/examples/runners/argent-android.json +1 -0
  54. package/examples/runners/argent-ios.json +1 -0
  55. package/examples/runners/xcodebuildmcp-ios.json +1 -0
  56. package/package.json +2 -1
  57. package/schemas/causal-run.schema.json +85 -2
  58. package/schemas/comparison.schema.json +130 -2
  59. package/schemas/external-adapter-message.schema.json +693 -0
  60. package/schemas/health.schema.json +72 -0
  61. package/schemas/live-proof-set.schema.json +1 -1
  62. package/schemas/live-proof.schema.json +14 -6
  63. package/schemas/manifest.schema.json +442 -1
  64. package/schemas/runner-capabilities.schema.json +20 -0
  65. package/schemas/scenario.schema.json +16 -0
  66. package/templates/primary-runner.json +1 -0
  67. package/templates/skills/agent-scenario-loop/SKILL.md +93 -0
  68. package/templates/skills/agent-scenario-loop/references/adoption-checklist.md +17 -0
  69. package/templates/skills/agent-scenario-loop/references/artifact-interpretation.md +26 -0
@@ -17,6 +17,7 @@ type LiveProofArtifact = {
17
17
  skipped: number;
18
18
  unchanged: number;
19
19
  worse: number;
20
+ low_confidence: number;
20
21
  };
21
22
  comparisonStatus: string;
22
23
  comparisons: LiveProofComparisonPointer[];
@@ -82,7 +83,7 @@ type LiveProofArtifact = {
82
83
  summary: string;
83
84
  };
84
85
  type LiveProofComparisonCounts = LiveProofArtifact['comparisonCounts'];
85
- type LiveProofMetricStatus = 'better' | 'worse' | 'unchanged' | 'inconclusive';
86
+ type LiveProofMetricStatus = 'better' | 'worse' | 'unchanged' | 'inconclusive' | 'low_confidence';
86
87
  type LiveProofPlatform = LiveProofArtifact['platform'];
87
88
  type LiveProofComparisonPointer = {
88
89
  baselineDir?: string | null;
@@ -102,7 +103,7 @@ type LiveProofComparisonPointer = {
102
103
  status?: string;
103
104
  summaryPath?: string | null;
104
105
  };
105
- type LiveProofAggregateStatus = ('baseline_missing' | 'improved' | 'inconclusive' | 'mixed' | 'not_compared' | 'regressed' | 'unchanged');
106
+ type LiveProofAggregateStatus = ('baseline_missing' | 'improved' | 'inconclusive' | 'low_confidence' | 'mixed' | 'not_compared' | 'regressed' | 'unchanged');
106
107
  type LiveProofNextActionCode = LiveProofArtifact['nextAction']['code'];
107
108
  type LiveProofSetArtifact = {
108
109
  failureReasons: string[];
@@ -153,6 +153,7 @@ function countLiveProofComparisons(comparisons) {
153
153
  const counts = {
154
154
  better: 0,
155
155
  inconclusive: 0,
156
+ low_confidence: 0,
156
157
  mixed: 0,
157
158
  skipped: 0,
158
159
  unchanged: 0,
@@ -183,6 +184,9 @@ function deriveLiveProofComparisonStatus(comparisons) {
183
184
  if (statuses.includes('inconclusive')) {
184
185
  return 'inconclusive';
185
186
  }
187
+ if (statuses.includes('low_confidence')) {
188
+ return 'low_confidence';
189
+ }
186
190
  if (statuses.every((status) => status === 'skipped')) {
187
191
  return 'baseline_missing';
188
192
  }
@@ -217,6 +221,9 @@ function expectedLiveProofNextActionCode(comparisonStatus, status = 'passed') {
217
221
  if (comparisonStatus === 'inconclusive') {
218
222
  return 'inspect_inconclusive';
219
223
  }
224
+ if (comparisonStatus === 'low_confidence') {
225
+ return 'inspect_low_confidence';
226
+ }
220
227
  if (comparisonStatus === 'mixed') {
221
228
  return 'inspect_mixed';
222
229
  }
@@ -439,7 +446,7 @@ function formatComparisonPointerMetrics(comparison) {
439
446
  const highlightText = highlights.length > 0
440
447
  ? `; notable: ${highlights.map(formatMetricHighlight).join(', ')}`
441
448
  : '';
442
- return ` (metrics better=${counts.better} worse=${counts.worse} unchanged=${counts.unchanged} inconclusive=${counts.inconclusive}${highlightText})`;
449
+ return ` (metrics better=${counts.better} worse=${counts.worse} unchanged=${counts.unchanged} inconclusive=${counts.inconclusive} low_confidence=${counts.low_confidence}${highlightText})`;
443
450
  }
444
451
  /**
445
452
  * Formats capture counts for one interaction proof pointer.
@@ -815,7 +822,7 @@ function formatLiveProof(proof) {
815
822
  `Skipped interaction proofs: ${proof.skippedInteractionProofs?.length ?? 0}`,
816
823
  ...(proof.skippedInteractionProofs ?? []).map((proofPointer) => (`- ${proofPointer.label} (${proofPointer.runnerId}/${proofPointer.scenarioId}/${proofPointer.runId}): ${proofPointer.reason} next=${proofPointer.nextAction.code}`)),
817
824
  `Comparisons: ${proof.comparisons.length}`,
818
- `Comparison counts: better=${proof.comparisonCounts.better} worse=${proof.comparisonCounts.worse} unchanged=${proof.comparisonCounts.unchanged} mixed=${proof.comparisonCounts.mixed} inconclusive=${proof.comparisonCounts.inconclusive} skipped=${proof.comparisonCounts.skipped}`,
825
+ `Comparison counts: better=${proof.comparisonCounts.better} worse=${proof.comparisonCounts.worse} unchanged=${proof.comparisonCounts.unchanged} mixed=${proof.comparisonCounts.mixed} inconclusive=${proof.comparisonCounts.inconclusive} low_confidence=${proof.comparisonCounts.low_confidence} skipped=${proof.comparisonCounts.skipped}`,
819
826
  ...proof.comparisons.map((comparison) => (`- ${comparison.label ?? 'comparison'} (${comparison.scenarioId ?? 'unknown-scenario'}/${comparison.runId ?? 'unknown-run'}): ${comparison.status ?? 'unknown'}${formatComparisonPointerMetrics(comparison)}`)),
820
827
  `Next action: ${proof.nextAction.code} - ${proof.nextAction.summary}`,
821
828
  `Summary: ${proof.summary}`,
@@ -8,8 +8,13 @@ type AndroidProfileOptions = {
8
8
  };
9
9
  type AndroidAdbProfileCommand = {
10
10
  command: string;
11
+ commandId?: string;
11
12
  label?: string;
13
+ queueId?: string;
14
+ sequence?: number;
15
+ waitForMilestone?: string;
12
16
  waitMs?: number;
17
+ waitTimeoutMs?: number;
13
18
  };
14
19
  type AndroidAdbDriverStep = import('./android-adb').AndroidAdbDriverStep;
15
20
  /**
@@ -26,6 +26,21 @@ const PROFILE_SESSION_CAPTURE_BOOTSTRAP_MS = 1000;
26
26
  const PROFILE_SESSION_CAPTURE_MAX_MS = 120000;
27
27
  const DEFAULT_ANDROID_PROFILE_SESSION_STORAGE_KEY = 'agent-scenario-loop.profile-session.1';
28
28
  const DEFAULT_ANDROID_PROFILE_COMMAND_STORAGE_KEY = 'agent-scenario-loop.profile-commands.1';
29
+ const MANIFEST_LIFECYCLE_PHASES = new Set([
30
+ 'cold-launch',
31
+ 'warm-launch',
32
+ 'hot-launch',
33
+ 'resume',
34
+ 'foreground',
35
+ 'background',
36
+ 'force-stop',
37
+ 'process-death',
38
+ 'scene-recreation',
39
+ 'activity-recreation',
40
+ 'os-reclaim',
41
+ 'reboot',
42
+ 'relaunch',
43
+ ]);
29
44
  /**
30
45
  * Reads and parses a JSON object from disk.
31
46
  *
@@ -54,6 +69,22 @@ function isEnabled(value) {
54
69
  function readPositiveInteger(value, fallback) {
55
70
  return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
56
71
  }
72
+ /**
73
+ * Resolves the lifecycle phase this runner is prepared to assert.
74
+ *
75
+ * @param {import('./profile-mobile').CliArgs} args
76
+ * @returns {string}
77
+ */
78
+ function resolveManifestLifecyclePhase(args) {
79
+ const lifecyclePhase = readScalarArg(args['lifecycle-phase']);
80
+ if (lifecyclePhase === undefined) {
81
+ return 'cold-launch';
82
+ }
83
+ if (typeof lifecyclePhase !== 'string' || !MANIFEST_LIFECYCLE_PHASES.has(lifecyclePhase)) {
84
+ throw new Error(`Unsupported --lifecycle-phase "${String(lifecyclePhase)}". Expected one of ${Array.from(MANIFEST_LIFECYCLE_PHASES).join(', ')}.`);
85
+ }
86
+ return lifecyclePhase;
87
+ }
57
88
  /**
58
89
  * Reads the number of scenario iterations that can emit app-owned truth events.
59
90
  *
@@ -119,7 +150,7 @@ function resolveAndroidPackageName({ args, config, }) {
119
150
  * @param {{config: Record<string, unknown>, action: 'start' | 'command', scenario: string, runId: string, command?: string}} options
120
151
  * @returns {string}
121
152
  */
122
- function buildProfileSessionUrl({ action, command, config, runId, scenario, }) {
153
+ function buildProfileSessionUrl({ action, command, commandId, config, queueId, runId, scenario, sequence, waitForMilestone, waitTimeoutMs, }) {
123
154
  const scheme = typeof config.app?.profileSessionScheme === 'string'
124
155
  ? config.app.profileSessionScheme
125
156
  : typeof config.app?.scheme === 'string'
@@ -128,6 +159,21 @@ function buildProfileSessionUrl({ action, command, config, runId, scenario, }) {
128
159
  const params = new URLSearchParams({ runId, scenario });
129
160
  if (action === 'command' && command) {
130
161
  params.set('command', command);
162
+ if (commandId) {
163
+ params.set('commandId', commandId);
164
+ }
165
+ if (typeof sequence === 'number') {
166
+ params.set('sequence', String(sequence));
167
+ }
168
+ if (queueId) {
169
+ params.set('queueId', queueId);
170
+ }
171
+ if (waitForMilestone) {
172
+ params.set('waitForMilestone', waitForMilestone);
173
+ }
174
+ if (typeof waitTimeoutMs === 'number') {
175
+ params.set('waitTimeoutMs', String(waitTimeoutMs));
176
+ }
131
177
  }
132
178
  return `${scheme}://profile-session/${action}?${params.toString()}`;
133
179
  }
@@ -138,6 +184,23 @@ function buildProfileSessionUrl({ action, command, config, runId, scenario, }) {
138
184
  * @returns {import('./android-adb').AndroidAsyncStorageWrite[]}
139
185
  */
140
186
  function buildProfileSessionStorageWrites({ commands, commandStorageKey, commandWaitMs, runId, scenario, sessionStorageKey, }) {
187
+ const timestampBase = Date.now();
188
+ const storedCommands = commands.map((profileCommand, index) => {
189
+ const timestamp = timestampBase + index + 1;
190
+ return {
191
+ id: `${timestamp}-${scenario}-${profileCommand.command}`,
192
+ scenario,
193
+ runId,
194
+ command: profileCommand.command,
195
+ ...(typeof profileCommand.commandId === 'string' ? { commandId: profileCommand.commandId } : {}),
196
+ ...(typeof profileCommand.sequence === 'number' ? { sequence: profileCommand.sequence } : {}),
197
+ ...(typeof profileCommand.queueId === 'string' ? { queueId: profileCommand.queueId } : {}),
198
+ ...(typeof profileCommand.waitForMilestone === 'string' ? { waitForMilestone: profileCommand.waitForMilestone } : {}),
199
+ ...(typeof profileCommand.waitTimeoutMs === 'number' ? { waitTimeoutMs: profileCommand.waitTimeoutMs } : {}),
200
+ timestamp,
201
+ };
202
+ });
203
+ const commandWaitMsTotal = commands.reduce((total, profileCommand) => (total + (typeof profileCommand.waitMs === 'number' && profileCommand.waitMs > 0 ? profileCommand.waitMs : 0)), 0);
141
204
  return [
142
205
  {
143
206
  clearKeys: [commandStorageKey],
@@ -151,21 +214,14 @@ function buildProfileSessionStorageWrites({ commands, commandStorageKey, command
151
214
  }).replace(`"${ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER}"`, ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER),
152
215
  waitMs: commandWaitMs,
153
216
  },
154
- ...commands.map((profileCommand, index) => {
155
- const timestamp = Date.now() + index + 1;
156
- return {
157
- key: commandStorageKey,
158
- label: profileCommand.label ?? `profile-command-${index + 1}`,
159
- value: JSON.stringify([{
160
- id: `${timestamp}-${scenario}-${profileCommand.command}`,
161
- scenario,
162
- runId,
163
- command: profileCommand.command,
164
- timestamp,
165
- }]),
166
- ...(typeof profileCommand.waitMs === 'number' ? { waitMs: profileCommand.waitMs } : {}),
167
- };
168
- }),
217
+ ...(storedCommands.length > 0
218
+ ? [{
219
+ key: commandStorageKey,
220
+ label: 'profile-command-queue',
221
+ value: JSON.stringify(storedCommands),
222
+ ...(commandWaitMsTotal > 0 ? { waitMs: commandWaitMsTotal } : {}),
223
+ }]
224
+ : []),
169
225
  ];
170
226
  }
171
227
  /**
@@ -271,14 +327,30 @@ function appendCaptureArg({ args, value, }) {
271
327
  function resolveExecutionPlanProfileCommands(scenario) {
272
328
  const executionPlan = buildScenarioExecutionPlan(scenario);
273
329
  const repeat = readPositiveInteger(scenario.defaultIterations, readPositiveInteger(scenario.cycles?.iterations, 1));
274
- const commands = executionPlan.steps
275
- .filter((step) => step.portMethod === 'executeStep' && typeof step.command === 'string')
276
- .map((step) => ({
277
- command: step.command,
278
- label: step.id,
279
- waitMs: readStepWaitMs(step),
280
- }));
281
- return Array.from({ length: repeat }).flatMap(() => commands);
330
+ const commands = [];
331
+ for (const [index, step] of executionPlan.steps.entries()) {
332
+ if (step.portMethod !== 'executeStep' || typeof step.command !== 'string') {
333
+ continue;
334
+ }
335
+ const nextStep = executionPlan.steps[index + 1];
336
+ commands.push({
337
+ command: step.command,
338
+ commandId: step.id,
339
+ label: step.id,
340
+ queueId: scenario.id ?? scenario.name,
341
+ waitMs: readStepWaitMs(step),
342
+ ...(nextStep?.portMethod === 'waitForTruthEvent' && typeof nextStep.milestone === 'string'
343
+ ? {
344
+ waitForMilestone: nextStep.milestone,
345
+ waitTimeoutMs: readPositiveInteger(nextStep.timeoutMs, 0),
346
+ }
347
+ : {}),
348
+ });
349
+ }
350
+ return Array.from({ length: repeat }).flatMap((_, iteration) => commands.map((command, commandIndex) => ({
351
+ ...command,
352
+ sequence: (iteration * commands.length) + commandIndex + 1,
353
+ })));
282
354
  }
283
355
  /**
284
356
  * Expands normalized scenario evidence steps into Android adb driver actions.
@@ -392,7 +464,16 @@ function resolveAndroidAdbProfileCommands(scenario) {
392
464
  }
393
465
  commands.push({
394
466
  command: command.command,
467
+ commandId: typeof command.id === 'string'
468
+ ? command.id
469
+ : typeof command.commandId === 'string'
470
+ ? command.commandId
471
+ : typeof command.label === 'string'
472
+ ? command.label
473
+ : command.command,
395
474
  ...(typeof command.label === 'string' ? { label: command.label } : {}),
475
+ queueId: scenario.id ?? scenario.name,
476
+ sequence: commands.length + 1,
396
477
  waitMs: readPositiveInteger(command.waitMs, 0),
397
478
  });
398
479
  }
@@ -459,6 +540,7 @@ function appendAgentDeviceCaptureArgs({ args, capture, }) {
459
540
  async function runProfileAndroid(args, options = {}) {
460
541
  if (!isEnabled(args['adb-capture']) && !isEnabled(args['agent-device-capture'])) {
461
542
  return runProfileMobile(args, {
543
+ commandTransport: typeof args.events === 'string' ? 'fixture-log-ingest' : 'adb-artifacts',
462
544
  ...(options.comparisonLane ? { comparisonLane: options.comparisonLane } : {}),
463
545
  defaultDriver: 'adb-logcat',
464
546
  ...(typeof args['adb-artifacts'] === 'string' ? { interactionDriver: 'adb-logcat' } : {}),
@@ -530,9 +612,14 @@ async function runProfileAndroid(args, options = {}) {
530
612
  url: buildProfileSessionUrl({
531
613
  action: 'command',
532
614
  command: profileCommand.command,
615
+ ...(typeof profileCommand.commandId === 'string' ? { commandId: profileCommand.commandId } : {}),
533
616
  config,
534
617
  runId,
535
618
  scenario: scenarioName,
619
+ ...(typeof profileCommand.queueId === 'string' ? { queueId: profileCommand.queueId } : {}),
620
+ ...(typeof profileCommand.sequence === 'number' ? { sequence: profileCommand.sequence } : {}),
621
+ ...(typeof profileCommand.waitForMilestone === 'string' ? { waitForMilestone: profileCommand.waitForMilestone } : {}),
622
+ ...(typeof profileCommand.waitTimeoutMs === 'number' ? { waitTimeoutMs: profileCommand.waitTimeoutMs } : {}),
536
623
  }),
537
624
  waitMs: profileCommand.waitMs,
538
625
  })),
@@ -635,9 +722,46 @@ async function runProfileAndroid(args, options = {}) {
635
722
  const profileArgs = agentDeviceCapture
636
723
  ? appendAgentDeviceCaptureArgs({ args: baseProfileArgs, capture: agentDeviceCapture })
637
724
  : baseProfileArgs;
725
+ const lifecyclePhase = resolveManifestLifecyclePhase(args);
726
+ const environmentSource = agentDeviceCapture ? 'agent-device' : 'adb';
727
+ const lifecycleArtifact = adbCapture ? 'raw/adb-logcat.txt' : 'raw/interaction.log';
638
728
  return runProfileMobile(profileArgs, {
729
+ commandTransport: profileSessionStorageEnabled
730
+ ? 'profile-session-storage'
731
+ : profileSessionEnabled
732
+ ? 'profile-session-deeplink'
733
+ : agentDeviceCapture
734
+ ? 'agent-device'
735
+ : 'adb-capture',
639
736
  ...(options.comparisonLane ? { comparisonLane: options.comparisonLane } : {}),
640
737
  defaultDriver: 'adb-logcat',
738
+ environmentPostconditions: {
739
+ appState: {
740
+ value: 'foreground',
741
+ evidence: 'asserted',
742
+ source: environmentSource,
743
+ artifact: lifecycleArtifact,
744
+ },
745
+ lifecyclePhase: {
746
+ value: 'foreground',
747
+ evidence: 'asserted',
748
+ source: environmentSource,
749
+ artifact: lifecycleArtifact,
750
+ },
751
+ },
752
+ environmentPreconditions: {
753
+ foregroundState: {
754
+ value: 'controlled-by-runner',
755
+ evidence: 'asserted',
756
+ source: environmentSource,
757
+ },
758
+ lifecyclePhase: {
759
+ value: lifecyclePhase,
760
+ evidence: 'asserted',
761
+ source: environmentSource,
762
+ artifact: lifecycleArtifact,
763
+ },
764
+ },
641
765
  interactionDriver: agentDeviceCapture ? 'agent-device' : 'adb-logcat',
642
766
  platform: 'android',
643
767
  });
@@ -8,8 +8,13 @@ type IosProfileOptions = {
8
8
  };
9
9
  type IosSimctlProfileCommand = {
10
10
  command: string;
11
+ commandId?: string;
11
12
  label?: string;
13
+ queueId?: string;
14
+ sequence?: number;
15
+ waitForMilestone?: string;
12
16
  waitMs?: number;
17
+ waitTimeoutMs?: number;
13
18
  };
14
19
  /**
15
20
  * Resolves the simctl capture output directory for a profile run.
@@ -44,12 +49,17 @@ declare function resolveIosConflictingBundleIds(config: Record<string, any>): st
44
49
  * @param {{config: Record<string, unknown>, action: 'start' | 'command', scenario: string, runId: string, command?: string}} options
45
50
  * @returns {string}
46
51
  */
47
- declare function buildProfileSessionUrl({ action, command, config, runId, scenario, }: {
52
+ declare function buildProfileSessionUrl({ action, command, commandId, config, queueId, runId, scenario, sequence, waitForMilestone, waitTimeoutMs, }: {
48
53
  action: 'start' | 'command';
49
54
  command?: string;
55
+ commandId?: string;
50
56
  config: Record<string, any>;
57
+ queueId?: string;
51
58
  runId: string;
52
59
  scenario: string;
60
+ sequence?: number;
61
+ waitForMilestone?: string;
62
+ waitTimeoutMs?: number;
53
63
  }): string;
54
64
  /**
55
65
  * Derives a storage-backed profile capture window from scenario waits and cycles.
@@ -34,6 +34,21 @@ const DEFAULT_IOS_PROFILE_COMMAND_STORAGE_KEY = 'agent-scenario-loop.profile-com
34
34
  const DEFAULT_IOS_PROFILE_EVENT_STORAGE_KEY = 'agent-scenario-loop.profile-events.1';
35
35
  const DEFAULT_IOS_PROFILE_SIGNAL_STORAGE_KEY = 'agent-scenario-loop.profile-signals.1';
36
36
  const DEFAULT_IOS_PROFILE_SESSION_ENTRIES_STORAGE_KEY = 'agent-scenario-loop.profile-session-entries.1';
37
+ const MANIFEST_LIFECYCLE_PHASES = new Set([
38
+ 'cold-launch',
39
+ 'warm-launch',
40
+ 'hot-launch',
41
+ 'resume',
42
+ 'foreground',
43
+ 'background',
44
+ 'force-stop',
45
+ 'process-death',
46
+ 'scene-recreation',
47
+ 'activity-recreation',
48
+ 'os-reclaim',
49
+ 'reboot',
50
+ 'relaunch',
51
+ ]);
37
52
  /**
38
53
  * Reads and parses a JSON object from disk.
39
54
  *
@@ -63,6 +78,22 @@ function readPositiveInteger(value, fallback) {
63
78
  const parsed = typeof value === 'string' ? Number(value) : value;
64
79
  return typeof parsed === 'number' && Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
65
80
  }
81
+ /**
82
+ * Resolves the lifecycle phase this runner is prepared to assert.
83
+ *
84
+ * @param {import('./profile-mobile').CliArgs} args
85
+ * @returns {string}
86
+ */
87
+ function resolveManifestLifecyclePhase(args) {
88
+ const lifecyclePhase = readScalarArg(args['lifecycle-phase']);
89
+ if (lifecyclePhase === undefined) {
90
+ return 'cold-launch';
91
+ }
92
+ if (typeof lifecyclePhase !== 'string' || !MANIFEST_LIFECYCLE_PHASES.has(lifecyclePhase)) {
93
+ throw new Error(`Unsupported --lifecycle-phase "${String(lifecyclePhase)}". Expected one of ${Array.from(MANIFEST_LIFECYCLE_PHASES).join(', ')}.`);
94
+ }
95
+ return lifecyclePhase;
96
+ }
66
97
  /**
67
98
  * Reads the number of scenario iterations that can emit app-owned truth events.
68
99
  *
@@ -140,7 +171,7 @@ function resolveIosConflictingBundleIds(config) {
140
171
  * @param {{config: Record<string, unknown>, action: 'start' | 'command', scenario: string, runId: string, command?: string}} options
141
172
  * @returns {string}
142
173
  */
143
- function buildProfileSessionUrl({ action, command, config, runId, scenario, }) {
174
+ function buildProfileSessionUrl({ action, command, commandId, config, queueId, runId, scenario, sequence, waitForMilestone, waitTimeoutMs, }) {
144
175
  const scheme = typeof config.app?.profileSessionScheme === 'string'
145
176
  ? config.app.profileSessionScheme
146
177
  : typeof config.app?.scheme === 'string'
@@ -149,6 +180,21 @@ function buildProfileSessionUrl({ action, command, config, runId, scenario, }) {
149
180
  const params = new URLSearchParams({ runId, scenario });
150
181
  if (action === 'command' && command) {
151
182
  params.set('command', command);
183
+ if (commandId) {
184
+ params.set('commandId', commandId);
185
+ }
186
+ if (typeof sequence === 'number') {
187
+ params.set('sequence', String(sequence));
188
+ }
189
+ if (queueId) {
190
+ params.set('queueId', queueId);
191
+ }
192
+ if (waitForMilestone) {
193
+ params.set('waitForMilestone', waitForMilestone);
194
+ }
195
+ if (typeof waitTimeoutMs === 'number') {
196
+ params.set('waitTimeoutMs', String(waitTimeoutMs));
197
+ }
152
198
  }
153
199
  return `${scheme}://profile-session/${action}?${params.toString()}`;
154
200
  }
@@ -211,14 +257,30 @@ function resolveProfileSessionCaptureWaitMs({ args, profileSessionEnabled, scena
211
257
  function resolveExecutionPlanProfileCommands(scenario) {
212
258
  const executionPlan = buildScenarioExecutionPlan(scenario);
213
259
  const repeat = readPositiveInteger(scenario.defaultIterations, readPositiveInteger(scenario.cycles?.iterations, 1));
214
- const commands = executionPlan.steps
215
- .filter((step) => step.portMethod === 'executeStep' && typeof step.command === 'string')
216
- .map((step) => ({
217
- command: step.command,
218
- label: step.id,
219
- waitMs: readStepWaitMs(step),
220
- }));
221
- return Array.from({ length: repeat }).flatMap(() => commands);
260
+ const commands = [];
261
+ for (const [index, step] of executionPlan.steps.entries()) {
262
+ if (step.portMethod !== 'executeStep' || typeof step.command !== 'string') {
263
+ continue;
264
+ }
265
+ const nextStep = executionPlan.steps[index + 1];
266
+ commands.push({
267
+ command: step.command,
268
+ commandId: step.id,
269
+ label: step.id,
270
+ queueId: scenario.id ?? scenario.name,
271
+ waitMs: readStepWaitMs(step),
272
+ ...(nextStep?.portMethod === 'waitForTruthEvent' && typeof nextStep.milestone === 'string'
273
+ ? {
274
+ waitForMilestone: nextStep.milestone,
275
+ waitTimeoutMs: readPositiveInteger(nextStep.timeoutMs, 0),
276
+ }
277
+ : {}),
278
+ });
279
+ }
280
+ return Array.from({ length: repeat }).flatMap((_, iteration) => commands.map((command, commandIndex) => ({
281
+ ...command,
282
+ sequence: (iteration * commands.length) + commandIndex + 1,
283
+ })));
222
284
  }
223
285
  /**
224
286
  * Expands scenario-declared iOS commands for a simctl capture profile session.
@@ -240,7 +302,16 @@ function resolveIosSimctlProfileCommands(scenario) {
240
302
  }
241
303
  commands.push({
242
304
  command: command.command,
305
+ commandId: typeof command.id === 'string'
306
+ ? command.id
307
+ : typeof command.commandId === 'string'
308
+ ? command.commandId
309
+ : typeof command.label === 'string'
310
+ ? command.label
311
+ : command.command,
243
312
  ...(typeof command.label === 'string' ? { label: command.label } : {}),
313
+ queueId: scenario.id ?? scenario.name,
314
+ sequence: commands.length + 1,
244
315
  waitMs: readPositiveInteger(command.waitMs, 0),
245
316
  });
246
317
  }
@@ -327,6 +398,7 @@ function appendAgentDeviceCaptureArgs({ args, capture, }) {
327
398
  async function runProfileIos(args, options = {}) {
328
399
  if (!isEnabled(args['simctl-capture']) && !isEnabled(args['agent-device-capture'])) {
329
400
  return runProfileMobile(args, {
401
+ commandTransport: typeof args.events === 'string' ? 'fixture-log-ingest' : 'simctl-artifacts',
330
402
  ...(options.comparisonLane ? { comparisonLane: options.comparisonLane } : {}),
331
403
  defaultDriver: 'ios-simctl',
332
404
  ...(typeof args['simctl-artifacts'] === 'string' ? { interactionDriver: 'ios-simctl' } : {}),
@@ -397,9 +469,14 @@ async function runProfileIos(args, options = {}) {
397
469
  url: buildProfileSessionUrl({
398
470
  action: 'command',
399
471
  command: profileCommand.command,
472
+ ...(typeof profileCommand.commandId === 'string' ? { commandId: profileCommand.commandId } : {}),
400
473
  config,
401
474
  runId,
402
475
  scenario: scenarioName,
476
+ ...(typeof profileCommand.queueId === 'string' ? { queueId: profileCommand.queueId } : {}),
477
+ ...(typeof profileCommand.sequence === 'number' ? { sequence: profileCommand.sequence } : {}),
478
+ ...(typeof profileCommand.waitForMilestone === 'string' ? { waitForMilestone: profileCommand.waitForMilestone } : {}),
479
+ ...(typeof profileCommand.waitTimeoutMs === 'number' ? { waitTimeoutMs: profileCommand.waitTimeoutMs } : {}),
403
480
  }),
404
481
  waitMs: profileCommand.waitMs,
405
482
  })),
@@ -431,8 +508,13 @@ async function runProfileIos(args, options = {}) {
431
508
  profileSessionStorage: {
432
509
  commands: profileSessionCommands.map((profileCommand, index) => ({
433
510
  command: profileCommand.command,
511
+ ...(typeof profileCommand.commandId === 'string' ? { commandId: profileCommand.commandId } : {}),
434
512
  id: `ios-storage-command-${index + 1}`,
435
513
  ...(typeof profileCommand.label === 'string' ? { label: profileCommand.label } : {}),
514
+ ...(typeof profileCommand.queueId === 'string' ? { queueId: profileCommand.queueId } : {}),
515
+ ...(typeof profileCommand.sequence === 'number' ? { sequence: profileCommand.sequence } : {}),
516
+ ...(typeof profileCommand.waitForMilestone === 'string' ? { waitForMilestone: profileCommand.waitForMilestone } : {}),
517
+ ...(typeof profileCommand.waitTimeoutMs === 'number' ? { waitTimeoutMs: profileCommand.waitTimeoutMs } : {}),
436
518
  })),
437
519
  runId,
438
520
  scenario: scenarioName,
@@ -496,9 +578,46 @@ async function runProfileIos(args, options = {}) {
496
578
  const profileArgs = agentDeviceCapture
497
579
  ? appendAgentDeviceCaptureArgs({ args: baseProfileArgs, capture: agentDeviceCapture })
498
580
  : baseProfileArgs;
581
+ const lifecyclePhase = resolveManifestLifecyclePhase(args);
582
+ const environmentSource = agentDeviceCapture ? 'agent-device' : 'simctl';
583
+ const lifecycleArtifact = simctlCapture ? 'raw/ios-simctl-log.txt' : 'raw/interaction.log';
499
584
  return runProfileMobile(profileArgs, {
585
+ commandTransport: agentDeviceCapture
586
+ ? 'agent-device'
587
+ : profileSessionEnabled && !profileSessionStorageEnabled
588
+ ? 'profile-session-deeplink'
589
+ : profileSessionEnabled
590
+ ? 'profile-session-storage'
591
+ : 'simctl-capture',
500
592
  ...(options.comparisonLane ? { comparisonLane: options.comparisonLane } : {}),
501
593
  defaultDriver: 'ios-simctl',
594
+ environmentPostconditions: {
595
+ appState: {
596
+ value: 'foreground',
597
+ evidence: 'asserted',
598
+ source: environmentSource,
599
+ artifact: lifecycleArtifact,
600
+ },
601
+ lifecyclePhase: {
602
+ value: 'foreground',
603
+ evidence: 'asserted',
604
+ source: environmentSource,
605
+ artifact: lifecycleArtifact,
606
+ },
607
+ },
608
+ environmentPreconditions: {
609
+ foregroundState: {
610
+ value: 'controlled-by-runner',
611
+ evidence: 'asserted',
612
+ source: environmentSource,
613
+ },
614
+ lifecyclePhase: {
615
+ value: lifecyclePhase,
616
+ evidence: 'asserted',
617
+ source: environmentSource,
618
+ artifact: lifecycleArtifact,
619
+ },
620
+ },
502
621
  interactionDriver: agentDeviceCapture ? 'agent-device' : 'ios-simctl',
503
622
  platform: 'ios',
504
623
  });
@@ -21,10 +21,14 @@ type ProfileRunResult = {
21
21
  };
22
22
  type ProfilePlatform = 'android' | 'ios';
23
23
  type ProfileMobileOptions = {
24
+ commandTransport?: string;
24
25
  comparisonLane?: string;
25
26
  defaultDriver: string;
27
+ environmentPostconditions?: Record<string, unknown>;
28
+ environmentPreconditions?: Record<string, unknown>;
26
29
  interactionDriver?: string;
27
30
  platform: ProfilePlatform;
31
+ provenanceCohort?: Record<string, unknown>;
28
32
  };
29
33
  type CaptureEvidenceKind = 'screenshot' | 'uiTree' | 'video';
30
34
  type ProviderEvidenceKind = 'accessibility' | 'logs' | 'profiler';
@@ -33,13 +37,17 @@ type EvidenceChannel = 'capture' | 'provider' | 'signal';
33
37
  type EvidenceKind = CaptureEvidenceKind | ProviderEvidenceKind | SignalEvidenceKind;
34
38
  type EvidenceAttachment = {
35
39
  channel: EvidenceChannel;
40
+ completenessStatus: 'complete';
41
+ corruptionStatus: 'valid';
36
42
  destinationPath: string;
37
43
  kind: EvidenceKind;
38
44
  manifestPath: string;
45
+ redactionStatus: 'not-redacted';
39
46
  sha256: string;
40
47
  sourcePath: string;
41
48
  sourceFileName: string;
42
49
  sizeBytes: number;
50
+ transformations: readonly ['copied'];
43
51
  };
44
52
  type EvidenceAttachmentInput = {
45
53
  channel: EvidenceChannel;