Files
ruvnet__ruflo/v3/@claude-flow/browser/tests/workflow-compiler.test.ts
ruv 350bcf0606 feat(browser): #2041 ADR-122 Phase 7 — Workflow Compiler + production-aware UCT
Phase 7 (substrate-layer compiler):
- domain/workflow.ts — CompiledWorkflow schema with WorkflowStep (primary
  SelectorSpec + ordered fallback chain), WorkflowRequirements (sessionCapsule,
  origins[], taskClass), WorkflowGuards (irreversibleAction,
  requiresUserConfirmation), WorkflowReplay (maxRetries, timeoutMs).
  ProductionUctSignals/Weights for the production-aware UCT formula.
- application/workflow-compiler.ts — WorkflowCompiler.compile() walks a signed
  trajectory envelope's steps, extracts the primary locator from each step
  input, derives an ordered selector fallback chain from observed snapshot
  metadata (role+name → text → ref), classifies highest-risk action via
  Phase 6 RiskClassifier, gates requirements + guards. toYaml() emits
  deterministic, parseable YAML for VCS check-in.
- application/production-uct.ts — productionUct(input, weights) implements
  the full substrate scoring formula:
    score = Q + C·√(ln(N)/n) + λ_R·replayability
            − λ_risk·risk − μ_cost·cost − α_auth·auth_fragility
  Unvisited branches still return +Infinity for parity with Phase 4 UCB1.
  blendQ() linearly mixes scorer value + replay-success-rate.
- 12 new tests: compile login flow + fallback chain, financial-action
  guard escalation, origin extraction, deterministic YAML output, UCT
  invariants (infinity for unvisited, prefers safe over risky, penalises
  cost, rewards replayability, custom weights honoured), blendQ linearity.

Renames in src/index.ts re-export: WorkflowStep → CompiledWorkflowStep
(avoids collision with workflow-templates.ts's pre-existing WorkflowStep).

230/230 tests passing (+12 for Phase 7). Build + typecheck clean.

Beyond SOTA: no other web agent compiles MCTS traces into signed, replayable
YAML workflows with selector fallback graphs. The compiler is the "output of
the substrate" — what makes browser experience truly reusable.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-18 19:28:31 -04:00

202 lines
8.4 KiB
TypeScript

/**
* @claude-flow/browser - Workflow Compiler + Production-Aware UCT Tests (ADR-122 Phase 7)
*
* Acceptance criteria covered:
* - Compile a successful trajectory → CompiledWorkflow with selector fallback per step
* - Detect highest-risk action in trajectory; gate requirements + guards accordingly
* - Origins extracted from `open` steps populate requirements.origins
* - YAML serialisation is deterministic + parseable
* - Production-aware UCT formula respects penalties (low Q + high risk < high Q + low risk)
* - Unvisited branches still return Infinity (parity with Phase 4 UCB1)
* - Cost penalty dominates exploitation when cost is high
*/
import { describe, it, expect } from 'vitest';
import { WorkflowCompiler } from '../src/application/workflow-compiler.js';
import { productionUct, blendQ } from '../src/application/production-uct.js';
import { sealTrajectory } from '../src/application/signed-trajectory-service.js';
import { generateWitnessKey } from '../src/infrastructure/witness-signer.js';
import type { BrowserTrajectory, Snapshot } from '../src/domain/types.js';
import { DEFAULT_PRODUCTION_UCT_WEIGHTS } from '../src/domain/workflow.js';
const sharedKey = generateWitnessKey();
function buildLoginTrajectory(): BrowserTrajectory {
const snapshot: Snapshot = {
tree: { role: 'main', children: [] },
refs: {
'@e1': { role: 'textbox', name: 'Email' },
'@e2': { role: 'textbox', name: 'Password' },
'@e3': { role: 'button', name: 'Sign in' },
},
url: 'https://example.com/login',
title: 'Login',
timestamp: '2026-05-18T20:00:00Z',
};
return {
id: 'wf-traj-1',
sessionId: 's',
goal: 'Sign in to example.com',
startedAt: '2026-05-18T20:00:00Z',
completedAt: '2026-05-18T20:00:05Z',
success: true,
verdict: 'ok',
steps: [
{ action: 'open', input: { url: 'https://example.com/login' }, result: { success: true }, timestamp: '2026-05-18T20:00:00Z' },
{ action: 'snapshot', input: {}, result: { success: true }, snapshot, timestamp: '2026-05-18T20:00:01Z' },
{ action: 'fill', input: { target: '@e1', value: 'user@example.com' }, result: { success: true }, snapshot, timestamp: '2026-05-18T20:00:02Z' },
{ action: 'fill', input: { target: '@e2', value: 'hunter2' }, result: { success: true }, snapshot, timestamp: '2026-05-18T20:00:03Z' },
{ action: 'click', input: { target: '@e3' }, result: { success: true }, snapshot, timestamp: '2026-05-18T20:00:04Z' },
],
};
}
describe('WorkflowCompiler', () => {
it('compiles a successful trajectory into a typed workflow', () => {
const compiler = new WorkflowCompiler();
const { envelope } = sealTrajectory({ trajectory: buildLoginTrajectory(), witnessKey: sharedKey });
const wf = compiler.compile({
id: 'example-login',
goal: 'Sign in to example.com',
trajectoryEnvelope: envelope,
});
expect(wf.workflow).toBe('example-login');
expect(wf.steps).toHaveLength(5);
expect(wf.requirements.origins).toContain('https://example.com');
});
it('detects draft-write as the highest risk in a login trajectory', () => {
const compiler = new WorkflowCompiler();
const { envelope } = sealTrajectory({ trajectory: buildLoginTrajectory(), witnessKey: sharedKey });
const wf = compiler.compile({
id: 'login-flow', goal: 'login flow', trajectoryEnvelope: envelope,
});
expect(wf.requirements.taskClass).toBe('draft-write');
expect(wf.guards.requiresUserConfirmation).toBe(false);
expect(wf.guards.irreversibleAction).toBe(false);
expect(wf.requirements.sessionCapsule).toBe(true);
});
it('escalates guards when trajectory contains a payment action', () => {
const compiler = new WorkflowCompiler();
const traj = buildLoginTrajectory();
traj.steps.push({
action: 'click',
input: { target: '@e9' },
result: { success: true },
timestamp: '2026-05-18T20:01:00Z',
});
traj.goal = 'Pay invoice';
const { envelope } = sealTrajectory({ trajectory: traj, witnessKey: sharedKey });
const wf = compiler.compile({ id: 'pay-invoice', goal: traj.goal, trajectoryEnvelope: envelope });
expect(wf.requirements.taskClass).toBe('financial');
expect(wf.guards.irreversibleAction).toBe(true);
expect(wf.guards.requiresUserConfirmation).toBe(true);
});
it('produces selector fallback chain from snapshot metadata', () => {
const compiler = new WorkflowCompiler();
const { envelope } = sealTrajectory({ trajectory: buildLoginTrajectory(), witnessKey: sharedKey });
const wf = compiler.compile({ id: 'login', goal: 'login', trajectoryEnvelope: envelope });
const clickStep = wf.steps.find(s => s.action === 'click')!;
// Primary is @e3 ref; fallback should include role:button name:Sign in
expect(clickStep.target).toMatchObject({ strategy: 'ref', value: '@e3' });
expect(clickStep.fallback.some(f => f.strategy === 'role' && f.value === 'button' && f.name === 'Sign in')).toBe(true);
expect(clickStep.fallback.some(f => f.strategy === 'text' && f.value === 'Sign in')).toBe(true);
});
it('renders deterministic YAML', () => {
const compiler = new WorkflowCompiler();
const { envelope } = sealTrajectory({ trajectory: buildLoginTrajectory(), witnessKey: sharedKey });
const wf = compiler.compile({
id: 'login', goal: 'login', trajectoryEnvelope: envelope, compiledAt: '2026-05-18T21:00:00Z',
});
const yaml = compiler.toYaml(wf);
expect(yaml).toContain('workflow: login');
expect(yaml).toContain('version: 1');
expect(yaml).toContain('compiledAt: 2026-05-18T21:00:00Z');
expect(yaml).toContain(' - action: click');
expect(yaml).toContain(' target:');
expect(yaml).toContain(' strategy: ref');
expect(yaml).toContain(' fallback:');
expect(yaml).toContain(' - strategy: role');
});
});
describe('productionUct', () => {
const visited = (visits: number, parentVisits: number) => ({ visits, parentVisits });
it('returns Infinity for unvisited branches', () => {
const score = productionUct({
...visited(0, 10),
signals: { qValue: 0, replayability: 0, risk: 0, costUsd: 0, authFragility: 0 },
});
expect(score).toBe(Infinity);
});
it('prefers high-Q low-risk branches over high-Q high-risk', () => {
const safe = productionUct({
...visited(5, 20),
signals: { qValue: 0.8, replayability: 0.5, risk: 0.0, costUsd: 0.01, authFragility: 0.1 },
});
const risky = productionUct({
...visited(5, 20),
signals: { qValue: 0.8, replayability: 0.5, risk: 0.9, costUsd: 0.01, authFragility: 0.1 },
});
expect(safe).toBeGreaterThan(risky);
});
it('penalizes high cost', () => {
const cheap = productionUct({
...visited(5, 20),
signals: { qValue: 0.6, replayability: 0.3, risk: 0.1, costUsd: 0.01, authFragility: 0.1 },
});
const expensive = productionUct({
...visited(5, 20),
signals: { qValue: 0.6, replayability: 0.3, risk: 0.1, costUsd: 0.5, authFragility: 0.1 },
});
expect(cheap).toBeGreaterThan(expensive);
expect(cheap - expensive).toBeCloseTo(
DEFAULT_PRODUCTION_UCT_WEIGHTS.costPenalty * (0.5 - 0.01),
4,
);
});
it('rewards high replayability', () => {
const replayable = productionUct({
...visited(5, 20),
signals: { qValue: 0.5, replayability: 1.0, risk: 0.1, costUsd: 0.01, authFragility: 0.1 },
});
const oneShot = productionUct({
...visited(5, 20),
signals: { qValue: 0.5, replayability: 0.0, risk: 0.1, costUsd: 0.01, authFragility: 0.1 },
});
expect(replayable).toBeGreaterThan(oneShot);
});
it('honours custom weights', () => {
const weights = { ...DEFAULT_PRODUCTION_UCT_WEIGHTS, riskPenalty: 0 };
const risky = productionUct({
...visited(5, 20),
signals: { qValue: 0.5, replayability: 0.5, risk: 1.0, costUsd: 0.01, authFragility: 0.0 },
}, weights);
const safe = productionUct({
...visited(5, 20),
signals: { qValue: 0.5, replayability: 0.5, risk: 0.0, costUsd: 0.01, authFragility: 0.0 },
}, weights);
// With riskPenalty=0 the two should now tie
expect(risky).toBeCloseTo(safe, 6);
});
});
describe('blendQ', () => {
it('returns raw scorer value when replaySuccessRate is undefined', () => {
expect(blendQ(0.7)).toBe(0.7);
});
it('linearly blends scorer + replay-success', () => {
expect(blendQ(0.8, 0.6)).toBeCloseTo(0.7 * 0.8 + 0.3 * 0.6, 6);
});
});