argus-reviewer-e2e 0.2.0 → 0.3.1

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.
package/dist/config.js CHANGED
@@ -47,6 +47,7 @@ const defaults = {
47
47
  triage: 'annotate',
48
48
  lowRiskModel: undefined,
49
49
  findingThreshold: 1.0,
50
+ requestChanges: true,
50
51
  },
51
52
  };
52
53
  export function defineConfig(input) {
@@ -123,6 +124,9 @@ export function resolveConfig(input = {}) {
123
124
  review.lowRiskModel = undefined;
124
125
  }
125
126
  review.findingThreshold = prob01(review.findingThreshold, defaults.review.findingThreshold);
127
+ // Advisory-only escape hatch — only literal `false` opts out; anything
128
+ // else (mis-typed values included) keeps the default-true posture.
129
+ review.requestChanges = review.requestChanges !== false;
126
130
  const resolved = { ...defaults, ...input, provider, sandbox, review };
127
131
  resolved.recordStepCap = posInt(resolved.recordStepCap, DEFAULT_RECORD_STEP_CAP);
128
132
  if (resolved.heal !== 'a0')
@@ -153,6 +157,30 @@ export async function loadConfig(cwd, opts) {
153
157
  const fs = await import('node:fs/promises');
154
158
  const path = await import('node:path');
155
159
  const untrusted = opts.trust === 'untrusted';
160
+ // cacheDir is the documented CLI default '.argus-reviewer-cache' — normalize
161
+ // it to an absolute path here so engine record/replay persistence (gated on
162
+ // config.cacheDir) writes where every other consumer already falls back to.
163
+ const finish = async (input = {}) => {
164
+ const config = resolveConfig(input);
165
+ const cacheDir = path.resolve(cwd, config.cacheDir ?? '.argus-reviewer-cache');
166
+ if (untrusted) {
167
+ // A hostile PR can commit the default path as a symlink and redirect
168
+ // cache writes outside the checkout — fail closed before writers run.
169
+ let isSymlink = false;
170
+ try {
171
+ isSymlink = (await fs.lstat(cacheDir)).isSymbolicLink();
172
+ }
173
+ catch (e) {
174
+ if (e.code !== 'ENOENT')
175
+ throw e;
176
+ }
177
+ if (isSymlink) {
178
+ throw new Error(`untrusted cache directory must not be a symlink: ${cacheDir}`);
179
+ }
180
+ }
181
+ config.cacheDir = cacheDir;
182
+ return config;
183
+ };
156
184
  const names = ['argus-reviewer.config', 'vision-e2e.config'];
157
185
  for (const name of names) {
158
186
  if (untrusted) {
@@ -181,9 +209,9 @@ export async function loadConfig(cwd, opts) {
181
209
  const parsed = JSON.parse(raw);
182
210
  if (untrusted) {
183
211
  opts.note?.(`config: ${name}.json loaded untrusted — honoring ${[...UNTRUSTED_CONFIG_KEYS].join(', ')} only`);
184
- return resolveConfig(filterUntrustedConfig(parsed));
212
+ return finish(filterUntrustedConfig(parsed));
185
213
  }
186
- return resolveConfig(parsed);
214
+ return finish(parsed);
187
215
  }
188
216
  // Always transpile .ts to a temp .mjs rather than importing natively:
189
217
  // Node's built-in type stripping resolves the module type from the
@@ -214,7 +242,7 @@ export async function loadConfig(cwd, opts) {
214
242
  await rm(out, { force: true }).catch(() => undefined);
215
243
  }
216
244
  const exported = mod.default ?? mod;
217
- return resolveConfig(exported);
245
+ return finish(exported);
218
246
  }
219
247
  catch (e) {
220
248
  const code = e.code;
@@ -224,7 +252,7 @@ export async function loadConfig(cwd, opts) {
224
252
  }
225
253
  }
226
254
  }
227
- return resolveConfig();
255
+ return finish();
228
256
  }
229
257
  /**
230
258
  * Provider slugs the harness recognizes for `provider.only/ignore/order`
@@ -62,6 +62,15 @@ export interface RunResult {
62
62
  reason?: string;
63
63
  steps: StepResult[];
64
64
  visionCalls: number;
65
+ cache: CacheStats;
66
+ }
67
+ export interface CacheStats {
68
+ hits: number;
69
+ misses: number;
70
+ heals: number;
71
+ staleEntries: number;
72
+ assertionHits: number;
73
+ assertionMisses: number;
65
74
  }
66
75
  export interface AssertResult extends AssertionResult {
67
76
  cached: boolean;
@@ -81,6 +90,7 @@ export declare class Engine {
81
90
  private _fingerprints;
82
91
  private _assertCache;
83
92
  private _errors;
93
+ private _cacheStats;
84
94
  /** Structured, non-fatal anomalies — journaled as evidence, never thrown. */
85
95
  get errorRecords(): ErrorRecord[];
86
96
  private _note;
@@ -88,6 +98,7 @@ export declare class Engine {
88
98
  /** Assertion verdicts collected/known this run — persist into the flow cache. */
89
99
  get assertEntries(): CachedAssert[];
90
100
  get visionCalls(): number;
101
+ get cacheStats(): CacheStats;
91
102
  record(instruction: string, tdApi?: TestDriverApi, options?: RecordOptions): Promise<RunResult>;
92
103
  replay(flow: FlowCache, options?: ReplayOptions): Promise<RunResult>;
93
104
  /**
@@ -114,5 +125,6 @@ export declare class Engine {
114
125
  private _buildFingerprint;
115
126
  private _regionScreenshot;
116
127
  private _result;
128
+ private _resetCacheStats;
117
129
  }
118
130
  export declare function instructionMatchesNode(instruction: string, nodeSnippet: string): boolean;
@@ -9,6 +9,14 @@ export class Engine {
9
9
  _fingerprints = [];
10
10
  _assertCache = new Map();
11
11
  _errors = [];
12
+ _cacheStats = {
13
+ hits: 0,
14
+ misses: 0,
15
+ heals: 0,
16
+ staleEntries: 0,
17
+ assertionHits: 0,
18
+ assertionMisses: 0,
19
+ };
12
20
  /** Structured, non-fatal anomalies — journaled as evidence, never thrown. */
13
21
  get errorRecords() {
14
22
  return this._errors;
@@ -31,10 +39,14 @@ export class Engine {
31
39
  get visionCalls() {
32
40
  return this._visionCalls;
33
41
  }
42
+ get cacheStats() {
43
+ return { ...this._cacheStats };
44
+ }
34
45
  async record(instruction, tdApi = this._opts.actions, options = {}) {
35
46
  this._visionCalls = 0;
36
47
  this._steps = [];
37
48
  this._fingerprints = [];
49
+ this._resetCacheStats();
38
50
  const cap = options.stepCap ?? this._opts.config.recordStepCap ?? DEFAULT_RECORD_STEP_CAP;
39
51
  let observation = await this._opts.driver.observe({ grid: true });
40
52
  for (let i = 0; i < cap; i++) {
@@ -82,16 +94,18 @@ export class Engine {
82
94
  async replay(flow, options = {}) {
83
95
  this._visionCalls = 0;
84
96
  this._steps = [];
97
+ this._resetCacheStats();
85
98
  for (let i = 0; i < flow.steps.length; i++) {
86
99
  const step = flow.steps[i];
87
100
  if (!step)
88
101
  continue;
89
- let observation = await this._opts.driver.observe({ grid: true });
102
+ const observation = await this._opts.driver.observe({ grid: true });
90
103
  // Diff-invalidated entries skip hash verification entirely and go
91
104
  // straight to the heal path — the diff already told us they're stale.
92
105
  let resolve;
93
106
  if (step.stale !== undefined) {
94
107
  this._note('heal', 'cache entry invalidated by diff', step.stale);
108
+ this._cacheStats.staleEntries++;
95
109
  resolve = { matched: false, currentHash: '', regionMatched: false, a11yMatched: false };
96
110
  }
97
111
  else {
@@ -99,10 +113,12 @@ export class Engine {
99
113
  resolve = new Fingerprint(step).resolve(regionBuffer, observation.a11yYaml);
100
114
  }
101
115
  if (resolve.matched) {
116
+ this._cacheStats.hits++;
102
117
  await this._executeAction(this._opts.actions, step.action);
103
118
  this._steps.push({ instruction: step.instruction, action: step.action.action, ok: true });
104
119
  continue;
105
120
  }
121
+ this._cacheStats.misses++;
106
122
  if (this._opts.ledger.replayOnly || !this._opts.ledger.canSpend(0.001)) {
107
123
  this._steps.push({
108
124
  instruction: step.instruction,
@@ -144,9 +160,10 @@ export class Engine {
144
160
  return this._result(false);
145
161
  }
146
162
  const resolved = await this._resolveAction(action);
147
- const nextObservation = await this._executeAction(this._opts.actions, action);
163
+ await this._executeAction(this._opts.actions, action);
148
164
  const newFingerprint = await this._buildFingerprint(step.instruction, action, resolved, response.model);
149
165
  flow.steps[i] = newFingerprint;
166
+ this._cacheStats.heals++;
150
167
  this._note('heal', 'fingerprint mismatch healed by model', step.instruction);
151
168
  this._steps.push({
152
169
  instruction: step.instruction,
@@ -155,7 +172,6 @@ export class Engine {
155
172
  healed: true,
156
173
  model: response.model,
157
174
  });
158
- observation = nextObservation;
159
175
  }
160
176
  if (options.flowName && this._opts.config.cacheDir) {
161
177
  await saveFlow(this._opts.config.cacheDir, options.flowName, flow.steps);
@@ -177,6 +193,7 @@ export class Engine {
177
193
  const regionBuffer = await this._regionScreenshot(cached.bbox);
178
194
  const resolve = new Fingerprint(cached).resolve(regionBuffer, observation.a11yYaml);
179
195
  if (resolve.matched) {
196
+ this._cacheStats.hits++;
180
197
  return {
181
198
  ok: true,
182
199
  reason: undefined,
@@ -186,6 +203,7 @@ export class Engine {
186
203
  model: undefined,
187
204
  };
188
205
  }
206
+ this._cacheStats.misses++;
189
207
  if (this._opts.ledger.replayOnly || !this._opts.ledger.canSpend(0.001)) {
190
208
  return {
191
209
  ok: false,
@@ -197,6 +215,13 @@ export class Engine {
197
215
  };
198
216
  }
199
217
  }
218
+ else if (cached?.stale !== undefined) {
219
+ this._cacheStats.misses++;
220
+ this._cacheStats.staleEntries++;
221
+ }
222
+ else {
223
+ this._cacheStats.misses++;
224
+ }
200
225
  // A diff-invalidated (stale) entry is a fresh ground, not a heal — heal
201
226
  // implies the fingerprint *checked out as wrong*, stale means we never
202
227
  // verified it. Keeping the kind split honest keeps the heal-rate signal
@@ -205,8 +230,11 @@ export class Engine {
205
230
  const isStale = cached !== undefined && cached.stale !== undefined;
206
231
  const useHeal = cached !== undefined && !isStale;
207
232
  const primary = await this._locateWithModel(instruction, observation, useHeal);
208
- if (primary.ok)
233
+ if (primary.ok) {
234
+ if (useHeal)
235
+ this._cacheStats.heals++;
209
236
  return primary;
237
+ }
210
238
  // Semantic escalation fallback (issue #14): the model answered but could
211
239
  // not ground — provider-level OpenRouter fallback only covers unavailable
212
240
  // models, not bad answers. Retry once with escalation_model as primary on
@@ -221,7 +249,10 @@ export class Engine {
221
249
  }
222
250
  this._note('locate', 'escalating to fallback model', `failed=${failedModel} esc=${esc} reason=${(primary.reason ?? '').slice(0, 80)}`);
223
251
  const fresh = await this._opts.driver.observe({ grid: true });
224
- return this._locateWithModel(instruction, fresh, useHeal, esc);
252
+ const escalated = await this._locateWithModel(instruction, fresh, useHeal, esc);
253
+ if (escalated.ok && useHeal)
254
+ this._cacheStats.heals++;
255
+ return escalated;
225
256
  }
226
257
  /**
227
258
  * One locate attempt against a specific model: initial call plus the
@@ -388,8 +419,10 @@ export class Engine {
388
419
  const key = `${question}${a11yHash}`;
389
420
  const cached = this._assertCache.get(key);
390
421
  if (cached) {
422
+ this._cacheStats.assertionHits++;
391
423
  return { verdict: cached.verdict, reasoning: cached.reasoning, cached: true };
392
424
  }
425
+ this._cacheStats.assertionMisses++;
393
426
  if (this._opts.ledger.replayOnly || !this._opts.ledger.canSpend(0.001)) {
394
427
  return { verdict: 'fail', reasoning: 'budget exceeded or replay-only', cached: false };
395
428
  }
@@ -621,7 +654,23 @@ export class Engine {
621
654
  return Buffer.from(raw);
622
655
  }
623
656
  _result(ok, reason) {
624
- return { ok, steps: this._steps, visionCalls: this._visionCalls, ...(reason ? { reason } : {}) };
657
+ return {
658
+ ok,
659
+ steps: this._steps,
660
+ visionCalls: this._visionCalls,
661
+ cache: this.cacheStats,
662
+ ...(reason ? { reason } : {}),
663
+ };
664
+ }
665
+ _resetCacheStats() {
666
+ this._cacheStats = {
667
+ hits: 0,
668
+ misses: 0,
669
+ heals: 0,
670
+ staleEntries: 0,
671
+ assertionHits: 0,
672
+ assertionMisses: 0,
673
+ };
625
674
  }
626
675
  }
627
676
  /**
@@ -0,0 +1,11 @@
1
+ import type { CallCost } from '../vision/cost.js';
2
+ import type { BudgetSummary, LaneId } from '../report/manifest.js';
3
+ export interface BudgetOptions {
4
+ limitUsd?: number;
5
+ maxDurationMs?: number;
6
+ maxTasks?: number;
7
+ }
8
+ export declare function createBudget(lane: LaneId, options?: BudgetOptions): BudgetSummary;
9
+ export declare function addProviderCalls(budget: BudgetSummary, calls: CallCost[] | undefined): BudgetSummary;
10
+ export declare function addA0Task(budget: BudgetSummary, elapsedMs: number, metered: boolean): BudgetSummary;
11
+ export declare function budgetCanSpend(budget: BudgetSummary, nextCostUsd: number): boolean;
@@ -0,0 +1,48 @@
1
+ export function createBudget(lane, options = {}) {
2
+ void lane;
3
+ return {
4
+ limitUsd: options.limitUsd,
5
+ spentUsd: 0,
6
+ exceeded: false,
7
+ maxDurationMs: options.maxDurationMs,
8
+ elapsedMs: 0,
9
+ maxTasks: options.maxTasks,
10
+ tasks: 0,
11
+ };
12
+ }
13
+ /**
14
+ * USD spend is summed as IEEE-754 doubles, so a lane that lands exactly on its
15
+ * cap (0.1 + 0.2 === 0.30000000000000004, not 0.3) would otherwise read as over
16
+ * budget and abort a run that never exceeded the cap. Compare with a tolerance
17
+ * far below the 1e-6 precision reports render, and far above double noise here.
18
+ */
19
+ const USD_EPSILON = 1e-9;
20
+ function overLimit(spentUsd, limitUsd) {
21
+ return spentUsd > limitUsd + USD_EPSILON;
22
+ }
23
+ export function addProviderCalls(budget, calls) {
24
+ if (calls === undefined || calls.length === 0)
25
+ return budget;
26
+ const spentUsd = calls.reduce((sum, call) => sum + call.costUsd, 0);
27
+ const exceeded = budget.exceeded ||
28
+ (budget.limitUsd !== undefined && overLimit(budget.spentUsd + spentUsd, budget.limitUsd));
29
+ return {
30
+ ...budget,
31
+ spentUsd: budget.spentUsd + spentUsd,
32
+ exceeded,
33
+ };
34
+ }
35
+ export function addA0Task(budget, elapsedMs, metered) {
36
+ const tasks = budget.tasks + 1;
37
+ const elapsed = budget.elapsedMs + elapsedMs;
38
+ const exceeded = budget.exceeded ||
39
+ (budget.maxTasks !== undefined && tasks > budget.maxTasks) ||
40
+ (budget.maxDurationMs !== undefined && elapsed > budget.maxDurationMs) ||
41
+ (budget.limitUsd !== undefined && metered && overLimit(budget.spentUsd, budget.limitUsd));
42
+ return { ...budget, tasks, elapsedMs: elapsed, exceeded };
43
+ }
44
+ export function budgetCanSpend(budget, nextCostUsd) {
45
+ return (!budget.exceeded &&
46
+ (budget.limitUsd === undefined ||
47
+ budget.spentUsd + nextCostUsd <= budget.limitUsd + USD_EPSILON));
48
+ }
@@ -0,0 +1,12 @@
1
+ import { LANE_IDS, type LaneId } from '../report/manifest.js';
2
+ export { LANE_IDS };
3
+ export type { LaneId };
4
+ export interface LaneSelection {
5
+ review: boolean;
6
+ flow: boolean;
7
+ app: boolean;
8
+ a0: boolean;
9
+ }
10
+ export declare function defaultLaneSelection(): LaneSelection;
11
+ export declare function selectionFromFlags(flags: Partial<Record<LaneId, boolean>>): LaneSelection;
12
+ export declare function selectedLanes(selection: LaneSelection): LaneId[];
@@ -0,0 +1,16 @@
1
+ import { LANE_IDS } from '../report/manifest.js';
2
+ export { LANE_IDS };
3
+ export function defaultLaneSelection() {
4
+ return { review: true, flow: false, app: false, a0: false };
5
+ }
6
+ export function selectionFromFlags(flags) {
7
+ const selection = defaultLaneSelection();
8
+ for (const lane of LANE_IDS) {
9
+ if (flags[lane] !== undefined)
10
+ selection[lane] = flags[lane];
11
+ }
12
+ return selection;
13
+ }
14
+ export function selectedLanes(selection) {
15
+ return LANE_IDS.filter((lane) => selection[lane]);
16
+ }
@@ -0,0 +1,27 @@
1
+ import { type LaneId, type RunIdentity, type RunManifest } from '../report/manifest.js';
2
+ import { type BudgetOptions } from './budget.js';
3
+ import { type LaneSelection } from './contracts.js';
4
+ export interface VerifyRunners {
5
+ review: () => Promise<number>;
6
+ flow?: (url: string) => Promise<number>;
7
+ app?: () => Promise<number>;
8
+ a0?: () => Promise<number>;
9
+ }
10
+ export interface VerifyInput {
11
+ cwd: string;
12
+ runId: string;
13
+ reportDir: string;
14
+ identity: RunIdentity;
15
+ selection: LaneSelection;
16
+ runners: VerifyRunners;
17
+ budgets?: Partial<Record<LaneId, BudgetOptions>>;
18
+ flowUrl?: string;
19
+ flowUnavailableReason?: string;
20
+ }
21
+ export interface VerifyResult {
22
+ manifest: RunManifest;
23
+ exitCode: number;
24
+ }
25
+ /** Run selected lanes and return one stable manifest without owning subprocesses. */
26
+ export declare function runVerify(input: VerifyInput): Promise<VerifyResult>;
27
+ export declare function selectedManifestLanes(selection: LaneSelection): LaneId[];
@@ -0,0 +1,197 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { aggregateLanes, addProviderUsage, emptyLane, emptyUsage, isHeadBindingConclusive, MANIFEST_SCHEMA_VERSION, } from '../report/manifest.js';
4
+ import { addProviderCalls, createBudget } from './budget.js';
5
+ import { selectedLanes } from './contracts.js';
6
+ async function readJson(path) {
7
+ try {
8
+ return JSON.parse(await readFile(path, 'utf8'));
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ function relativeReport(cwd, reportDir, name) {
15
+ return relative(cwd, join(reportDir, name)) || name;
16
+ }
17
+ function laneStart(lane, at = new Date()) {
18
+ return { ...lane, startedAt: at.toISOString() };
19
+ }
20
+ function laneEnd(lane, at = new Date()) {
21
+ return { ...lane, finishedAt: at.toISOString() };
22
+ }
23
+ function reportStatus(code, report, inconclusive) {
24
+ if (report?.skipped === true)
25
+ return 'skipped';
26
+ if (inconclusive)
27
+ return 'inconclusive';
28
+ if (report?.ok === true && code === 0)
29
+ return 'passed';
30
+ return 'failed';
31
+ }
32
+ function providerUsageFromCalls(calls) {
33
+ const usage = emptyUsage('openrouter');
34
+ return calls === undefined ? usage : addProviderUsage(usage, calls);
35
+ }
36
+ function flowCalls(report) {
37
+ return report?.tests?.flatMap((test) => test.calls ?? []) ?? [];
38
+ }
39
+ function flowUsage(report) {
40
+ const calls = flowCalls(report);
41
+ const usage = providerUsageFromCalls(calls);
42
+ if (report?.totals !== undefined && calls.length === 0) {
43
+ return {
44
+ ...usage,
45
+ calls: report.totals.visionCalls ?? 0,
46
+ tokens: 0,
47
+ costUsd: report.totals.visionCostUsd ?? 0,
48
+ };
49
+ }
50
+ return usage;
51
+ }
52
+ function budgetFor(lane, input, report) {
53
+ const options = input.budgets?.[lane] ?? {};
54
+ let budget = createBudget(lane, options);
55
+ if (lane === 'review') {
56
+ budget = addProviderCalls(budget, report?.calls);
57
+ }
58
+ else if (lane === 'flow') {
59
+ budget = addProviderCalls(budget, flowCalls(report));
60
+ }
61
+ return budget;
62
+ }
63
+ function baseLane(lane, selected) {
64
+ return emptyLane(lane, selected);
65
+ }
66
+ /** Run selected lanes and return one stable manifest without owning subprocesses. */
67
+ export async function runVerify(input) {
68
+ const startedAt = new Date();
69
+ const lanes = Object.fromEntries(['review', 'flow', 'app', 'a0'].map((lane) => [
70
+ lane,
71
+ baseLane(lane, input.selection[lane]),
72
+ ]));
73
+ const reviewSelected = input.selection.review;
74
+ if (reviewSelected) {
75
+ lanes.review = laneStart(lanes.review, startedAt);
76
+ let code = 1;
77
+ let runnerError;
78
+ try {
79
+ code = await input.runners.review();
80
+ }
81
+ catch (error) {
82
+ runnerError = error instanceof Error ? error.message : String(error);
83
+ ctxError(runnerError);
84
+ }
85
+ const report = await readJson(join(input.reportDir, 'code-review.json'));
86
+ const inconclusive = report !== undefined &&
87
+ report.skipped !== true &&
88
+ !isHeadBindingConclusive(report.headBinding);
89
+ const status = runnerError !== undefined ? 'failed' : reportStatus(code, report, inconclusive);
90
+ lanes.review = laneEnd({
91
+ ...lanes.review,
92
+ status,
93
+ reportPath: report === undefined
94
+ ? undefined
95
+ : relativeReport(input.cwd, input.reportDir, 'code-review.json'),
96
+ model: report?.model,
97
+ summary: report?.summary,
98
+ reason: runnerError ??
99
+ (report === undefined
100
+ ? 'code-review.json was not produced'
101
+ : status === 'passed'
102
+ ? undefined
103
+ : report.summary),
104
+ usage: providerUsageFromCalls(report?.calls),
105
+ budget: budgetFor('review', input, report),
106
+ headBinding: report?.headBinding,
107
+ });
108
+ }
109
+ if (input.selection.flow) {
110
+ lanes.flow = laneStart(lanes.flow, startedAt);
111
+ if (input.runners.flow === undefined) {
112
+ lanes.flow = laneEnd({
113
+ ...lanes.flow,
114
+ status: 'unavailable',
115
+ reason: 'flow runner is not available in this build',
116
+ });
117
+ }
118
+ else if (input.flowUrl === undefined || input.flowUrl === '') {
119
+ lanes.flow = laneEnd({
120
+ ...lanes.flow,
121
+ status: 'blocked',
122
+ reason: input.flowUnavailableReason ?? 'no application target configured',
123
+ });
124
+ }
125
+ else {
126
+ let code = 1;
127
+ let runnerError;
128
+ try {
129
+ code = await input.runners.flow(input.flowUrl);
130
+ }
131
+ catch (error) {
132
+ runnerError = error instanceof Error ? error.message : String(error);
133
+ ctxError(runnerError);
134
+ }
135
+ const report = await readJson(join(input.reportDir, 'run.json'));
136
+ lanes.flow = laneEnd({
137
+ ...lanes.flow,
138
+ status: runnerError !== undefined ? 'failed' : reportStatus(code, report, false),
139
+ reportPath: report === undefined ? undefined : relativeReport(input.cwd, input.reportDir, 'run.json'),
140
+ summary: report === undefined ? undefined : report.ok === true ? 'flow passed' : 'flow failed',
141
+ reason: runnerError ??
142
+ (report === undefined
143
+ ? 'run.json was not produced'
144
+ : report.ok === true
145
+ ? undefined
146
+ : 'flow failed'),
147
+ usage: flowUsage(report),
148
+ budget: budgetFor('flow', input, report),
149
+ });
150
+ }
151
+ }
152
+ for (const lane of ['app', 'a0']) {
153
+ if (!input.selection[lane])
154
+ continue;
155
+ const runner = input.runners[lane];
156
+ lanes[lane] = laneStart(lanes[lane], startedAt);
157
+ if (runner === undefined) {
158
+ lanes[lane] = laneEnd({
159
+ ...lanes[lane],
160
+ status: 'unavailable',
161
+ reason: `${lane} runner is not available in this build`,
162
+ });
163
+ continue;
164
+ }
165
+ let code = 1;
166
+ let runnerError;
167
+ try {
168
+ code = await runner();
169
+ }
170
+ catch (error) {
171
+ runnerError = error instanceof Error ? error.message : String(error);
172
+ ctxError(runnerError);
173
+ }
174
+ lanes[lane] = laneEnd({
175
+ ...lanes[lane],
176
+ status: code === 0 ? 'passed' : 'failed',
177
+ reason: runnerError ?? (code === 0 ? undefined : `${lane} runner exited ${code}`),
178
+ });
179
+ }
180
+ const aggregate = aggregateLanes(lanes);
181
+ const manifest = {
182
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
183
+ runId: input.runId,
184
+ startedAt: startedAt.toISOString(),
185
+ finishedAt: new Date().toISOString(),
186
+ identity: input.identity,
187
+ lanes,
188
+ aggregate,
189
+ };
190
+ return { manifest, exitCode: aggregate.ok ? 0 : 1 };
191
+ }
192
+ function ctxError(message) {
193
+ console.error(`verify lane error: ${message}`);
194
+ }
195
+ export function selectedManifestLanes(selection) {
196
+ return selectedLanes(selection);
197
+ }