agent-scenario-loop 0.1.5 → 0.1.7
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.
|
@@ -167,6 +167,7 @@ function parseKeyValueProfileSessionEntry(payload) {
|
|
|
167
167
|
return null;
|
|
168
168
|
}
|
|
169
169
|
const timestamp = coerceNumber(entry.timestamp);
|
|
170
|
+
const startedAt = coerceNumber(entry.startedAt);
|
|
170
171
|
const atMs = coerceNumber(entry.atMs);
|
|
171
172
|
const sequence = coerceNumber(entry.sequence);
|
|
172
173
|
const waitMs = coerceNumber(entry.waitMs);
|
|
@@ -177,6 +178,9 @@ function parseKeyValueProfileSessionEntry(payload) {
|
|
|
177
178
|
if (timestamp !== null) {
|
|
178
179
|
entry.timestamp = timestamp;
|
|
179
180
|
}
|
|
181
|
+
if (startedAt !== null) {
|
|
182
|
+
entry.startedAt = startedAt;
|
|
183
|
+
}
|
|
180
184
|
if (sequence !== null) {
|
|
181
185
|
entry.sequence = sequence;
|
|
182
186
|
}
|
|
@@ -473,10 +477,15 @@ function buildMetricsFromProfileEvents({ scenario, runId, events, expectedIterat
|
|
|
473
477
|
(typeof record.milestoneCount === 'number' &&
|
|
474
478
|
record.milestoneCount >= requiredMilestoneEventsPerIteration)) &&
|
|
475
479
|
record.milestoneAt >= 0;
|
|
476
|
-
if (hasMilestoneDuration) {
|
|
480
|
+
if (hasMilestoneDuration && expectedIterations === 1) {
|
|
477
481
|
durationsMs.push(roundMs(record.milestoneAt));
|
|
478
482
|
openDurationsMs.push(roundMs(record.milestoneAt));
|
|
479
483
|
}
|
|
484
|
+
else if (hasMilestoneDuration) {
|
|
485
|
+
// Repeated completion-only milestones prove that cycles finished, but
|
|
486
|
+
// their atMs values are positions on the session timeline, not per-cycle
|
|
487
|
+
// latency samples. Repeated latency budgets need explicit interval anchors.
|
|
488
|
+
}
|
|
480
489
|
else if (hasCycleDuration) {
|
|
481
490
|
durationsMs.push(roundMs(record.dismissedAt - record.presentRequestedAt));
|
|
482
491
|
}
|
|
@@ -126,6 +126,17 @@ type ProviderCommandFailure = {
|
|
|
126
126
|
providerId: string;
|
|
127
127
|
rawPath?: string;
|
|
128
128
|
};
|
|
129
|
+
type ProfileSessionSeed = {
|
|
130
|
+
runId: string;
|
|
131
|
+
scenario: string;
|
|
132
|
+
startedAt: number;
|
|
133
|
+
};
|
|
134
|
+
type ProfileSessionFreshness = {
|
|
135
|
+
appStartedAt?: number;
|
|
136
|
+
reason?: string;
|
|
137
|
+
seed: ProfileSessionSeed;
|
|
138
|
+
status: 'fresh' | 'missing-app-session' | 'stale';
|
|
139
|
+
};
|
|
129
140
|
/**
|
|
130
141
|
* Prints CLI usage to stderr.
|
|
131
142
|
*
|
|
@@ -173,10 +184,10 @@ declare function resolveAttachedEvidence({ args, layout, providerInputs, }: {
|
|
|
173
184
|
/**
|
|
174
185
|
* Builds scenario health from profile metrics.
|
|
175
186
|
*
|
|
176
|
-
* @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>, diagnostics?: DiagnosticInventoryEntry[], profileEventCount?: number, profileSessionEntryCount?: number, commandTransport?: string, sessionEntries?: Record<string, unknown>[]}} options
|
|
187
|
+
* @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>, diagnostics?: DiagnosticInventoryEntry[], profileEventCount?: number, profileSessionEntryCount?: number, commandTransport?: string, sessionEntries?: Record<string, unknown>[], sessionFreshness?: ProfileSessionFreshness | null}} options
|
|
177
188
|
* @returns {Record<string, unknown>}
|
|
178
189
|
*/
|
|
179
|
-
declare function buildProfileHealth({ scenario, runId, metrics, diagnostics, profileEventCount, profileSessionEntryCount, commandTransport, sessionEntries, }: {
|
|
190
|
+
declare function buildProfileHealth({ scenario, runId, metrics, diagnostics, profileEventCount, profileSessionEntryCount, commandTransport, sessionEntries, sessionFreshness, }: {
|
|
180
191
|
scenario: Record<string, any>;
|
|
181
192
|
runId: string;
|
|
182
193
|
metrics: Record<string, any>;
|
|
@@ -185,6 +196,7 @@ declare function buildProfileHealth({ scenario, runId, metrics, diagnostics, pro
|
|
|
185
196
|
profileSessionEntryCount?: number;
|
|
186
197
|
commandTransport?: string;
|
|
187
198
|
sessionEntries?: Record<string, any>[];
|
|
199
|
+
sessionFreshness?: ProfileSessionFreshness | null;
|
|
188
200
|
}): Record<string, unknown>;
|
|
189
201
|
/**
|
|
190
202
|
* Builds failed scenario health from evidence-provider command failures.
|
|
@@ -20,7 +20,7 @@ exports.runProfileCli = runProfileCli;
|
|
|
20
20
|
exports.runProfileMobile = runProfileMobile;
|
|
21
21
|
exports.hashScenarioContract = hashScenarioContract;
|
|
22
22
|
exports.usage = usage;
|
|
23
|
-
const {
|
|
23
|
+
const { spawn } = require('node:child_process');
|
|
24
24
|
const fs = require('node:fs');
|
|
25
25
|
const fsp = require('node:fs/promises');
|
|
26
26
|
const path = require('node:path');
|
|
@@ -35,6 +35,7 @@ const { writeUsage } = require('./cli');
|
|
|
35
35
|
const CAPTURE_EVIDENCE_KINDS = new Set(['screenshot', 'uiTree', 'video']);
|
|
36
36
|
const PROVIDER_EVIDENCE_KINDS = new Set(['accessibility', 'logs', 'profiler']);
|
|
37
37
|
const SIGNAL_EVIDENCE_KINDS = new Set(['js', 'memory', 'network']);
|
|
38
|
+
const DEFAULT_PROVIDER_COMMAND_TIMEOUT_MS = 180_000;
|
|
38
39
|
/**
|
|
39
40
|
* Prints CLI usage to stderr.
|
|
40
41
|
*
|
|
@@ -188,23 +189,83 @@ async function hashFileSha256(filePath) {
|
|
|
188
189
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
189
190
|
}
|
|
190
191
|
/**
|
|
191
|
-
*
|
|
192
|
+
* Resolves the timeout applied to provider commands.
|
|
192
193
|
*
|
|
193
|
-
* @
|
|
194
|
+
* @returns {number}
|
|
195
|
+
*/
|
|
196
|
+
function resolveProviderCommandTimeoutMs() {
|
|
197
|
+
return readPositiveInteger(process.env.ASL_PROVIDER_COMMAND_TIMEOUT_MS, DEFAULT_PROVIDER_COMMAND_TIMEOUT_MS);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Runs one provider command without a shell, streaming output to raw files.
|
|
201
|
+
*
|
|
202
|
+
* @param {{command: string, args: string[], cwd?: string, env?: Record<string, string>, stderrPath: string, stdoutPath: string, timeoutMs: number}} options
|
|
194
203
|
* @returns {Promise<ProviderCommandResult>}
|
|
195
204
|
*/
|
|
196
|
-
function execProviderCommand({ args, command, cwd, env, }) {
|
|
205
|
+
function execProviderCommand({ args, command, cwd, env, stderrPath, stdoutPath, timeoutMs, }) {
|
|
197
206
|
return new Promise((resolve) => {
|
|
198
|
-
|
|
207
|
+
const child = spawn(command, args, {
|
|
199
208
|
...(cwd ? { cwd } : {}),
|
|
200
209
|
env: env ? { ...process.env, ...env } : process.env,
|
|
201
|
-
|
|
210
|
+
shell: false,
|
|
211
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
212
|
+
});
|
|
213
|
+
const stdoutChunks = [];
|
|
214
|
+
const stderrChunks = [];
|
|
215
|
+
let timedOut = false;
|
|
216
|
+
let settled = false;
|
|
217
|
+
const timeout = setTimeout(() => {
|
|
218
|
+
timedOut = true;
|
|
219
|
+
child.kill('SIGTERM');
|
|
220
|
+
setTimeout(() => {
|
|
221
|
+
if (!settled) {
|
|
222
|
+
child.kill('SIGKILL');
|
|
223
|
+
}
|
|
224
|
+
}, 1000).unref();
|
|
225
|
+
}, timeoutMs);
|
|
226
|
+
timeout.unref();
|
|
227
|
+
child.stdout.on('data', (chunk) => {
|
|
228
|
+
stdoutChunks.push(chunk);
|
|
229
|
+
fs.appendFileSync(stdoutPath, chunk);
|
|
230
|
+
});
|
|
231
|
+
child.stderr.on('data', (chunk) => {
|
|
232
|
+
stderrChunks.push(chunk);
|
|
233
|
+
fs.appendFileSync(stderrPath, chunk);
|
|
234
|
+
});
|
|
235
|
+
child.on('error', (error) => {
|
|
236
|
+
if (settled) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
settled = true;
|
|
241
|
+
const stderr = error.message;
|
|
242
|
+
fs.appendFileSync(stderrPath, `${stderr}\n`, 'utf8');
|
|
202
243
|
resolve({
|
|
203
244
|
args,
|
|
204
245
|
command,
|
|
205
|
-
exitCode:
|
|
246
|
+
exitCode: 1,
|
|
247
|
+
signal: null,
|
|
248
|
+
stderr,
|
|
249
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
250
|
+
timedOut,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
child.on('close', (exitCode, signal) => {
|
|
254
|
+
if (settled) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
clearTimeout(timeout);
|
|
258
|
+
settled = true;
|
|
259
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
260
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
261
|
+
resolve({
|
|
262
|
+
args,
|
|
263
|
+
command,
|
|
264
|
+
exitCode: typeof exitCode === 'number' ? exitCode : timedOut ? 124 : 1,
|
|
265
|
+
signal,
|
|
206
266
|
stderr,
|
|
207
267
|
stdout,
|
|
268
|
+
timedOut,
|
|
208
269
|
});
|
|
209
270
|
});
|
|
210
271
|
});
|
|
@@ -396,32 +457,66 @@ async function executeProviderCommands({ args, layout, platform, runDir, runId,
|
|
|
396
457
|
? resolveProviderPath({ context, manifestDir, value: providerCommand.cwd })
|
|
397
458
|
: manifestDir;
|
|
398
459
|
const resolvedEnv = Object.fromEntries(Object.entries(providerCommand.env ?? {}).map(([key, value]) => [key, applyProviderPlaceholders(value, context)]));
|
|
460
|
+
const commandRecordFileName = `${providerId}-${providerCommand.id}.json`;
|
|
461
|
+
const stdoutFileName = `${providerId}-${providerCommand.id}.stdout.txt`;
|
|
462
|
+
const stderrFileName = `${providerId}-${providerCommand.id}.stderr.txt`;
|
|
463
|
+
const commandRecordPath = path.join(commandRecordDir, commandRecordFileName);
|
|
464
|
+
const stdoutPath = path.join(commandRecordDir, stdoutFileName);
|
|
465
|
+
const stderrPath = path.join(commandRecordDir, stderrFileName);
|
|
466
|
+
const timeoutMs = resolveProviderCommandTimeoutMs();
|
|
467
|
+
const startedAt = new Date().toISOString();
|
|
468
|
+
await fsp.writeFile(stdoutPath, '', 'utf8');
|
|
469
|
+
await fsp.writeFile(stderrPath, '', 'utf8');
|
|
470
|
+
await fsp.writeFile(commandRecordPath, `${JSON.stringify({
|
|
471
|
+
args: resolvedArgs,
|
|
472
|
+
command: resolvedCommand,
|
|
473
|
+
phase: providerCommand.phase,
|
|
474
|
+
providerId,
|
|
475
|
+
startedAt,
|
|
476
|
+
status: 'started',
|
|
477
|
+
stderrPath: `raw/provider-commands/${stderrFileName}`,
|
|
478
|
+
stdoutPath: `raw/provider-commands/${stdoutFileName}`,
|
|
479
|
+
timeoutMs,
|
|
480
|
+
}, null, 2)}\n`, 'utf8');
|
|
399
481
|
const commandResult = await execProviderCommand({
|
|
400
482
|
args: resolvedArgs,
|
|
401
483
|
command: resolvedCommand,
|
|
402
484
|
cwd: resolvedCwd,
|
|
403
485
|
env: resolvedEnv,
|
|
486
|
+
stderrPath,
|
|
487
|
+
stdoutPath,
|
|
488
|
+
timeoutMs,
|
|
404
489
|
});
|
|
405
|
-
const commandRecordFileName = `${providerId}-${providerCommand.id}.json`;
|
|
406
|
-
const commandRecordPath = path.join(commandRecordDir, commandRecordFileName);
|
|
407
490
|
await fsp.writeFile(commandRecordPath, `${JSON.stringify({
|
|
408
491
|
args: commandResult.args,
|
|
409
492
|
command: commandResult.command,
|
|
493
|
+
endedAt: new Date().toISOString(),
|
|
410
494
|
exitCode: commandResult.exitCode,
|
|
411
495
|
phase: providerCommand.phase,
|
|
412
496
|
providerId,
|
|
497
|
+
signal: commandResult.signal,
|
|
413
498
|
stderr: commandResult.stderr,
|
|
499
|
+
stderrPath: `raw/provider-commands/${stderrFileName}`,
|
|
500
|
+
status: commandResult.timedOut ? 'timed_out' : commandResult.exitCode === 0 ? 'completed' : 'failed',
|
|
414
501
|
stdout: commandResult.stdout,
|
|
502
|
+
stdoutPath: `raw/provider-commands/${stdoutFileName}`,
|
|
503
|
+
timedOut: commandResult.timedOut,
|
|
504
|
+
timeoutMs,
|
|
415
505
|
}, null, 2)}\n`, 'utf8');
|
|
416
506
|
if (commandResult.exitCode !== 0) {
|
|
507
|
+
const timedOut = commandResult.timedOut;
|
|
417
508
|
failures.push({
|
|
418
509
|
commandId: providerCommand.id,
|
|
419
|
-
code: 'provider_command_failed',
|
|
510
|
+
code: timedOut ? 'provider_liveness_timeout' : 'provider_command_failed',
|
|
420
511
|
exitCode: commandResult.exitCode,
|
|
421
|
-
message:
|
|
512
|
+
message: timedOut
|
|
513
|
+
? `Evidence provider command ${providerId}/${providerCommand.id} did not finish before the ${timeoutMs}ms timeout.`
|
|
514
|
+
: `Evidence provider command ${providerId}/${providerCommand.id} failed with exit code ${commandResult.exitCode}.`,
|
|
422
515
|
name: 'evidence_provider_command_completed',
|
|
423
|
-
nextAction:
|
|
424
|
-
|
|
516
|
+
nextAction: timedOut
|
|
517
|
+
? `Inspect raw/provider-commands/${commandRecordFileName}, raw/provider-commands/${stdoutFileName}, and raw/provider-commands/${stderrFileName}; fix the provider liveness issue or increase ASL_PROVIDER_COMMAND_TIMEOUT_MS only if the provider is making progress.`
|
|
518
|
+
: `Inspect raw/provider-commands/${commandRecordFileName}, fix the provider command or its environment, then rerun the profile.`,
|
|
519
|
+
nextActionCode: timedOut ? 'fix_provider_liveness' : 'fix_provider_command',
|
|
425
520
|
phase: providerCommand.phase,
|
|
426
521
|
providerId,
|
|
427
522
|
rawPath: `raw/provider-commands/${commandRecordFileName}`,
|
|
@@ -1109,10 +1204,10 @@ function buildRequiredDiagnosticHealthChecks(diagnostics = []) {
|
|
|
1109
1204
|
/**
|
|
1110
1205
|
* Builds scenario health from profile metrics.
|
|
1111
1206
|
*
|
|
1112
|
-
* @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>, diagnostics?: DiagnosticInventoryEntry[], profileEventCount?: number, profileSessionEntryCount?: number, commandTransport?: string, sessionEntries?: Record<string, unknown>[]}} options
|
|
1207
|
+
* @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>, diagnostics?: DiagnosticInventoryEntry[], profileEventCount?: number, profileSessionEntryCount?: number, commandTransport?: string, sessionEntries?: Record<string, unknown>[], sessionFreshness?: ProfileSessionFreshness | null}} options
|
|
1113
1208
|
* @returns {Record<string, unknown>}
|
|
1114
1209
|
*/
|
|
1115
|
-
function buildProfileHealth({ scenario, runId, metrics, diagnostics = [], profileEventCount, profileSessionEntryCount, commandTransport, sessionEntries = [], }) {
|
|
1210
|
+
function buildProfileHealth({ scenario, runId, metrics, diagnostics = [], profileEventCount, profileSessionEntryCount, commandTransport, sessionEntries = [], sessionFreshness = null, }) {
|
|
1116
1211
|
const passed = metrics.status === 'passed';
|
|
1117
1212
|
const metadata = {
|
|
1118
1213
|
failures: typeof metrics.failures === 'number' ? metrics.failures : null,
|
|
@@ -1175,7 +1270,39 @@ function buildProfileHealth({ scenario, runId, metrics, diagnostics = [], profil
|
|
|
1175
1270
|
const commandChecksPassed = commandChecks.every((check) => check.status === 'passed');
|
|
1176
1271
|
const diagnosticChecks = buildRequiredDiagnosticHealthChecks(diagnostics);
|
|
1177
1272
|
const diagnosticChecksPassed = diagnosticChecks.every((check) => check.status === 'passed');
|
|
1178
|
-
const
|
|
1273
|
+
const sessionFreshnessChecks = sessionFreshness
|
|
1274
|
+
? [
|
|
1275
|
+
{
|
|
1276
|
+
name: 'profile_session_freshness',
|
|
1277
|
+
status: sessionFreshness.status === 'fresh'
|
|
1278
|
+
? 'passed'
|
|
1279
|
+
: sessionFreshness.status === 'missing-app-session'
|
|
1280
|
+
? 'warning'
|
|
1281
|
+
: 'failed',
|
|
1282
|
+
source: 'runner',
|
|
1283
|
+
code: sessionFreshness.status === 'fresh'
|
|
1284
|
+
? 'profile_session_fresh'
|
|
1285
|
+
: sessionFreshness.status === 'missing-app-session'
|
|
1286
|
+
? 'profile_session_start_missing'
|
|
1287
|
+
: 'profile_session_stale',
|
|
1288
|
+
message: sessionFreshness.status === 'fresh'
|
|
1289
|
+
? 'App-side profile-session start matched the runner-written session seed.'
|
|
1290
|
+
: sessionFreshness.reason ?? 'App-side profile-session evidence did not match the runner-written session seed.',
|
|
1291
|
+
metadata: {
|
|
1292
|
+
appStartedAt: sessionFreshness.appStartedAt ?? null,
|
|
1293
|
+
nextAction: sessionFreshness.status === 'fresh'
|
|
1294
|
+
? 'No action required.'
|
|
1295
|
+
: 'Clear stale app/session state, reload the expected app bundle, and rerun before treating profile events or metrics as product evidence.',
|
|
1296
|
+
nextActionCode: sessionFreshness.status === 'fresh'
|
|
1297
|
+
? 'none'
|
|
1298
|
+
: 'rerun_with_fresh_profile_session',
|
|
1299
|
+
seedStartedAt: sessionFreshness.seed.startedAt,
|
|
1300
|
+
},
|
|
1301
|
+
},
|
|
1302
|
+
]
|
|
1303
|
+
: [];
|
|
1304
|
+
const sessionFreshnessChecksPassed = sessionFreshnessChecks.every((check) => check.status !== 'failed');
|
|
1305
|
+
const healthPassed = passed && commandChecksPassed && diagnosticChecksPassed && sessionFreshnessChecksPassed;
|
|
1179
1306
|
return assertValidJson({
|
|
1180
1307
|
schemaVersion: '1.0.0',
|
|
1181
1308
|
scenarioId: scenario.name,
|
|
@@ -1193,6 +1320,7 @@ function buildProfileHealth({ scenario, runId, metrics, diagnostics = [], profil
|
|
|
1193
1320
|
: 'Profile events did not complete every expected iteration.',
|
|
1194
1321
|
metadata,
|
|
1195
1322
|
},
|
|
1323
|
+
...sessionFreshnessChecks,
|
|
1196
1324
|
...commandChecks,
|
|
1197
1325
|
...diagnosticChecks,
|
|
1198
1326
|
],
|
|
@@ -1446,6 +1574,141 @@ function resolveProfileSessionEntriesPath({ args, platform }) {
|
|
|
1446
1574
|
}
|
|
1447
1575
|
return null;
|
|
1448
1576
|
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Reads one JSON object candidate from raw command text.
|
|
1579
|
+
*
|
|
1580
|
+
* @param {string} text
|
|
1581
|
+
* @returns {Record<string, unknown>[]}
|
|
1582
|
+
*/
|
|
1583
|
+
function parseJsonObjectsFromText(text) {
|
|
1584
|
+
const matches = text.match(/\{[^{}\n]*\}/gu) ?? [];
|
|
1585
|
+
const objects = [];
|
|
1586
|
+
for (const match of matches) {
|
|
1587
|
+
try {
|
|
1588
|
+
const parsed = JSON.parse(match);
|
|
1589
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1590
|
+
objects.push(parsed);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
catch {
|
|
1594
|
+
// Raw command files can contain shell syntax around JSON payloads.
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return objects;
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Reads an Android profile-session seed from adb AsyncStorage raw artifacts.
|
|
1601
|
+
*
|
|
1602
|
+
* @param {{sidecarRoot: string, runId: string, scenarioName: string}} options
|
|
1603
|
+
* @returns {ProfileSessionSeed | null}
|
|
1604
|
+
*/
|
|
1605
|
+
function readAndroidProfileSessionSeed({ runId, scenarioName, sidecarRoot, }) {
|
|
1606
|
+
const rawDir = path.resolve(sidecarRoot, 'raw');
|
|
1607
|
+
if (!fs.existsSync(rawDir)) {
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
for (const fileName of fs.readdirSync(rawDir).filter((entry) => /^adb-async-storage-write-\d+\.txt$/u.test(entry)).sort()) {
|
|
1611
|
+
const rawText = fs.readFileSync(path.join(rawDir, fileName), 'utf8');
|
|
1612
|
+
for (const candidate of parseJsonObjectsFromText(rawText)) {
|
|
1613
|
+
if (candidate.runId === runId &&
|
|
1614
|
+
candidate.scenario === scenarioName &&
|
|
1615
|
+
typeof candidate.startedAt === 'number' &&
|
|
1616
|
+
Number.isFinite(candidate.startedAt)) {
|
|
1617
|
+
return {
|
|
1618
|
+
runId,
|
|
1619
|
+
scenario: scenarioName,
|
|
1620
|
+
startedAt: candidate.startedAt,
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Reads an iOS profile-session seed from simctl storage artifacts.
|
|
1629
|
+
*
|
|
1630
|
+
* @param {{sidecarRoot: string, runId: string, scenarioName: string}} options
|
|
1631
|
+
* @returns {ProfileSessionSeed | null}
|
|
1632
|
+
*/
|
|
1633
|
+
function readIosProfileSessionSeed({ runId, scenarioName, sidecarRoot, }) {
|
|
1634
|
+
const seedPath = path.resolve(sidecarRoot, 'raw', 'ios-profile-session-seed.json');
|
|
1635
|
+
const seed = readOptionalJsonObject(seedPath);
|
|
1636
|
+
const session = seed?.session;
|
|
1637
|
+
if (!session || typeof session !== 'object' || Array.isArray(session)) {
|
|
1638
|
+
return null;
|
|
1639
|
+
}
|
|
1640
|
+
const record = session;
|
|
1641
|
+
if (record.runId === runId &&
|
|
1642
|
+
record.scenario === scenarioName &&
|
|
1643
|
+
typeof record.startedAt === 'number' &&
|
|
1644
|
+
Number.isFinite(record.startedAt)) {
|
|
1645
|
+
return {
|
|
1646
|
+
runId,
|
|
1647
|
+
scenario: scenarioName,
|
|
1648
|
+
startedAt: record.startedAt,
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
return null;
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Reads the profile-session seed written by a platform sidecar, when present.
|
|
1655
|
+
*
|
|
1656
|
+
* @param {{args: CliArgs, platform: ProfilePlatform, runId: string, scenarioName: string}} options
|
|
1657
|
+
* @returns {ProfileSessionSeed | null}
|
|
1658
|
+
*/
|
|
1659
|
+
function resolveProfileSessionSeed({ args, platform, runId, scenarioName, }) {
|
|
1660
|
+
if (platform === 'android' && typeof args['adb-artifacts'] === 'string') {
|
|
1661
|
+
return readAndroidProfileSessionSeed({
|
|
1662
|
+
runId,
|
|
1663
|
+
scenarioName,
|
|
1664
|
+
sidecarRoot: path.resolve(args['adb-artifacts']),
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
if (platform === 'ios' && typeof args['simctl-artifacts'] === 'string') {
|
|
1668
|
+
return readIosProfileSessionSeed({
|
|
1669
|
+
runId,
|
|
1670
|
+
scenarioName,
|
|
1671
|
+
sidecarRoot: path.resolve(args['simctl-artifacts']),
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
return null;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Compares the sidecar-written profile session to the app-emitted session.
|
|
1678
|
+
*
|
|
1679
|
+
* @param {{seed: ProfileSessionSeed | null, sessionEntries: Record<string, unknown>[]}} options
|
|
1680
|
+
* @returns {ProfileSessionFreshness | null}
|
|
1681
|
+
*/
|
|
1682
|
+
function resolveProfileSessionFreshness({ seed, sessionEntries, }) {
|
|
1683
|
+
if (!seed) {
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
const appStart = sessionEntries.find((entry) => (entry?.kind === 'start' &&
|
|
1687
|
+
entry.runId === seed.runId &&
|
|
1688
|
+
entry.scenario === seed.scenario &&
|
|
1689
|
+
typeof entry.startedAt === 'number' &&
|
|
1690
|
+
Number.isFinite(entry.startedAt)));
|
|
1691
|
+
if (!appStart || typeof appStart.startedAt !== 'number') {
|
|
1692
|
+
return {
|
|
1693
|
+
seed,
|
|
1694
|
+
status: 'missing-app-session',
|
|
1695
|
+
reason: 'The runner wrote a profile-session seed, but no matching app-side start entry was observed.',
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
if (appStart.startedAt !== seed.startedAt) {
|
|
1699
|
+
return {
|
|
1700
|
+
appStartedAt: appStart.startedAt,
|
|
1701
|
+
seed,
|
|
1702
|
+
status: 'stale',
|
|
1703
|
+
reason: 'The app-side profile-session start did not match the runner-written seed.',
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
return {
|
|
1707
|
+
appStartedAt: appStart.startedAt,
|
|
1708
|
+
seed,
|
|
1709
|
+
status: 'fresh',
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1449
1712
|
/**
|
|
1450
1713
|
* Resolves the run id used by rehydrated sidecar evidence.
|
|
1451
1714
|
*
|
|
@@ -2319,6 +2582,16 @@ async function runProfileMobile(args, options) {
|
|
|
2319
2582
|
})
|
|
2320
2583
|
: []),
|
|
2321
2584
|
];
|
|
2585
|
+
const profileSessionSeed = resolveProfileSessionSeed({
|
|
2586
|
+
args,
|
|
2587
|
+
platform: options.platform,
|
|
2588
|
+
runId: evidenceFilterRunId,
|
|
2589
|
+
scenarioName,
|
|
2590
|
+
});
|
|
2591
|
+
const sessionFreshness = resolveProfileSessionFreshness({
|
|
2592
|
+
seed: profileSessionSeed,
|
|
2593
|
+
sessionEntries,
|
|
2594
|
+
});
|
|
2322
2595
|
const runtimeTarget = resolveRuntimeTarget({ args, platform: options.platform });
|
|
2323
2596
|
const metrics = buildMetricsFromProfileEvents({
|
|
2324
2597
|
scenario: scenarioName,
|
|
@@ -2447,6 +2720,7 @@ async function runProfileMobile(args, options) {
|
|
|
2447
2720
|
profileSessionEntryCount: sessionEntries.length,
|
|
2448
2721
|
commandTransport,
|
|
2449
2722
|
sessionEntries,
|
|
2723
|
+
sessionFreshness,
|
|
2450
2724
|
});
|
|
2451
2725
|
const verdict = buildProfileVerdict({ scenario: profileScenario, runId, health, metrics });
|
|
2452
2726
|
const agentSummary = buildAgentSummaryMarkdown({ health, verdict, manifest });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-scenario-loop",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Scenario orchestration and evidence collection for agent-driven software development. Bring your own runner. Keep your scenarios. Keep your evidence.",
|
|
6
6
|
"license": "MIT",
|