agent-scenario-loop 0.1.3 → 0.1.4

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 (47) hide show
  1. package/app/profile-session.ts +263 -17
  2. package/dist/core/artifact-contract.d.ts +6 -4
  3. package/dist/core/artifact-contract.js +164 -15
  4. package/dist/core/schema-validator.d.ts +1 -0
  5. package/dist/core/schema-validator.js +1 -0
  6. package/dist/runner/android-adb-driver.d.ts +7 -2
  7. package/dist/runner/android-adb-driver.js +7 -1
  8. package/dist/runner/android-adb.d.ts +40 -5
  9. package/dist/runner/android-adb.js +1046 -664
  10. package/dist/runner/ios-simctl.d.ts +1 -0
  11. package/dist/runner/ios-simctl.js +1 -0
  12. package/dist/runner/profile-android.d.ts +11 -1
  13. package/dist/runner/profile-android.js +230 -16
  14. package/dist/runner/profile-ios.d.ts +3 -2
  15. package/dist/runner/profile-ios.js +223 -20
  16. package/dist/runner/profile-mobile.d.ts +31 -3
  17. package/dist/runner/profile-mobile.js +793 -20
  18. package/dist/runner/validate-project.js +3 -0
  19. package/dist/scripts/consumer-rehearsal.d.ts +119 -0
  20. package/dist/scripts/consumer-rehearsal.js +757 -0
  21. package/dist/scripts/downstream-local-package-gate.d.ts +2 -0
  22. package/dist/scripts/downstream-local-package-gate.js +264 -0
  23. package/dist/scripts/package-smoke.d.ts +96 -0
  24. package/dist/scripts/package-smoke.js +2282 -0
  25. package/dist/scripts/release-readiness.d.ts +2 -0
  26. package/dist/scripts/release-readiness.js +520 -0
  27. package/docs/adapters.md +3 -1
  28. package/docs/api.md +2 -2
  29. package/docs/authoring.md +34 -2
  30. package/docs/consumer-rehearsal.md +27 -1
  31. package/docs/contracts.md +16 -2
  32. package/docs/live-proofs.md +5 -3
  33. package/examples/mobile-app/runner-manifests/evidence-provider.json +3 -3
  34. package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +25 -0
  35. package/examples/runners/README.md +3 -3
  36. package/examples/runners/axe-accessibility-provider.json +2 -2
  37. package/examples/runners/script-accessibility-provider.json +2 -2
  38. package/examples/runners/script-memory-provider.json +2 -2
  39. package/examples/runners/script-network-provider.json +2 -2
  40. package/examples/runners/script-profiler-provider.json +2 -2
  41. package/package.json +11 -3
  42. package/schemas/manifest.schema.json +73 -3
  43. package/schemas/profiler.schema.json +243 -0
  44. package/schemas/runner-capabilities.schema.json +8 -2
  45. package/schemas/scenario.schema.json +18 -2
  46. package/templates/evidence-provider.json +3 -3
  47. package/templates/scripts/asl-capture-profiler-provider.mjs +20 -0
@@ -49,7 +49,7 @@ function usage({ binaryName, output = process.stderr, platform, }) {
49
49
  ];
50
50
  if (platform === 'android') {
51
51
  lines.push('Use --adb-artifacts <dir> to read raw/adb-logcat.txt from a prior asl-android-adb capture.');
52
- lines.push('Use --adb-capture [--clear-logcat] [--launch] [--launch-wait-ms <ms>] [--wait-ms <ms>] to capture adb logcat before profiling.');
52
+ lines.push('Use --adb-capture [--clear-logcat] [--launch] [--launch-wait-ms <ms>] [--wait-ms <ms>] [--adb-command-timeout-ms <ms>] to capture adb logcat before profiling.');
53
53
  lines.push('Use --android-dev-client-url <url> [--android-dev-client-wait-ms <ms>] [--android-dev-client-ready-pattern <pattern>] with --adb-capture to open an Expo dev-client session before profile-session deep links.');
54
54
  lines.push('Use --android-profile-session-storage with --profile-session to seed startup control through Android AsyncStorage.');
55
55
  lines.push('Use --profile-session with --adb-capture to start the app profile session and execute scenario-declared Android commands.');
@@ -251,6 +251,8 @@ function buildProviderEvidenceInput({ layout, output, providerId, sourcePath, })
251
251
  destinationPath: path.join(layout.signals[kind], fileName),
252
252
  kind,
253
253
  manifestPath: `signals/${kind}/${fileName}`,
254
+ providerId,
255
+ ...(typeof output.required === 'boolean' ? { required: output.required } : {}),
254
256
  sourcePath,
255
257
  };
256
258
  }
@@ -263,6 +265,8 @@ function buildProviderEvidenceInput({ layout, output, providerId, sourcePath, })
263
265
  destinationPath: path.join(layout.captures, fileName),
264
266
  kind: output.kind,
265
267
  manifestPath: `captures/${fileName}`,
268
+ providerId,
269
+ ...(typeof output.required === 'boolean' ? { required: output.required } : {}),
266
270
  sourcePath,
267
271
  };
268
272
  }
@@ -274,9 +278,33 @@ function buildProviderEvidenceInput({ layout, output, providerId, sourcePath, })
274
278
  destinationPath: path.join(layout.raw, 'providers', providerId, fileName),
275
279
  kind: output.kind,
276
280
  manifestPath: `raw/providers/${providerId}/${fileName}`,
281
+ providerId,
282
+ ...(typeof output.required === 'boolean' ? { required: output.required } : {}),
277
283
  sourcePath,
278
284
  };
279
285
  }
286
+ /**
287
+ * Validates structured profiler evidence when a provider emits JSON.
288
+ *
289
+ * Native traces and flamegraph files may be attached as profiler evidence, but
290
+ * JSON profiler files must carry enough envelope metadata for agents to reason
291
+ * about source, target, and completeness.
292
+ *
293
+ * @param {{kind: EvidenceKind, sourcePath: string}} options
294
+ * @returns {void}
295
+ */
296
+ function validateStructuredProfilerEvidence({ kind, sourcePath, }) {
297
+ if (kind !== 'profiler' || path.extname(sourcePath).toLowerCase() !== '.json') {
298
+ return;
299
+ }
300
+ try {
301
+ assertValidJson(readJson(sourcePath), SCHEMAS.profiler, 'Profiler evidence artifact');
302
+ }
303
+ catch (error) {
304
+ const detail = error instanceof Error ? error.message : String(error);
305
+ throw new Error(`Profiler evidence artifact is invalid: ${sourcePath}. ${detail}`);
306
+ }
307
+ }
280
308
  /**
281
309
  * Fails when provider command ids would collide in raw command records.
282
310
  *
@@ -442,7 +470,7 @@ async function resolveAttachedEvidence({ args, layout, providerInputs = [], }) {
442
470
  },
443
471
  };
444
472
  const destinationPaths = new Set();
445
- const addCopy = async ({ channel, destinationPath, kind, manifestPath, sourcePath, }) => {
473
+ const addCopy = async ({ channel, destinationPath, kind, manifestPath, required = false, sourcePath, }) => {
446
474
  const stat = await fsp.stat(sourcePath).catch(() => null);
447
475
  if (!stat?.isFile()) {
448
476
  throw new Error(`Evidence artifact does not exist or is not a file: ${sourcePath}`);
@@ -450,6 +478,7 @@ async function resolveAttachedEvidence({ args, layout, providerInputs = [], }) {
450
478
  if (destinationPaths.has(destinationPath)) {
451
479
  throw new Error(`Duplicate evidence artifact destination: ${manifestPath}`);
452
480
  }
481
+ validateStructuredProfilerEvidence({ kind, sourcePath });
453
482
  destinationPaths.add(destinationPath);
454
483
  const attachment = {
455
484
  channel,
@@ -459,6 +488,7 @@ async function resolveAttachedEvidence({ args, layout, providerInputs = [], }) {
459
488
  kind,
460
489
  manifestPath,
461
490
  redactionStatus: 'not-redacted',
491
+ required,
462
492
  sha256: await hashFileSha256(sourcePath),
463
493
  sourceFileName: path.basename(sourcePath),
464
494
  sourcePath,
@@ -578,20 +608,447 @@ function toPortablePathReference(targetPath) {
578
608
  }
579
609
  return path.basename(targetPath);
580
610
  }
611
+ /**
612
+ * Returns a path reference from one run folder to an external sidecar.
613
+ *
614
+ * @param {{runDir: string, targetPath: string}} options
615
+ * @returns {string}
616
+ */
617
+ function toRunPathReference({ runDir, targetPath }) {
618
+ const relativePath = path.relative(runDir, targetPath);
619
+ return relativePath.length > 0 ? relativePath : path.basename(targetPath);
620
+ }
621
+ /**
622
+ * Returns a sidecar dependency path that stays readable in rehydrated artifacts.
623
+ *
624
+ * @param {{runDir: string, sidecarRoot: string, targetPath: string}} options
625
+ * @returns {SidecarEvidenceDependency}
626
+ */
627
+ function toSidecarEvidenceDependency({ runDir, sidecarRoot, targetPath, }) {
628
+ const sidecarRelativePath = path.relative(sidecarRoot, targetPath);
629
+ if (sidecarRelativePath.length > 0 &&
630
+ !sidecarRelativePath.startsWith('..') &&
631
+ !path.isAbsolute(sidecarRelativePath)) {
632
+ return {
633
+ kind: 'sidecar',
634
+ root: 'sidecar',
635
+ path: sidecarRelativePath,
636
+ };
637
+ }
638
+ return {
639
+ kind: 'sidecar',
640
+ path: toRunPathReference({ runDir, targetPath }),
641
+ };
642
+ }
643
+ /**
644
+ * Reads scenario string-list declarations into a set.
645
+ *
646
+ * @param {Record<string, unknown>} scenario
647
+ * @param {string[]} pathSegments
648
+ * @returns {Set<string>}
649
+ */
650
+ function readScenarioStringSet(scenario, pathSegments) {
651
+ const values = pathSegments.reduce((current, segment) => (current && typeof current === 'object' && !Array.isArray(current)
652
+ ? current[segment]
653
+ : undefined), scenario);
654
+ return new Set(Array.isArray(values)
655
+ ? values.filter((value) => typeof value === 'string')
656
+ : []);
657
+ }
658
+ /**
659
+ * Returns true when a scenario artifact declaration matches a diagnostic kind.
660
+ *
661
+ * @param {Set<string>} artifacts
662
+ * @param {string[]} aliases
663
+ * @returns {boolean}
664
+ */
665
+ function artifactSetHasAny(artifacts, aliases) {
666
+ return aliases.some((alias) => artifacts.has(alias));
667
+ }
668
+ /**
669
+ * Resolves common aliases used by scenario artifact contracts.
670
+ *
671
+ * @param {DiagnosticKind} kind
672
+ * @returns {string[]}
673
+ */
674
+ function diagnosticArtifactAliases(kind) {
675
+ const aliases = {
676
+ accessibility: ['accessibility'],
677
+ js: ['js', 'profileEvents', 'profileSession'],
678
+ logs: ['logs', 'deviceLog', 'interactionLog'],
679
+ memory: ['memory'],
680
+ network: ['network'],
681
+ profiler: ['profiler', 'profile'],
682
+ screenshot: ['screenshot', 'screenshots'],
683
+ uiTree: ['uiTree', 'ui-tree', 'accessibilityTree'],
684
+ video: ['video', 'recording'],
685
+ };
686
+ return aliases[kind];
687
+ }
688
+ /**
689
+ * Resolves common aliases used by runner capability declarations.
690
+ *
691
+ * @param {DiagnosticKind} kind
692
+ * @returns {string[]}
693
+ */
694
+ function diagnosticCapabilityAliases(kind) {
695
+ const aliases = {
696
+ accessibility: ['accessibility', 'accessibilityCapture'],
697
+ js: ['js', 'profileSession', 'profileEvents'],
698
+ logs: ['logCapture', 'logs', 'deviceLog'],
699
+ memory: ['memory', 'memoryCapture'],
700
+ network: ['network', 'networkCapture'],
701
+ profiler: ['profiler', 'profile', 'profilerCapture'],
702
+ screenshot: ['screenshot', 'screenshots'],
703
+ uiTree: ['uiTree', 'ui-tree', 'accessibilityTree'],
704
+ video: ['video', 'recording'],
705
+ };
706
+ return aliases[kind];
707
+ }
708
+ /**
709
+ * Returns requirement/request metadata for one diagnostic kind.
710
+ *
711
+ * @param {{kind: DiagnosticKind, optionalArtifacts: Set<string>, optionalCapabilities: Set<string>, requiredArtifacts: Set<string>, requiredCapabilities: Set<string>}} options
712
+ * @returns {{required: boolean, requested: boolean}}
713
+ */
714
+ function resolveDiagnosticRequest({ kind, optionalArtifacts, optionalCapabilities, requiredArtifacts, requiredCapabilities, }) {
715
+ const artifactAliases = diagnosticArtifactAliases(kind);
716
+ const capabilityAliases = diagnosticCapabilityAliases(kind);
717
+ const required = artifactSetHasAny(requiredArtifacts, artifactAliases) ||
718
+ artifactSetHasAny(requiredCapabilities, capabilityAliases);
719
+ return {
720
+ required,
721
+ requested: required ||
722
+ artifactSetHasAny(optionalArtifacts, artifactAliases) ||
723
+ artifactSetHasAny(optionalCapabilities, capabilityAliases),
724
+ };
725
+ }
726
+ /**
727
+ * Builds a status entry for one diagnostic surface.
728
+ *
729
+ * @param {DiagnosticInventoryEntry & {requested?: boolean}} entry
730
+ * @returns {DiagnosticInventoryEntry}
731
+ */
732
+ function buildDiagnosticEntry(entry) {
733
+ const { requested = true, ...diagnostic } = entry;
734
+ if (diagnostic.status === 'captured' || requested || diagnostic.required) {
735
+ return diagnostic;
736
+ }
737
+ return {
738
+ ...diagnostic,
739
+ status: 'not_requested',
740
+ reason: diagnostic.reason ?? 'Scenario did not request this optional diagnostic surface.',
741
+ };
742
+ }
743
+ /**
744
+ * Builds the product-neutral diagnostic inventory for a profile run.
745
+ *
746
+ * @param {{args: CliArgs, attachedEvidence: AttachedEvidence, eventLogPath: string | null, platform: ProfilePlatform, profileSessionEntriesPath: string | null, runDir: string, scenario: Record<string, unknown>}} options
747
+ * @returns {DiagnosticInventoryEntry[]}
748
+ */
749
+ function buildDiagnosticInventory({ args, attachedEvidence, eventLogPath, platform, profileSessionEntriesPath, runDir, scenario, }) {
750
+ const requiredArtifacts = readScenarioStringSet(scenario, ['artifacts', 'required']);
751
+ const optionalArtifacts = readScenarioStringSet(scenario, ['artifacts', 'optional']);
752
+ const requiredCapabilities = readScenarioStringSet(scenario, ['requiredCapabilities']);
753
+ const optionalCapabilities = readScenarioStringSet(scenario, ['optionalCapabilities']);
754
+ const requiredProviderDiagnostics = new Set(attachedEvidence.attachments
755
+ .filter((attachment) => attachment.required)
756
+ .map((attachment) => attachment.kind));
757
+ const sidecarRoot = typeof args['adb-artifacts'] === 'string'
758
+ ? path.resolve(args['adb-artifacts'])
759
+ : typeof args['simctl-artifacts'] === 'string'
760
+ ? path.resolve(args['simctl-artifacts'])
761
+ : null;
762
+ const sidecarRootRef = sidecarRoot ? toRunPathReference({ runDir, targetPath: sidecarRoot }) : undefined;
763
+ const adbScreenshotDependency = platform === 'android'
764
+ ? resolveAndroidAdbScreenshotDependency({ runDir, sidecarRoot })
765
+ : null;
766
+ const eventLogBaseName = eventLogPath ? path.basename(eventLogPath) : undefined;
767
+ const eventLogManifestPath = eventLogBaseName ? `raw/${eventLogBaseName}` : undefined;
768
+ const eventLogIsIosProfileEvents = platform === 'ios' && eventLogBaseName === 'ios-profile-events.log';
769
+ const simctlRuntimeLogPath = typeof args['simctl-artifacts'] === 'string'
770
+ ? path.resolve(args['simctl-artifacts'], 'raw', 'ios-simctl-log.txt')
771
+ : null;
772
+ const simctlRuntimeLogExists = Boolean(simctlRuntimeLogPath && fs.existsSync(simctlRuntimeLogPath));
773
+ const simctlRuntimeLogDependency = simctlRuntimeLogPath && simctlRuntimeLogExists
774
+ ? toSidecarEvidenceDependency({ runDir, sidecarRoot: path.resolve(args['simctl-artifacts']), targetPath: simctlRuntimeLogPath })
775
+ : undefined;
776
+ const copiedSimctlLogManifestPath = platform === 'ios' && eventLogPath && path.basename(eventLogPath) === 'ios-simctl-log.txt'
777
+ ? eventLogManifestPath
778
+ : undefined;
779
+ const explicitIosRuntimeLogManifestPath = platform === 'ios' &&
780
+ typeof args.events === 'string' &&
781
+ eventLogManifestPath &&
782
+ !eventLogIsIosProfileEvents
783
+ ? eventLogManifestPath
784
+ : undefined;
785
+ const eventLogDependency = eventLogPath && sidecarRoot
786
+ ? toSidecarEvidenceDependency({ runDir, sidecarRoot, targetPath: eventLogPath })
787
+ : undefined;
788
+ const jsProfilePath = attachedEvidence.signals.js[0] ?? eventLogManifestPath;
789
+ const profileSessionEntriesManifestPath = profileSessionEntriesPath
790
+ ? `raw/${path.basename(profileSessionEntriesPath)}`
791
+ : undefined;
792
+ const entries = [];
793
+ const pushDiagnostic = (kind, entry) => {
794
+ const request = resolveDiagnosticRequest({
795
+ kind,
796
+ optionalArtifacts,
797
+ optionalCapabilities,
798
+ requiredArtifacts,
799
+ requiredCapabilities,
800
+ });
801
+ entries.push(buildDiagnosticEntry({
802
+ kind,
803
+ ...entry,
804
+ required: request.required || requiredProviderDiagnostics.has(kind) || Boolean(entry.required),
805
+ requested: request.requested || requiredProviderDiagnostics.has(kind) || Boolean(entry.requested),
806
+ }));
807
+ };
808
+ const logCaptured = platform === 'ios'
809
+ ? Boolean(copiedSimctlLogManifestPath || simctlRuntimeLogDependency || explicitIosRuntimeLogManifestPath)
810
+ : Boolean(eventLogManifestPath);
811
+ pushDiagnostic('logs', {
812
+ name: platform === 'ios' ? 'simulator-runtime-log' : 'device-log',
813
+ ...(typeof args['adb-artifacts'] === 'string'
814
+ ? { provider: 'adb', runnerId: 'android-adb' }
815
+ : typeof args['simctl-artifacts'] === 'string'
816
+ ? { provider: 'simctl', runnerId: 'ios-simctl' }
817
+ : typeof args.events === 'string'
818
+ ? { provider: 'fixture-log-ingest' }
819
+ : {}),
820
+ status: logCaptured ? 'captured' : 'unavailable',
821
+ ...(platform === 'ios'
822
+ ? copiedSimctlLogManifestPath
823
+ ? { path: copiedSimctlLogManifestPath }
824
+ : simctlRuntimeLogDependency
825
+ ? { path: simctlRuntimeLogDependency.path }
826
+ : explicitIosRuntimeLogManifestPath
827
+ ? { path: explicitIosRuntimeLogManifestPath }
828
+ : {}
829
+ : eventLogManifestPath
830
+ ? { path: eventLogManifestPath }
831
+ : {}),
832
+ ...(sidecarRootRef ? { sidecarRoot: sidecarRootRef } : {}),
833
+ ...(platform === 'ios'
834
+ ? simctlRuntimeLogDependency
835
+ ? { evidenceDependency: simctlRuntimeLogDependency }
836
+ : {}
837
+ : eventLogDependency
838
+ ? { evidenceDependency: eventLogDependency }
839
+ : {}),
840
+ ...(logCaptured
841
+ ? {
842
+ reason: platform === 'ios'
843
+ ? 'iOS simulator runtime log evidence was available from the simctl capture sidecar.'
844
+ : 'Device or fixture log evidence was available to the profile runner.',
845
+ }
846
+ : {
847
+ reason: platform === 'ios'
848
+ ? 'No iOS simulator runtime log was available in the selected simctl capture sidecar.'
849
+ : 'No device log source was supplied to this profile run.',
850
+ nextAction: platform === 'ios'
851
+ ? 'Run with --simctl-capture or provide --simctl-artifacts containing raw/ios-simctl-log.txt.'
852
+ : 'Run with --events, --adb-artifacts, --adb-capture, or provide a runtime log artifact.',
853
+ }),
854
+ });
855
+ pushDiagnostic('js', {
856
+ name: 'profile-session-evidence',
857
+ status: eventLogManifestPath || attachedEvidence.signals.js.length > 0 ? 'captured' : 'unavailable',
858
+ ...(jsProfilePath ? { path: jsProfilePath } : {}),
859
+ ...(profileSessionEntriesManifestPath
860
+ ? {
861
+ evidenceDependency: {
862
+ kind: 'profile-session-entries',
863
+ path: profileSessionEntriesManifestPath,
864
+ },
865
+ }
866
+ : eventLogDependency
867
+ ? { evidenceDependency: eventLogDependency }
868
+ : {}),
869
+ ...(sidecarRootRef ? { sidecarRoot: sidecarRootRef } : {}),
870
+ ...(eventLogManifestPath || attachedEvidence.signals.js.length > 0
871
+ ? { reason: 'Profile or JS evidence was captured from runner input.' }
872
+ : {
873
+ reason: 'No profile-session event log or JS signal attachment was available.',
874
+ nextAction: 'Attach JS evidence with --signal js:<path> or run a profile-session capture that emits profile events.',
875
+ }),
876
+ });
877
+ const attachedScreenshotPath = attachedEvidence.captures.screenshots[0];
878
+ const sidecarScreenshotDependency = attachedScreenshotPath ? null : adbScreenshotDependency;
879
+ pushDiagnostic('screenshot', {
880
+ ...(sidecarScreenshotDependency ? { provider: 'adb', runnerId: 'android-adb' } : {}),
881
+ status: attachedScreenshotPath || sidecarScreenshotDependency ? 'captured' : 'unavailable',
882
+ ...(attachedScreenshotPath
883
+ ? { path: attachedScreenshotPath }
884
+ : sidecarScreenshotDependency
885
+ ? { path: sidecarScreenshotDependency.path }
886
+ : {}),
887
+ ...(sidecarScreenshotDependency && sidecarRootRef ? { sidecarRoot: sidecarRootRef } : {}),
888
+ ...(sidecarScreenshotDependency ? { evidenceDependency: sidecarScreenshotDependency.dependency } : {}),
889
+ ...(attachedScreenshotPath || sidecarScreenshotDependency
890
+ ? {
891
+ reason: sidecarScreenshotDependency
892
+ ? 'Screenshot evidence was available from the adb capture sidecar.'
893
+ : 'Screenshot capture was attached to the run.',
894
+ }
895
+ : {
896
+ reason: 'No screenshot capture was produced by the selected runner/provider set.',
897
+ nextAction: 'Use --capture screenshot:<path> or a runner/provider that produces screenshots.',
898
+ }),
899
+ });
900
+ pushDiagnostic('uiTree', {
901
+ status: attachedEvidence.captures.uiTree ? 'captured' : 'unavailable',
902
+ ...(attachedEvidence.captures.uiTree ? { path: attachedEvidence.captures.uiTree } : {}),
903
+ ...(attachedEvidence.captures.uiTree
904
+ ? { reason: 'UI tree capture was attached to the run.' }
905
+ : {
906
+ reason: 'No UI tree capture was produced by the selected runner/provider set.',
907
+ nextAction: 'Use --capture uiTree:<path> or add an accessibility/UI-tree provider.',
908
+ }),
909
+ });
910
+ pushDiagnostic('video', {
911
+ status: attachedEvidence.captures.video ? 'captured' : 'unavailable',
912
+ ...(attachedEvidence.captures.video ? { path: attachedEvidence.captures.video } : {}),
913
+ ...(attachedEvidence.captures.video
914
+ ? { reason: 'Video capture was attached to the run.' }
915
+ : {
916
+ reason: 'No video capture was produced by the selected runner/provider set.',
917
+ nextAction: 'Use --capture video:<path> or run a capture provider that records video.',
918
+ }),
919
+ });
920
+ for (const kind of ['memory', 'network']) {
921
+ pushDiagnostic(kind, {
922
+ status: attachedEvidence.signals[kind].length > 0 ? 'captured' : 'unavailable',
923
+ ...(attachedEvidence.signals[kind][0] ? { path: attachedEvidence.signals[kind][0] } : {}),
924
+ ...(attachedEvidence.signals[kind].length > 0
925
+ ? { reason: `${kind} signal evidence was attached to the run.` }
926
+ : {
927
+ reason: `No ${kind} signal evidence was produced by the selected provider set.`,
928
+ nextAction: `Attach ${kind} evidence with --signal ${kind}:<path> or add a provider command that emits it.`,
929
+ }),
930
+ });
931
+ }
932
+ for (const kind of ['accessibility', 'profiler']) {
933
+ const attachment = attachedEvidence.attachments.find((item) => item.kind === kind);
934
+ pushDiagnostic(kind, {
935
+ ...(attachment?.channel === 'provider' ? { provider: 'evidence-provider' } : {}),
936
+ status: attachment ? 'captured' : 'unavailable',
937
+ ...(attachment ? { path: attachment.manifestPath } : {}),
938
+ ...(attachment
939
+ ? { reason: `${kind} provider evidence was attached to the run.` }
940
+ : {
941
+ reason: `No ${kind} provider attachment was produced by the selected provider set.`,
942
+ nextAction: `Declare a provider command or attach ${kind} evidence before expecting this diagnostic.`,
943
+ }),
944
+ });
945
+ }
946
+ return entries.map((entry) => {
947
+ const cleaned = Object.entries(entry).filter(([, value]) => value !== undefined);
948
+ return Object.fromEntries(cleaned);
949
+ });
950
+ }
951
+ /**
952
+ * Converts uncaptured required diagnostics into health checks.
953
+ *
954
+ * @param {DiagnosticInventoryEntry[]} diagnostics
955
+ * @returns {Record<string, unknown>[]}
956
+ */
957
+ function buildRequiredDiagnosticHealthChecks(diagnostics = []) {
958
+ return diagnostics
959
+ .filter((diagnostic) => diagnostic.required && diagnostic.status !== 'captured')
960
+ .map((diagnostic) => ({
961
+ name: `required_${diagnostic.kind}_diagnostic`,
962
+ status: 'failed',
963
+ source: 'evidence',
964
+ code: 'required_diagnostic_not_captured',
965
+ message: diagnostic.reason ?? `Required ${diagnostic.kind} diagnostic was not captured.`,
966
+ metadata: {
967
+ kind: diagnostic.kind,
968
+ status: diagnostic.status,
969
+ ...(diagnostic.name ? { name: diagnostic.name } : {}),
970
+ ...(diagnostic.nextAction ? { nextAction: diagnostic.nextAction } : {}),
971
+ ...(diagnostic.provider ? { provider: diagnostic.provider } : {}),
972
+ ...(diagnostic.runnerId ? { runnerId: diagnostic.runnerId } : {}),
973
+ },
974
+ }));
975
+ }
581
976
  /**
582
977
  * Builds scenario health from profile metrics.
583
978
  *
584
- * @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>}} options
979
+ * @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>, diagnostics?: DiagnosticInventoryEntry[], profileEventCount?: number, profileSessionEntryCount?: number, commandTransport?: string, sessionEntries?: Record<string, unknown>[]}} options
585
980
  * @returns {Record<string, unknown>}
586
981
  */
587
- function buildProfileHealth({ scenario, runId, metrics, }) {
982
+ function buildProfileHealth({ scenario, runId, metrics, diagnostics = [], profileEventCount, profileSessionEntryCount, commandTransport, sessionEntries = [], }) {
588
983
  const passed = metrics.status === 'passed';
984
+ const metadata = {
985
+ failures: typeof metrics.failures === 'number' ? metrics.failures : null,
986
+ timeouts: typeof metrics.timeouts === 'number' ? metrics.timeouts : null,
987
+ };
988
+ if (typeof profileEventCount === 'number') {
989
+ metadata.profileEventCount = profileEventCount;
990
+ }
991
+ if (typeof profileSessionEntryCount === 'number') {
992
+ metadata.profileSessionEntryCount = profileSessionEntryCount;
993
+ }
994
+ if (typeof commandTransport === 'string' && commandTransport.length > 0) {
995
+ metadata.commandTransport = commandTransport;
996
+ }
997
+ if (!passed &&
998
+ profileEventCount === 0 &&
999
+ profileSessionEntryCount === 0 &&
1000
+ typeof commandTransport === 'string' &&
1001
+ commandTransport.startsWith('profile-session')) {
1002
+ metadata.nextActionCode = 'verify_profile_session_bootstrap';
1003
+ metadata.nextAction =
1004
+ 'Verify the app loaded the expected bundle, mounted the profile-session bootstrap near the app root, and uses the configured storage keys or deep-link scheme before treating this as a product failure.';
1005
+ }
1006
+ const skippedCommands = sessionEntries.filter((entry) => (entry?.kind === 'command' && entry.status === 'skipped'));
1007
+ const firstSkippedCommand = skippedCommands[0];
1008
+ const firstSkippedReason = typeof firstSkippedCommand?.reason === 'string'
1009
+ ? firstSkippedCommand.reason
1010
+ : undefined;
1011
+ const commandFailureCode = firstSkippedReason === 'wait-for-milestone-timeout'
1012
+ ? 'profile_command_gate_timeout'
1013
+ : 'profile_command_skipped';
1014
+ const commandFailureMessage = firstSkippedReason === 'wait-for-milestone-timeout'
1015
+ ? 'One or more profile-session commands waited for a milestone that was not observed before timeout.'
1016
+ : 'One or more profile-session commands were skipped before the scenario completed.';
1017
+ const commandChecks = skippedCommands.length > 0
1018
+ ? [
1019
+ {
1020
+ name: 'profile_command_sequence',
1021
+ status: 'failed',
1022
+ source: 'runner',
1023
+ code: commandFailureCode,
1024
+ message: commandFailureMessage,
1025
+ metadata: {
1026
+ skippedCommandCount: skippedCommands.length,
1027
+ ...(typeof firstSkippedCommand?.command === 'string' ? { command: firstSkippedCommand.command } : {}),
1028
+ ...(typeof firstSkippedCommand?.commandId === 'string' ? { commandId: firstSkippedCommand.commandId } : {}),
1029
+ ...(typeof firstSkippedCommand?.queueId === 'string' ? { queueId: firstSkippedCommand.queueId } : {}),
1030
+ ...(typeof firstSkippedCommand?.reason === 'string' ? { reason: firstSkippedCommand.reason } : {}),
1031
+ ...(typeof firstSkippedCommand?.sequence === 'number' ? { sequence: firstSkippedCommand.sequence } : {}),
1032
+ ...(typeof firstSkippedCommand?.waitForMilestone === 'string'
1033
+ ? { waitForMilestone: firstSkippedCommand.waitForMilestone }
1034
+ : {}),
1035
+ ...(typeof firstSkippedCommand?.waitTimeoutMs === 'number'
1036
+ ? { waitTimeoutMs: firstSkippedCommand.waitTimeoutMs }
1037
+ : {}),
1038
+ },
1039
+ },
1040
+ ]
1041
+ : [];
1042
+ const commandChecksPassed = commandChecks.every((check) => check.status === 'passed');
1043
+ const diagnosticChecks = buildRequiredDiagnosticHealthChecks(diagnostics);
1044
+ const diagnosticChecksPassed = diagnosticChecks.every((check) => check.status === 'passed');
1045
+ const healthPassed = passed && commandChecksPassed && diagnosticChecksPassed;
589
1046
  return assertValidJson({
590
1047
  schemaVersion: '1.0.0',
591
1048
  scenarioId: scenario.name,
592
1049
  ...(typeof scenario.flowId === 'string' ? { flowId: scenario.flowId } : {}),
593
1050
  runId,
594
- healthStatus: passed ? 'passed' : 'failed',
1051
+ healthStatus: healthPassed ? 'passed' : 'failed',
595
1052
  checks: [
596
1053
  {
597
1054
  name: 'truth_events_complete',
@@ -601,11 +1058,10 @@ function buildProfileHealth({ scenario, runId, metrics, }) {
601
1058
  message: passed
602
1059
  ? 'Profile events completed every expected iteration.'
603
1060
  : 'Profile events did not complete every expected iteration.',
604
- metadata: {
605
- failures: typeof metrics.failures === 'number' ? metrics.failures : null,
606
- timeouts: typeof metrics.timeouts === 'number' ? metrics.timeouts : null,
607
- },
1061
+ metadata,
608
1062
  },
1063
+ ...commandChecks,
1064
+ ...diagnosticChecks,
609
1065
  ],
610
1066
  }, SCHEMAS.health, 'Health artifact');
611
1067
  }
@@ -857,6 +1313,121 @@ function resolveProfileSessionEntriesPath({ args, platform }) {
857
1313
  }
858
1314
  return null;
859
1315
  }
1316
+ /**
1317
+ * Resolves the run id used by rehydrated sidecar evidence.
1318
+ *
1319
+ * A rehydrated artifact can intentionally have a new run id while ingesting a
1320
+ * previously captured adb/simctl sidecar. Keep live runs strict, but allow an
1321
+ * explicit sidecar with exactly one source run id for the scenario to provide
1322
+ * the event filter.
1323
+ *
1324
+ * @param {{args: CliArgs, eventLogText: string, profileSessionEntriesPath: string | null, runId: string, scenarioName: string}} options
1325
+ * @returns {string}
1326
+ */
1327
+ function resolveEvidenceFilterRunId({ args, eventLogText, profileSessionEntriesPath, runId, scenarioName, }) {
1328
+ const isRehydratedSidecar = typeof args['adb-artifacts'] === 'string' || typeof args['simctl-artifacts'] === 'string';
1329
+ if (!isRehydratedSidecar) {
1330
+ return runId;
1331
+ }
1332
+ const scenarioEvents = extractProfileEvents(eventLogText, { scenario: scenarioName });
1333
+ const currentRunEvents = scenarioEvents.filter((event) => event.runId === runId);
1334
+ if (currentRunEvents.length > 0) {
1335
+ return runId;
1336
+ }
1337
+ const sourceRunIds = new Set(scenarioEvents
1338
+ .map((event) => event.runId)
1339
+ .filter((sourceRunId) => typeof sourceRunId === 'string' && sourceRunId.length > 0));
1340
+ if (profileSessionEntriesPath && fs.existsSync(profileSessionEntriesPath)) {
1341
+ const storedEntries = JSON.parse(fs.readFileSync(profileSessionEntriesPath, 'utf8'));
1342
+ if (Array.isArray(storedEntries)) {
1343
+ for (const entry of storedEntries) {
1344
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1345
+ continue;
1346
+ }
1347
+ const record = entry;
1348
+ if (record.scenario !== scenarioName) {
1349
+ continue;
1350
+ }
1351
+ if (typeof record.runId === 'string' && record.runId.length > 0) {
1352
+ sourceRunIds.add(record.runId);
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+ return sourceRunIds.size === 1 ? [...sourceRunIds][0] : runId;
1358
+ }
1359
+ /**
1360
+ * Returns the first usable adb screenshot file from sidecar metadata.
1361
+ *
1362
+ * ADB can produce a valid PNG even when command metadata records a nonzero
1363
+ * exit status from the host process. Treat the binary artifact as the capture
1364
+ * authority, but only after validating the PNG signature and sidecar boundary.
1365
+ *
1366
+ * @param {{runDir: string, sidecarRoot: string | null}} options
1367
+ * @returns {{dependency: SidecarEvidenceDependency, path: string} | null}
1368
+ */
1369
+ function resolveAndroidAdbScreenshotDependency({ runDir, sidecarRoot, }) {
1370
+ if (!sidecarRoot) {
1371
+ return null;
1372
+ }
1373
+ const metadata = readOptionalJsonObject(path.resolve(sidecarRoot, 'raw', 'android-metadata.json'));
1374
+ const actions = Array.isArray(metadata?.driverActions) ? metadata.driverActions : [];
1375
+ for (const action of actions) {
1376
+ if (!action || typeof action !== 'object' || Array.isArray(action)) {
1377
+ continue;
1378
+ }
1379
+ const record = action;
1380
+ if (record.driverAction !== 'screenshot') {
1381
+ continue;
1382
+ }
1383
+ const sidecarRelativePath = typeof record.capturePath === 'string'
1384
+ ? record.capturePath
1385
+ : typeof record.rawPath === 'string'
1386
+ ? record.rawPath
1387
+ : null;
1388
+ if (!sidecarRelativePath || path.isAbsolute(sidecarRelativePath)) {
1389
+ continue;
1390
+ }
1391
+ const screenshotPath = path.resolve(sidecarRoot, sidecarRelativePath);
1392
+ const relativeToSidecar = path.relative(sidecarRoot, screenshotPath);
1393
+ if (relativeToSidecar.length === 0 ||
1394
+ relativeToSidecar.startsWith('..') ||
1395
+ path.isAbsolute(relativeToSidecar) ||
1396
+ !isPngFile(screenshotPath)) {
1397
+ continue;
1398
+ }
1399
+ const sidecarDependency = toSidecarEvidenceDependency({ runDir, sidecarRoot, targetPath: screenshotPath });
1400
+ return {
1401
+ dependency: sidecarDependency,
1402
+ path: sidecarDependency.path,
1403
+ };
1404
+ }
1405
+ return null;
1406
+ }
1407
+ /**
1408
+ * Checks whether a file starts with the PNG signature.
1409
+ *
1410
+ * @param {string} filePath
1411
+ * @returns {boolean}
1412
+ */
1413
+ function isPngFile(filePath) {
1414
+ let signature;
1415
+ try {
1416
+ signature = fs.readFileSync(filePath, { flag: 'r' }).subarray(0, 8);
1417
+ }
1418
+ catch {
1419
+ return false;
1420
+ }
1421
+ return signature.length === 8 &&
1422
+ signature[0] === 0x89 &&
1423
+ signature[1] === 0x50 &&
1424
+ signature[2] === 0x4e &&
1425
+ signature[3] === 0x47 &&
1426
+ signature[4] === 0x0d &&
1427
+ signature[5] === 0x0a &&
1428
+ signature[6] === 0x1a &&
1429
+ signature[7] === 0x0a;
1430
+ }
860
1431
  /**
861
1432
  * Reads a JSON artifact if it exists and contains an object.
862
1433
  *
@@ -1045,6 +1616,46 @@ function findMilestoneEvent(scenario, milestoneId) {
1045
1616
  }
1046
1617
  return null;
1047
1618
  }
1619
+ /**
1620
+ * Returns true when a milestone is explicitly optional.
1621
+ *
1622
+ * @param {Record<string, unknown>} scenario
1623
+ * @param {unknown} milestoneId
1624
+ * @returns {boolean}
1625
+ */
1626
+ function isOptionalMilestone(scenario, milestoneId) {
1627
+ if (typeof milestoneId !== 'string' || !Array.isArray(scenario.milestones)) {
1628
+ return false;
1629
+ }
1630
+ for (const milestone of scenario.milestones) {
1631
+ if (!isRecord(milestone)) {
1632
+ continue;
1633
+ }
1634
+ if (milestone.id === milestoneId) {
1635
+ return milestone.required === false;
1636
+ }
1637
+ }
1638
+ return false;
1639
+ }
1640
+ /**
1641
+ * Returns true when a milestone represents one-time scenario readiness rather than a repeated cycle edge.
1642
+ *
1643
+ * @param {Record<string, unknown>} scenario
1644
+ * @param {unknown} milestoneId
1645
+ * @param {string | null} milestoneEvent
1646
+ * @returns {boolean}
1647
+ */
1648
+ function isReadinessMilestone(scenario, milestoneId, milestoneEvent) {
1649
+ const readyEvent = isRecord(scenario.truthEvents) && isRecord(scenario.truthEvents.ready)
1650
+ ? scenario.truthEvents.ready.event
1651
+ : undefined;
1652
+ if (typeof readyEvent === 'string' && milestoneEvent === readyEvent) {
1653
+ return true;
1654
+ }
1655
+ const id = typeof milestoneId === 'string' ? milestoneId.toLowerCase() : '';
1656
+ const event = typeof milestoneEvent === 'string' ? milestoneEvent.toLowerCase() : '';
1657
+ return id.includes('ready') || event.includes('ready');
1658
+ }
1048
1659
  /**
1049
1660
  * Builds a milestone-id to event-name lookup for schema-era scenarios.
1050
1661
  *
@@ -1100,6 +1711,16 @@ function resolveProfileMetricEvents(scenario) {
1100
1711
  milestone: toEvent,
1101
1712
  };
1102
1713
  }
1714
+ if (fromEvent && toEvent && isReadinessMilestone(scenario, budget.fromMilestone, fromEvent)) {
1715
+ return {
1716
+ milestone: toEvent,
1717
+ };
1718
+ }
1719
+ if (fromEvent && toEvent && isOptionalMilestone(scenario, budget.fromMilestone)) {
1720
+ return {
1721
+ milestone: toEvent,
1722
+ };
1723
+ }
1103
1724
  if (fromEvent && toEvent) {
1104
1725
  return {
1105
1726
  closeRequested: toEvent,
@@ -1112,7 +1733,59 @@ function resolveProfileMetricEvents(scenario) {
1112
1733
  return null;
1113
1734
  }
1114
1735
  /**
1115
- * Maps schema-era milestone budget fields to the legacy profile budget keys.
1736
+ * Resolves how many repeated completion milestone events prove one cycle body.
1737
+ *
1738
+ * @param {Record<string, unknown>} scenario
1739
+ * @param {Record<string, string> | null} metricEvents
1740
+ * @returns {number}
1741
+ */
1742
+ function resolveMilestoneEventsPerIteration(scenario, metricEvents) {
1743
+ if (!metricEvents || typeof metricEvents.milestone !== 'string' || !Array.isArray(scenario.steps)) {
1744
+ return 1;
1745
+ }
1746
+ const milestoneEvents = buildMilestoneEventLookup(scenario);
1747
+ const matchingWaitStepIds = new Set();
1748
+ for (const step of scenario.steps) {
1749
+ if (!isRecord(step) || step.kind !== 'waitForMilestone' || typeof step.milestone !== 'string') {
1750
+ continue;
1751
+ }
1752
+ const event = milestoneEvents[step.milestone] ?? step.milestone;
1753
+ if (event === metricEvents.milestone && typeof step.id === 'string') {
1754
+ matchingWaitStepIds.add(step.id);
1755
+ }
1756
+ }
1757
+ if (matchingWaitStepIds.size === 0) {
1758
+ return 1;
1759
+ }
1760
+ const bodyStepIds = isRecord(scenario.cycles) && Array.isArray(scenario.cycles.bodyStepIds)
1761
+ ? new Set(scenario.cycles.bodyStepIds.filter((entry) => typeof entry === 'string'))
1762
+ : null;
1763
+ if (bodyStepIds && bodyStepIds.size > 0) {
1764
+ let count = 0;
1765
+ let bodyCommandPending = false;
1766
+ for (const step of scenario.steps) {
1767
+ if (!isRecord(step)) {
1768
+ continue;
1769
+ }
1770
+ if (typeof step.id === 'string' && bodyStepIds.has(step.id) && step.kind === 'command') {
1771
+ bodyCommandPending = true;
1772
+ continue;
1773
+ }
1774
+ if (bodyCommandPending && typeof step.id === 'string' && matchingWaitStepIds.has(step.id)) {
1775
+ count += 1;
1776
+ bodyCommandPending = false;
1777
+ continue;
1778
+ }
1779
+ if (bodyCommandPending && step.kind === 'command') {
1780
+ bodyCommandPending = false;
1781
+ }
1782
+ }
1783
+ return count > 1 ? count : 1;
1784
+ }
1785
+ return matchingWaitStepIds.size > 1 ? matchingWaitStepIds.size : 1;
1786
+ }
1787
+ /**
1788
+ * Maps shorthand milestone budget fields to aggregate profile budget keys.
1116
1789
  *
1117
1790
  * @param {{budget: Record<string, unknown>, metric: string}} options
1118
1791
  * @returns {string | null}
@@ -1144,11 +1817,24 @@ function resolveProfileBudgets(scenario) {
1144
1817
  return null;
1145
1818
  }
1146
1819
  const pass = {};
1820
+ const intervals = [];
1147
1821
  for (const budget of scenario.budgets) {
1148
1822
  if (!isRecord(budget) || typeof budget.limit !== 'number') {
1149
1823
  continue;
1150
1824
  }
1151
1825
  if (budget.metric === 'p95' || budget.metric === 'p50') {
1826
+ const fromEvent = findMilestoneEvent(scenario, budget.fromMilestone);
1827
+ const toEvent = findMilestoneEvent(scenario, budget.toMilestone);
1828
+ if (fromEvent && toEvent) {
1829
+ intervals.push({
1830
+ name: typeof budget.name === 'string' ? budget.name : `${String(budget.fromMilestone)} to ${String(budget.toMilestone)}`,
1831
+ metric: budget.metric,
1832
+ limit: budget.limit,
1833
+ fromEvent,
1834
+ toEvent,
1835
+ });
1836
+ continue;
1837
+ }
1152
1838
  const budgetKey = resolveProfileBudgetKey({ budget, metric: budget.metric });
1153
1839
  if (budgetKey) {
1154
1840
  pass[budgetKey] = budget.limit;
@@ -1161,10 +1847,11 @@ function resolveProfileBudgets(scenario) {
1161
1847
  pass.timeouts = budget.limit;
1162
1848
  }
1163
1849
  }
1164
- return Object.keys(pass).length > 0
1850
+ return Object.keys(pass).length > 0 || intervals.length > 0
1165
1851
  ? {
1166
1852
  metric: 'milestone budget',
1167
1853
  pass,
1854
+ ...(intervals.length > 0 ? { intervals } : {}),
1168
1855
  }
1169
1856
  : null;
1170
1857
  }
@@ -1287,6 +1974,7 @@ async function runProfileMobile(args, options) {
1287
1974
  const scenarioHash = hashScenarioContract(profileScenario);
1288
1975
  const expectedIterations = resolveExpectedIterations(profileScenario);
1289
1976
  const profileMetricEvents = resolveProfileMetricEvents(profileScenario);
1977
+ const milestoneEventsPerIteration = resolveMilestoneEventsPerIteration(profileScenario, profileMetricEvents);
1290
1978
  const profileBudgets = resolveProfileBudgets(profileScenario);
1291
1979
  const runId = typeof args['run-id'] === 'string' ? args['run-id'] : createRunId();
1292
1980
  const artifactRoot = resolveArtifactRoot({ args, config, configPath, platform: options.platform });
@@ -1342,15 +2030,69 @@ async function runProfileMobile(args, options) {
1342
2030
  verdict,
1343
2031
  };
1344
2032
  }
1345
- const attachedEvidence = await resolveAttachedEvidence({ args, layout, providerInputs: providerExecution.inputs });
2033
+ let attachedEvidence;
2034
+ try {
2035
+ attachedEvidence = await resolveAttachedEvidence({ args, layout, providerInputs: providerExecution.inputs });
2036
+ }
2037
+ catch (error) {
2038
+ const providerInput = providerExecution.inputs.find((input) => error instanceof Error && error.message.includes(input.sourcePath));
2039
+ const health = buildProviderCommandFailureHealth({
2040
+ failures: [
2041
+ {
2042
+ commandId: 'provider-evidence',
2043
+ code: 'provider_evidence_invalid',
2044
+ exitCode: null,
2045
+ message: error instanceof Error ? error.message : String(error),
2046
+ name: 'evidence_provider_output_valid',
2047
+ nextAction: 'Fix the provider output so it satisfies the ASL evidence contract, then rerun the profile.',
2048
+ nextActionCode: 'fix_provider_evidence_output',
2049
+ phase: 'afterCapture',
2050
+ providerId: providerInput?.providerId ?? 'unknown-provider',
2051
+ ...(providerInput?.manifestPath ? { rawPath: providerInput.manifestPath } : {}),
2052
+ },
2053
+ ],
2054
+ runId,
2055
+ scenario: profileScenario,
2056
+ });
2057
+ const verdict = buildProfileVerdict({ scenario: profileScenario, runId, health, metrics: {} });
2058
+ const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
2059
+ await writeJsonArtifact({
2060
+ filePath: layout.health,
2061
+ value: health,
2062
+ schema: SCHEMAS.health,
2063
+ label: 'Health artifact',
2064
+ });
2065
+ await writeJsonArtifact({
2066
+ filePath: layout.verdict,
2067
+ value: verdict,
2068
+ schema: SCHEMAS.verdict,
2069
+ label: 'Verdict artifact',
2070
+ });
2071
+ await writeTextArtifact({
2072
+ filePath: layout.agentSummary,
2073
+ content: agentSummary,
2074
+ });
2075
+ return {
2076
+ runDir,
2077
+ health,
2078
+ verdict,
2079
+ };
2080
+ }
1346
2081
  const eventLogText = eventLogPath ? await fsp.readFile(eventLogPath, 'utf8') : '';
2082
+ const evidenceFilterRunId = resolveEvidenceFilterRunId({
2083
+ args,
2084
+ eventLogText,
2085
+ profileSessionEntriesPath,
2086
+ runId,
2087
+ scenarioName,
2088
+ });
1347
2089
  const events = extractProfileEvents(eventLogText, {
1348
2090
  scenario: scenarioName,
1349
- runId,
2091
+ runId: evidenceFilterRunId,
1350
2092
  });
1351
2093
  const logSessionEntries = extractProfileSessionEntries(eventLogText, {
1352
2094
  scenario: scenarioName,
1353
- runId,
2095
+ runId: evidenceFilterRunId,
1354
2096
  });
1355
2097
  const storedSessionEntries = profileSessionEntriesPath
1356
2098
  ? JSON.parse(await fsp.readFile(profileSessionEntriesPath, 'utf8'))
@@ -1364,7 +2106,7 @@ async function runProfileMobile(args, options) {
1364
2106
  }
1365
2107
  const record = entry;
1366
2108
  return ((!('scenario' in record) || record.scenario === scenarioName) &&
1367
- (!('runId' in record) || record.runId === runId));
2109
+ (!('runId' in record) || record.runId === evidenceFilterRunId));
1368
2110
  })
1369
2111
  : []),
1370
2112
  ];
@@ -1376,11 +2118,16 @@ async function runProfileMobile(args, options) {
1376
2118
  expectedIterations,
1377
2119
  budgets: profileBudgets,
1378
2120
  cycleEventNames: profileMetricEvents,
2121
+ milestoneEventsPerIteration,
1379
2122
  artifacts: {
1380
2123
  captures: attachedEvidence.captures,
1381
2124
  signals: attachedEvidence.signals,
1382
2125
  },
1383
2126
  });
2127
+ const eventLogRawPath = eventLogPath ? `raw/${path.basename(eventLogPath)}` : undefined;
2128
+ const eventLogIsProfileSessionEvidenceOnly = options.platform === 'ios' &&
2129
+ eventLogPath &&
2130
+ path.basename(eventLogPath) === 'ios-profile-events.log';
1384
2131
  const manifestArtifacts = {
1385
2132
  causalRun: 'causal-run.json',
1386
2133
  budgetVerdict: 'budget-verdict.json',
@@ -1389,13 +2136,20 @@ async function runProfileMobile(args, options) {
1389
2136
  summary: 'summary.md',
1390
2137
  scenario: toPortablePathReference(scenarioPath),
1391
2138
  raw: {
1392
- interactionLog: eventLogPath ? `raw/${path.basename(eventLogPath)}` : 'raw/interaction.log',
1393
- deviceLog: 'raw/device.log',
2139
+ ...(eventLogRawPath && !eventLogIsProfileSessionEvidenceOnly
2140
+ ? {
2141
+ interactionLog: eventLogRawPath,
2142
+ deviceLog: eventLogRawPath,
2143
+ }
2144
+ : {}),
2145
+ ...(profileSessionEntriesPath
2146
+ ? { profileSessionEntries: `raw/${path.basename(profileSessionEntriesPath)}` }
2147
+ : {}),
1394
2148
  },
1395
2149
  captures: {
1396
2150
  screenshots: attachedEvidence.captures.screenshots,
1397
- video: attachedEvidence.captures.video ?? 'captures/run.mp4',
1398
- uiTree: attachedEvidence.captures.uiTree ?? 'captures/ui-tree.json',
2151
+ ...(attachedEvidence.captures.video ? { video: attachedEvidence.captures.video } : {}),
2152
+ ...(attachedEvidence.captures.uiTree ? { uiTree: attachedEvidence.captures.uiTree } : {}),
1399
2153
  },
1400
2154
  signals: {
1401
2155
  js: attachedEvidence.signals.js,
@@ -1403,8 +2157,18 @@ async function runProfileMobile(args, options) {
1403
2157
  network: attachedEvidence.signals.network,
1404
2158
  },
1405
2159
  evidenceAttachments: buildEvidenceAttachmentManifest(attachedEvidence.attachments),
2160
+ diagnostics: buildDiagnosticInventory({
2161
+ args,
2162
+ attachedEvidence,
2163
+ eventLogPath,
2164
+ platform: options.platform,
2165
+ profileSessionEntriesPath,
2166
+ runDir,
2167
+ scenario: profileScenario,
2168
+ }),
1406
2169
  };
1407
2170
  const appId = resolveAppId({ config, platform: options.platform });
2171
+ const commandTransport = resolveCommandTransport({ args, interactionDriver, options });
1408
2172
  const provenanceCohort = buildProfileProvenanceCohort({
1409
2173
  appId,
1410
2174
  args,
@@ -1465,7 +2229,16 @@ async function runProfileMobile(args, options) {
1465
2229
  runId,
1466
2230
  budgetEvaluation: metrics.budgetEvaluation ?? null,
1467
2231
  });
1468
- const health = buildProfileHealth({ scenario: profileScenario, runId, metrics });
2232
+ const health = buildProfileHealth({
2233
+ scenario: profileScenario,
2234
+ runId,
2235
+ metrics,
2236
+ diagnostics: manifestArtifacts.diagnostics,
2237
+ profileEventCount: events.length,
2238
+ profileSessionEntryCount: sessionEntries.length,
2239
+ commandTransport,
2240
+ sessionEntries,
2241
+ });
1469
2242
  const verdict = buildProfileVerdict({ scenario: profileScenario, runId, health, metrics });
1470
2243
  const agentSummary = buildAgentSummaryMarkdown({ health, verdict, manifest });
1471
2244
  const summary = buildSummaryMarkdown({ manifest, metrics });