📦 EqualifyEverything / equalify-iris

📄 regression.ts · 89 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from "node:fs";
import { join, extname } from "node:path";
import type { Paths } from "../store/paths.ts";
import type { Fragment } from "./fragment.ts";

const MAX_FIXTURES_PER_AGENT = 5;

// One captured regression case: an agent's accepted output for a real source
// image. Re-checked before any update/merge to that agent (see the regression
// gate in feedback.ts) so the change can't break a use the agent already handled.
export interface FixtureCase {
  agent: string; // agent file, e.g. "table.md"
  image_file: string; // fixture image filename within the agent's fixtures dir
  source_image: string; // original source image name
  accepted_html: string; // the agent's accepted output for that image
  captured_at: string;
  session: string;
}

// Resolve the on-disk input file for a source image name. Inputs are stored as
// "<0001>__<original-name>" (see orchestrator.enumerateInputs).
function inputFileFor(inputFiles: string[], imageName: string): string | null {
  return (
    inputFiles.find((fn) => fn.includes("__") && fn.split("__").slice(1).join("__") === imageName) ?? null
  );
}

// Keep only the most recent MAX_FIXTURES_PER_AGENT cases (by filename, which is
// timestamp-ordered); delete older case json + their image files. Best-effort.
function pruneFixtures(dir: string): void {
  const cases = readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
  const excess = cases.slice(0, Math.max(0, cases.length - MAX_FIXTURES_PER_AGENT));
  for (const caseFile of excess) {
    try {
      const c = JSON.parse(readFileSync(join(dir, caseFile), "utf8")) as FixtureCase;
      if (c.image_file && existsSync(join(dir, c.image_file))) rmSync(join(dir, c.image_file), { force: true });
      rmSync(join(dir, caseFile), { force: true });
    } catch {
      // ignore a malformed/locked fixture during pruning
    }
  }
}

// PRD §7.12 (auto-capture on accept): when a session is accepted (closed), save a
// regression fixture for each agent that produced accepted output — the
// triggering source image plus the agent's accepted HTML for it. File-only; no
// model calls. Reconciled (cross-image) fragments are skipped (no single source).
export function captureFixtures(paths: Paths, sessionId: string, fragments: Fragment[]): void {
  const byAgent = new Map<string, Fragment[]>();
  for (const f of fragments) {
    if (f.reconciled || !f.agent) continue;
    const list = byAgent.get(f.agent) ?? [];
    list.push(f);
    byAgent.set(f.agent, list);
  }

  const inputDir = paths.sessionInput(sessionId);
  const inputFiles = existsSync(inputDir) ? readdirSync(inputDir) : [];

  for (const [agentFile, frs] of byAgent) {
    const rep = frs[0]; // representative source image for this agent
    const inputFile = inputFileFor(inputFiles, rep.image);
    if (!inputFile) continue;

    const dir = paths.agentFixtures(agentFile);
    mkdirSync(dir, { recursive: true });

    // Accepted HTML = this agent's fragments on the representative image, in order.
    const acceptedHtml = frs.filter((f) => f.image === rep.image).map((f) => f.innerHtml).join("\n\n");
    const stamp = new Date().toISOString().replace(/[:.]/g, "-");
    const imageOut = `case-${stamp}${extname(rep.image) || ".png"}`;
    try {
      copyFileSync(join(inputDir, inputFile), join(dir, imageOut));
    } catch {
      continue; // could not copy the source image; skip this fixture
    }
    const caseObj: FixtureCase = {
      agent: agentFile,
      image_file: imageOut,
      source_image: rep.image,
      accepted_html: acceptedHtml,
      captured_at: new Date().toISOString(),
      session: sessionId,
    };
    writeFileSync(join(dir, `case-${stamp}.json`), JSON.stringify(caseObj, null, 2));
    pruneFixtures(dir);
  }
}