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/README.md +77 -64
- package/action/action.yml +149 -66
- package/action/approval-review.mjs +393 -0
- package/action/bootstrap.mjs +104 -0
- package/action/cli.mjs +36 -0
- package/action/emit-review.mjs +336 -0
- package/action/runtime.mjs +166 -0
- package/action/{sticky-comment.mjs → sticky-comment.cjs} +360 -74
- package/dist/api.d.ts +1 -0
- package/dist/api.js +3 -0
- package/dist/cache/store.d.ts +2 -0
- package/dist/cache/store.js +10 -3
- package/dist/cli.d.ts +80 -10
- package/dist/cli.js +302 -13
- package/dist/config.d.ts +4 -0
- package/dist/config.js +32 -4
- package/dist/engine/loop.d.ts +12 -0
- package/dist/engine/loop.js +55 -6
- package/dist/pipeline/budget.d.ts +11 -0
- package/dist/pipeline/budget.js +48 -0
- package/dist/pipeline/contracts.d.ts +12 -0
- package/dist/pipeline/contracts.js +16 -0
- package/dist/pipeline/verify.d.ts +27 -0
- package/dist/pipeline/verify.js +197 -0
- package/dist/report/manifest.d.ts +87 -0
- package/dist/report/manifest.js +143 -0
- package/dist/report/run.d.ts +9 -0
- package/dist/report/run.js +6 -0
- package/dist/review/secrets.d.ts +2 -0
- package/dist/review/secrets.js +4 -0
- package/package.json +7 -7
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
|
|
212
|
+
return finish(filterUntrustedConfig(parsed));
|
|
185
213
|
}
|
|
186
|
-
return
|
|
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
|
|
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
|
|
255
|
+
return finish();
|
|
228
256
|
}
|
|
229
257
|
/**
|
|
230
258
|
* Provider slugs the harness recognizes for `provider.only/ignore/order`
|
package/dist/engine/loop.d.ts
CHANGED
|
@@ -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;
|
package/dist/engine/loop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|