debutly 0.0.0-stage → 0.2.0
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/LICENSE +662 -0
- package/README.md +192 -2
- package/bin/debutly.mjs +8 -0
- package/drizzle/0000_init.sql +107 -0
- package/drizzle/0001_predictions.sql +14 -0
- package/drizzle/0002_dashboard.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +722 -0
- package/drizzle/meta/0001_snapshot.json +821 -0
- package/drizzle/meta/0002_snapshot.json +942 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +83 -4
- package/src/agent/describe.ts +29 -0
- package/src/agent/effect.ts +94 -0
- package/src/agent/give-up.ts +42 -0
- package/src/agent/label.ts +19 -0
- package/src/agent/loop.ts +302 -0
- package/src/agent/parse-reply.ts +127 -0
- package/src/agent/prompt.ts +85 -0
- package/src/agent/run-agent.ts +68 -0
- package/src/artifacts/db.ts +55 -0
- package/src/artifacts/s3.ts +36 -0
- package/src/artifacts/store.ts +26 -0
- package/src/bin.ts +140 -0
- package/src/browser/ax.ts +77 -0
- package/src/browser/execute.ts +401 -0
- package/src/browser/observe.ts +62 -0
- package/src/browser/reflow.ts +73 -0
- package/src/browser/samples.ts +119 -0
- package/src/browser/scan-script.ts +108 -0
- package/src/browser/settle.ts +32 -0
- package/src/browser/signals.ts +73 -0
- package/src/cancel.ts +20 -0
- package/src/cli.ts +281 -0
- package/src/config.ts +62 -0
- package/src/cost/estimate.ts +32 -0
- package/src/cost/prices.ts +32 -0
- package/src/cost/tiers.ts +27 -0
- package/src/costs.ts +96 -0
- package/src/crew/run-unit.ts +62 -0
- package/src/detect/findings.ts +649 -0
- package/src/detect/wcag.ts +38 -0
- package/src/doctor.ts +145 -0
- package/src/hear-cli.ts +137 -0
- package/src/hear.ts +309 -0
- package/src/import.ts +108 -0
- package/src/inbox/mailpit.ts +46 -0
- package/src/inbox/types.ts +10 -0
- package/src/init.ts +184 -0
- package/src/llm/anthropic.ts +41 -0
- package/src/llm/credits.ts +35 -0
- package/src/llm/factory.ts +65 -0
- package/src/llm/meter.ts +39 -0
- package/src/llm/openai-compatible.ts +90 -0
- package/src/llm/openrouter.ts +9 -0
- package/src/llm/scripted.ts +18 -0
- package/src/llm/types.ts +36 -0
- package/src/perceive.ts +150 -0
- package/src/perception/aggregate.ts +111 -0
- package/src/perception/compare.ts +77 -0
- package/src/perception/evaluate.ts +83 -0
- package/src/perception/exhibits.ts +146 -0
- package/src/perception/market-context.ts +50 -0
- package/src/perception/run-perception.ts +178 -0
- package/src/persona/generate.ts +92 -0
- package/src/persona/inclusion.ts +53 -0
- package/src/persona/language.ts +59 -0
- package/src/persona/people.ts +32 -0
- package/src/predictions.ts +33 -0
- package/src/queue/dispatch.ts +18 -0
- package/src/queue/jobs.ts +28 -0
- package/src/queue/processor.ts +42 -0
- package/src/report/fix-first.ts +121 -0
- package/src/report/launch-readiness.ts +106 -0
- package/src/report/markdown.ts +202 -0
- package/src/run.ts +141 -0
- package/src/sandbox/outbox.ts +65 -0
- package/src/store/db.ts +40 -0
- package/src/store/import-run.ts +116 -0
- package/src/store/pg-run-store.ts +163 -0
- package/src/store/predictions.ts +70 -0
- package/src/store/run-store.ts +78 -0
- package/src/store/schema.ts +193 -0
- package/src/store/url.ts +15 -0
- package/src/types.ts +201 -0
- package/src/util/chromium.ts +20 -0
- package/src/util/json.ts +7 -0
- package/src/util/key-defaults.ts +34 -0
- package/src/util/legacy-env.ts +14 -0
- package/src/util/load-env.ts +28 -0
- package/src/util/pool.ts +12 -0
- package/src/util/run-origin.ts +27 -0
- package/src/worker.ts +53 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { PerceptionResult } from "../perception/run-perception";
|
|
2
|
+
import type { Finding } from "../types";
|
|
3
|
+
|
|
4
|
+
export interface LaunchReadinessInput {
|
|
5
|
+
target: string;
|
|
6
|
+
icp: string;
|
|
7
|
+
result: PerceptionResult;
|
|
8
|
+
/** Functional findings from a depth run, when there was one. */
|
|
9
|
+
functional?: Finding[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const pct = (x: number) => `${Math.round(x * 100)}%`;
|
|
13
|
+
const label = (category: string) => category.replace(/_/g, " ");
|
|
14
|
+
|
|
15
|
+
function renderExperiment(r: PerceptionResult): string[] {
|
|
16
|
+
const p = r.pairAggregate!;
|
|
17
|
+
const [a, b] = r.variants;
|
|
18
|
+
const decided = p.winsA + p.winsB;
|
|
19
|
+
const row = (v: "A" | "B", name: string, wins: number) => `| ${name} | ${wins} of ${decided} | ${v === p.leader ? `${pct(p.interval.low)}–${pct(p.interval.high)}` : ""} | ${(p.reasons[v] ?? []).slice(0, 2).join("; ")} |`;
|
|
20
|
+
return [
|
|
21
|
+
`## Variant comparison`,
|
|
22
|
+
"",
|
|
23
|
+
`| Variant | Chosen by | 95% interval (leader) | Why buyers chose it |`,
|
|
24
|
+
`|---|---|---|---|`,
|
|
25
|
+
row("A", a, p.winsA),
|
|
26
|
+
row("B", b, p.winsB),
|
|
27
|
+
"",
|
|
28
|
+
`${p.neither} of ${r.pairs.length} buyers wanted neither. ${
|
|
29
|
+
p.decisive
|
|
30
|
+
? `**${p.leader === "A" ? a : b} leads decisively**: its interval excludes a 50/50 split.`
|
|
31
|
+
: `**No decisive winner**: the interval includes a 50/50 split, so this could be noise. Run more evaluators.`
|
|
32
|
+
}`,
|
|
33
|
+
`Position check: the option shown first won ${pct(p.firstShownWinShare)} of choices (50% means no order bias).`,
|
|
34
|
+
"",
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderObjections(r: PerceptionResult): string[] {
|
|
39
|
+
return r.variants.flatMap((v) => {
|
|
40
|
+
const a = r.aggregates[v];
|
|
41
|
+
if (!a.objections.length) return [];
|
|
42
|
+
return [
|
|
43
|
+
...(r.variants.length > 1 ? [`### ${v}`, ""] : []),
|
|
44
|
+
...a.objections.slice(0, 6).map((o) => `- **${label(o.category)}** — ${o.buyers} of ${a.evaluators} buyers. ${o.examples.map((e) => `"${e}"`).join(" ")}`),
|
|
45
|
+
"",
|
|
46
|
+
];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderSingle(r: PerceptionResult): string[] {
|
|
51
|
+
const a = r.aggregates[r.variants[0]];
|
|
52
|
+
const s = a.meanScores;
|
|
53
|
+
return [
|
|
54
|
+
`## How simulated buyers responded`,
|
|
55
|
+
"",
|
|
56
|
+
`Of ${a.evaluators} buyers: ${a.intent.yes} would buy, ${a.intent.maybe} might, ${a.intent.no} would not. This is a comparison baseline, not a conversion rate.`,
|
|
57
|
+
`Average scores (1–5): clarity ${s.clarity.toFixed(1)}, credibility ${s.credibility.toFixed(1)}, value ${s.value.toFixed(1)}, urgency ${s.urgency.toFixed(1)}.`,
|
|
58
|
+
a.wouldPay.answers
|
|
59
|
+
? `Of those who might buy, ${a.wouldPay.answers} named a price: median $${a.wouldPay.medianUsd}/month (${Object.entries(a.wouldPay.plans).map(([plan, n]) => `${plan} ×${n}`).join(", ")}).`
|
|
60
|
+
: "",
|
|
61
|
+
"",
|
|
62
|
+
...(a.confusions.length
|
|
63
|
+
? [`## Confusion points`, "", ...a.confusions.slice(0, 6).map((c) => `- ${c.exhibitId} (${c.count}): ${c.examples.map((e) => `"${e}"`).join(" ")}`), ""]
|
|
64
|
+
: []),
|
|
65
|
+
].filter((line, i, all) => line !== "" || all[i - 1] !== "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The top-level artifact. Comparative, grounded in counts, never a fake percentage. */
|
|
69
|
+
export function renderLaunchReadiness(i: LaunchReadinessInput): string {
|
|
70
|
+
const r = i.result;
|
|
71
|
+
const blockers = (i.functional ?? []).filter((f) => f.severity !== "minor" && f.confidence !== "low" && !f.sandboxCause);
|
|
72
|
+
const cost = r.usage.costUsd !== undefined ? `$${r.usage.costUsd.toFixed(4)}` : "unknown";
|
|
73
|
+
return [
|
|
74
|
+
`# Launch Readiness: ${i.target}`,
|
|
75
|
+
"",
|
|
76
|
+
`> Simulated evidence — not real traction. ${r.evaluators} AI buyers matching "${i.icp}" judged ${r.mode === "experiment" ? `${r.variants.join(" vs ")}` : "the product"}; ${r.unusable} replies were unusable and left out.`,
|
|
77
|
+
"",
|
|
78
|
+
`## Verdict`,
|
|
79
|
+
"",
|
|
80
|
+
r.summary.verdict,
|
|
81
|
+
"",
|
|
82
|
+
`## Ranked blockers`,
|
|
83
|
+
"",
|
|
84
|
+
...(blockers.length ? blockers.map((f) => `- [${f.severity.toUpperCase()}] ${f.title} (${f.hitBy.length} of ${f.visitors} agents)`) : ["_No functional blockers from the depth run._"]),
|
|
85
|
+
"",
|
|
86
|
+
...(r.mode === "experiment" ? renderExperiment(r) : renderSingle(r)),
|
|
87
|
+
`## Strongest objections`,
|
|
88
|
+
"",
|
|
89
|
+
...renderObjections(r),
|
|
90
|
+
`## Recommended fixes`,
|
|
91
|
+
"",
|
|
92
|
+
...(r.summary.fixes.length ? r.summary.fixes.map((f, n) => `${n + 1}. ${f.fix} _(addresses: ${label(f.addresses)})_`) : ["_None suggested._"]),
|
|
93
|
+
"",
|
|
94
|
+
`## What would change our mind`,
|
|
95
|
+
"",
|
|
96
|
+
`These predictions are stored. Record what really happens (\`npm run debutly:predictions -- record <id> <actual>\`) to measure how well the simulation predicts.`,
|
|
97
|
+
"",
|
|
98
|
+
...r.predictions.map((p) => `- [${p.metric}] ${p.claim}`),
|
|
99
|
+
"",
|
|
100
|
+
...(r.market
|
|
101
|
+
? [`## Market context`, "", ...r.market.competitors.map((c) => `- **${c.name}**: ${c.pricing} (${c.positioning})`), `Sources: ${r.market.sources.join(", ") || "none"}`, ""]
|
|
102
|
+
: []),
|
|
103
|
+
`_Evaluator cost: ${cost} (${r.usage.inputTokens.toLocaleString("en-US")} input + ${r.usage.outputTokens.toLocaleString("en-US")} output tokens)._`,
|
|
104
|
+
"",
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describeAction } from "../agent/describe";
|
|
2
|
+
import { MEASURED } from "../detect/findings";
|
|
3
|
+
import { describeCriterion } from "../detect/wcag";
|
|
4
|
+
import { OUT_OF_CREDITS } from "../llm/types";
|
|
5
|
+
import { languageName } from "../persona/language";
|
|
6
|
+
import type { CustomerUnit, Finding, Session } from "../types";
|
|
7
|
+
import { renderFixFirst, shotPath } from "./fix-first";
|
|
8
|
+
|
|
9
|
+
export interface ReportInput {
|
|
10
|
+
targetUrl: string;
|
|
11
|
+
goal: string;
|
|
12
|
+
sessions: Session[];
|
|
13
|
+
findings: Finding[];
|
|
14
|
+
units?: CustomerUnit[];
|
|
15
|
+
usage?: { inputTokens: number; outputTokens: number; costUsd?: number };
|
|
16
|
+
model?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderCost(usage: ReportInput["usage"], model?: string): string[] {
|
|
20
|
+
if (!usage || usage.inputTokens + usage.outputTokens === 0) return [];
|
|
21
|
+
const tokens = `${usage.inputTokens.toLocaleString("en-US")} input + ${usage.outputTokens.toLocaleString("en-US")} output tokens${model ? ` on ${model}` : ""}`;
|
|
22
|
+
return [`Cost: ${usage.costUsd !== undefined ? `$${usage.costUsd.toFixed(4)}` : "unknown"} (${tokens}) `];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderFinding(f: Finding, sessions: Session[]): string {
|
|
26
|
+
const lines = [
|
|
27
|
+
`### [${f.severity.toUpperCase()}] ${f.title}`,
|
|
28
|
+
"",
|
|
29
|
+
f.detail,
|
|
30
|
+
"",
|
|
31
|
+
MEASURED.has(f.kind)
|
|
32
|
+
? `Measured by the browser on ${f.pagePath}, by ${f.hitBy.length} of ${f.visitors} agents who visited it.`
|
|
33
|
+
: `Hit by ${f.hitBy.length} of ${f.visitors} agents who visited ${f.pagePath} (${f.confidence} confidence).`,
|
|
34
|
+
];
|
|
35
|
+
if (f.wcag?.length) lines.push("", `Related WCAG 2.2: ${f.wcag.map(describeCriterion).join(", ")}, for a person to check.`);
|
|
36
|
+
const ev = f.evidence[0];
|
|
37
|
+
const session = sessions.find((s) => s.agentId === ev?.agentId);
|
|
38
|
+
if (ev && session) {
|
|
39
|
+
lines.push("", `Reproduction (${session.personaName}):`, "");
|
|
40
|
+
for (const step of session.steps.slice(0, ev.stepIndex + 1)) {
|
|
41
|
+
lines.push(`${step.index + 1}. ${describeAction(step.action, step.observation)} — "${step.reasoning}"`);
|
|
42
|
+
}
|
|
43
|
+
const shot = session.steps[ev.stepIndex]?.screenshotPath;
|
|
44
|
+
if (shot) lines.push("", `Screenshot: ${shotPath(shot)}`);
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const STOP_VERB: Partial<Record<Session["outcome"], string>> = {
|
|
50
|
+
gave_up: "gave up",
|
|
51
|
+
error: "stopped with an error",
|
|
52
|
+
budget_exhausted: "ran out of steps",
|
|
53
|
+
not_invited: "never got an invite",
|
|
54
|
+
cancelled: "was cancelled",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const OWNER_VERB: Record<Session["outcome"], string> = {
|
|
58
|
+
success: "succeeded",
|
|
59
|
+
gave_up: "gave up",
|
|
60
|
+
error: "hit an engine error",
|
|
61
|
+
budget_exhausted: "ran out of steps",
|
|
62
|
+
not_invited: "never got an invite",
|
|
63
|
+
cancelled: "was cancelled",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// One line per business crew: did the owner get through, and did the team get in?
|
|
67
|
+
function renderUnits(units: CustomerUnit[], sessions: Session[]): string[] {
|
|
68
|
+
const crews = units.filter((u) => u.kind === "business");
|
|
69
|
+
if (!crews.length) return [];
|
|
70
|
+
const lines = crews.map((u) => {
|
|
71
|
+
const mine = sessions.filter((s) => s.unitId === u.id);
|
|
72
|
+
const owner = mine.find((s) => s.agentId === u.owner.id);
|
|
73
|
+
const team = mine.filter((s) => s.agentId !== u.owner.id);
|
|
74
|
+
const inTeam = team.filter((s) => s.outcome === "success").length;
|
|
75
|
+
const missing = team.filter((s) => s.outcome !== "success").map((s) => `${s.personaName}: ${s.outcomeNote}`);
|
|
76
|
+
const b = u.business;
|
|
77
|
+
const head = `**${b.name}** (${b.archetype}, ~${b.size} people, $${b.monthlyBudget}/mo): owner ${u.owner.name} ${owner ? OWNER_VERB[owner.outcome] : "did not run"}`;
|
|
78
|
+
if (!team.length) return `- ${head}`;
|
|
79
|
+
return `- ${head}; ${inTeam} of ${team.length} teammates got in${missing.length ? ` (${missing.join("; ")})` : ""}`;
|
|
80
|
+
});
|
|
81
|
+
return [`## Customer units`, "", ...lines, ""];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Access need and first language, e.g. "screen reader, Arabic".
|
|
85
|
+
const needLabel = (s: Session) =>
|
|
86
|
+
[s.inclusion?.replace(/_/g, " "), s.language && s.language !== "en" ? languageName(s.language) : ""].filter(Boolean).join(", ") || "—";
|
|
87
|
+
|
|
88
|
+
// The agents' own reasons are often the clearest statement of a blocker.
|
|
89
|
+
function renderStops(sessions: Session[]): string {
|
|
90
|
+
const lines = sessions.flatMap((s) => {
|
|
91
|
+
const verb = STOP_VERB[s.outcome];
|
|
92
|
+
if (!verb) return [];
|
|
93
|
+
const last = s.steps.at(-1)?.observation.url;
|
|
94
|
+
let where = "";
|
|
95
|
+
try {
|
|
96
|
+
where = last ? ` on ${new URL(last).pathname}` : "";
|
|
97
|
+
} catch {
|
|
98
|
+
where = "";
|
|
99
|
+
}
|
|
100
|
+
return [`- **${s.personaName}** ${verb}${where}: "${s.outcomeNote}"`];
|
|
101
|
+
});
|
|
102
|
+
return lines.length ? lines.join("\n") : "_Every agent reached its goal._";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const MISSING_KEY_PREFIX = "missing key: ";
|
|
106
|
+
const MADE_UP_SITE_PREFIX = "made-up site: ";
|
|
107
|
+
|
|
108
|
+
/** Why the test environment, not the app, explains a failure: blocked hosts, a missing key, a made-up site. */
|
|
109
|
+
function sandboxCauseLine(f: Finding): string {
|
|
110
|
+
const causes = f.sandboxCause ?? [];
|
|
111
|
+
const hosts = causes.filter((c) => !c.startsWith(MISSING_KEY_PREFIX) && !c.startsWith(MADE_UP_SITE_PREFIX));
|
|
112
|
+
const said = causes.filter((c) => c.startsWith(MISSING_KEY_PREFIX)).map((c) => `"${c.slice(MISSING_KEY_PREFIX.length)}"`);
|
|
113
|
+
const sites = causes.filter((c) => c.startsWith(MADE_UP_SITE_PREFIX)).map((c) => c.slice(MADE_UP_SITE_PREFIX.length));
|
|
114
|
+
return [
|
|
115
|
+
hosts.length ? `Blocked at the same moment: ${hosts.join(", ")}.` : "",
|
|
116
|
+
said.length ? `The app said a key or setting is missing: ${said.join("; ")}.` : "",
|
|
117
|
+
sites.length ? `The app couldn't reach the website the agent made up for its company: ${sites.join(", ")}.` : "",
|
|
118
|
+
`Hit by ${f.hitBy.length} of ${f.visitors} agents who visited ${f.pagePath}.`,
|
|
119
|
+
]
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join(" ");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Agents the AI provider stopped for lack of credit: the run is incomplete, and not because of the app.
|
|
125
|
+
function renderOutOfCredits(sessions: Session[]): string[] {
|
|
126
|
+
const stopped = sessions.filter((s) => s.outcome === "error" && s.outcomeNote.startsWith(OUT_OF_CREDITS));
|
|
127
|
+
if (!stopped.length) return [];
|
|
128
|
+
return [
|
|
129
|
+
`## Run cut short`,
|
|
130
|
+
"",
|
|
131
|
+
`The AI provider account ran out of credits, so ${stopped.length} of ${sessions.length} agents stopped early: ${stopped.map((s) => s.personaName).join(", ")}. That is not the app's doing. Findings cover only the steps they took. Top up the account and run again.`,
|
|
132
|
+
"",
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Failures the test environment explains (it blocked an outbound call, or has no key the app
|
|
137
|
+
// needs): likely not the app's fault.
|
|
138
|
+
function renderSandboxCaused(caused: Finding[]): string[] {
|
|
139
|
+
if (!caused.length) return [];
|
|
140
|
+
return [
|
|
141
|
+
`## Caused by the test environment`,
|
|
142
|
+
"",
|
|
143
|
+
`The test environment blocks the app from reaching real outside services and has none of its secret keys, so nothing leaks and nothing is charged. Each of these failures happened while it was blocking a call, or the app said a key was missing, or it couldn't reach a website the agent made up, so they probably don't happen on the real app. Check there if in doubt.`,
|
|
144
|
+
"",
|
|
145
|
+
...caused.flatMap((f) => [`### ${f.title}`, "", f.detail, "", sandboxCauseLine(f), ""]),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function renderReport(input: ReportInput): string {
|
|
150
|
+
const caused = input.findings.filter((f) => f.sandboxCause);
|
|
151
|
+
const real = input.findings.filter((f) => !f.sandboxCause);
|
|
152
|
+
const confident = real.filter((f) => f.confidence !== "low");
|
|
153
|
+
const low = real.filter((f) => f.confidence === "low");
|
|
154
|
+
const count = (s: Finding["severity"]) => confident.filter((f) => f.severity === s).length;
|
|
155
|
+
|
|
156
|
+
return [
|
|
157
|
+
`# Debutly Report`,
|
|
158
|
+
"",
|
|
159
|
+
`> Simulated evidence — not real traction. Findings come from AI agents, not real users.`,
|
|
160
|
+
"",
|
|
161
|
+
`Target: ${input.targetUrl} `,
|
|
162
|
+
`Goal: ${input.goal} `,
|
|
163
|
+
`Agents: ${input.sessions.length} `,
|
|
164
|
+
...renderCost(input.usage, input.model),
|
|
165
|
+
`Findings: ${count("blocker")} blocker, ${count("major")} major, ${count("minor")} minor (${low.length} low-confidence${caused.length ? `, ${caused.length} caused by the test environment` : ""})`,
|
|
166
|
+
"",
|
|
167
|
+
...renderOutOfCredits(input.sessions),
|
|
168
|
+
...renderFixFirst(input.sessions, input.findings, needLabel),
|
|
169
|
+
...renderUnits(input.units ?? [], input.sessions),
|
|
170
|
+
`## Agent outcomes`,
|
|
171
|
+
"",
|
|
172
|
+
`| Persona | Role | Needs | Outcome | Note |`,
|
|
173
|
+
`|---|---|---|---|---|`,
|
|
174
|
+
...input.sessions.map((s) => `| ${s.personaName} | ${s.role ?? "—"} | ${needLabel(s)} | ${s.outcome} | ${s.outcomeNote} |`),
|
|
175
|
+
"",
|
|
176
|
+
`## Where agents stopped`,
|
|
177
|
+
"",
|
|
178
|
+
renderStops(input.sessions),
|
|
179
|
+
"",
|
|
180
|
+
`## Findings`,
|
|
181
|
+
"",
|
|
182
|
+
...(input.findings.some((f) => f.wcag?.length)
|
|
183
|
+
? [
|
|
184
|
+
`WCAG references point to what a person should check. Debutly doesn't test conformance, and a run without such findings says nothing about it.`,
|
|
185
|
+
"",
|
|
186
|
+
]
|
|
187
|
+
: []),
|
|
188
|
+
confident.length ? confident.map((f) => renderFinding(f, input.sessions)).join("\n\n") : "_None._",
|
|
189
|
+
"",
|
|
190
|
+
`## Low-confidence findings`,
|
|
191
|
+
"",
|
|
192
|
+
`Few of the agents that reached these pages hit these issues. They may be agent errors rather than product bugs.`,
|
|
193
|
+
"",
|
|
194
|
+
low.length ? low.map((f) => renderFinding(f, input.sessions)).join("\n\n") : "_None._",
|
|
195
|
+
"",
|
|
196
|
+
...renderSandboxCaused(caused),
|
|
197
|
+
"---",
|
|
198
|
+
"",
|
|
199
|
+
"Rehearsed with [Debutly](https://debutly.dev).",
|
|
200
|
+
"",
|
|
201
|
+
].join("\n");
|
|
202
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
import { LocalArtifactStore, type ArtifactStore } from "./artifacts/store";
|
|
5
|
+
import { runAgentSession } from "./agent/run-agent";
|
|
6
|
+
import { runUnit, type RunAgent } from "./crew/run-unit";
|
|
7
|
+
import { detectFindings } from "./detect/findings";
|
|
8
|
+
import type { Inbox } from "./inbox/types";
|
|
9
|
+
import type { LlmClient } from "./llm/types";
|
|
10
|
+
import { PEOPLE_FILE } from "./persona/people";
|
|
11
|
+
import { renderReport } from "./report/markdown";
|
|
12
|
+
import type { ModelPrice } from "./llm/meter";
|
|
13
|
+
import { blockedCalls, type SandboxOutbox } from "./sandbox/outbox";
|
|
14
|
+
import type { NewRun, RunStore, RunUsage, SideEffect } from "./store/run-store";
|
|
15
|
+
import type { CustomerUnit, Finding, Persona, Session } from "./types";
|
|
16
|
+
import { mapWithConcurrency } from "./util/pool";
|
|
17
|
+
|
|
18
|
+
export interface SimulationOptions {
|
|
19
|
+
url: string;
|
|
20
|
+
goal: string;
|
|
21
|
+
/** Individuals and/or business crews; crews run owner first, then invited employees. */
|
|
22
|
+
units: CustomerUnit[];
|
|
23
|
+
llm: LlmClient;
|
|
24
|
+
maxSteps: number;
|
|
25
|
+
/** How many units run at once. */
|
|
26
|
+
concurrency: number;
|
|
27
|
+
outDir: string;
|
|
28
|
+
/** Gives each agent its own email address and lets it read mail (magic links, invites). */
|
|
29
|
+
inbox?: Inbox;
|
|
30
|
+
emailDomain?: string;
|
|
31
|
+
/** The app's sandbox outbox, to tell failures the test environment caused from real ones. */
|
|
32
|
+
sandboxOutbox?: SandboxOutbox;
|
|
33
|
+
/** Where screenshots go; defaults to <outDir>/screenshots. */
|
|
34
|
+
artifacts?: ArtifactStore;
|
|
35
|
+
/** Records the run as it happens (and provides its kill switch). */
|
|
36
|
+
store?: RunStore;
|
|
37
|
+
/** An existing run to record into (e.g. one queued by an API); otherwise one is created. */
|
|
38
|
+
runId?: string;
|
|
39
|
+
/** What to record about the run when creating it. */
|
|
40
|
+
run?: Omit<NewRun, "targetUrl" | "goal">;
|
|
41
|
+
/** Per-agent limits. */
|
|
42
|
+
caps?: { maxTokens?: number; maxMs?: number };
|
|
43
|
+
/** The nav model's price, to cost each session. */
|
|
44
|
+
price?: ModelPrice | null;
|
|
45
|
+
/** In-memory kill switch when there is no store (e.g. Ctrl+C). */
|
|
46
|
+
shouldStop?: () => boolean | Promise<boolean>;
|
|
47
|
+
/** Where to keep each agent's browser state (cookies, storage) at the end, for `debutly hear`. */
|
|
48
|
+
browserStateDir?: string;
|
|
49
|
+
/** Sends each agent somewhere else to run (e.g. a queue of workers) instead of this process. */
|
|
50
|
+
dispatch?: (ctx: { runId?: string; runTag: string }) => RunAgent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const asIndividuals = (personas: Persona[]): CustomerUnit[] => personas.map((p) => ({ kind: "individual", id: p.id, person: p }));
|
|
54
|
+
|
|
55
|
+
export function agentEmail(persona: Pick<Persona, "id" | "name">, runTag: string, domain: string): string {
|
|
56
|
+
const slug = persona.name
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
59
|
+
.replace(/^-+|-+$/g, "");
|
|
60
|
+
return `${slug || "agent"}.${persona.id}.${runTag}@${domain}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SimulationResult {
|
|
64
|
+
runId?: string;
|
|
65
|
+
sessions: Session[];
|
|
66
|
+
findings: Finding[];
|
|
67
|
+
reportPath: string;
|
|
68
|
+
usage: RunUsage;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sums sessions' usage; the cost is known only if every session's is. */
|
|
72
|
+
export function runUsage(sessions: Session[]): RunUsage {
|
|
73
|
+
const costs = sessions.filter((s) => s.usage).map((s) => s.usage!.costUsd);
|
|
74
|
+
return {
|
|
75
|
+
inputTokens: sessions.reduce((n, s) => n + (s.usage?.inputTokens ?? 0), 0),
|
|
76
|
+
outputTokens: sessions.reduce((n, s) => n + (s.usage?.outputTokens ?? 0), 0),
|
|
77
|
+
costUsd: costs.length && costs.every((c) => c !== undefined) ? costs.reduce((a, c) => a + (c ?? 0), 0) : undefined,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runSimulation(o: SimulationOptions): Promise<SimulationResult> {
|
|
82
|
+
await mkdir(o.outDir, { recursive: true });
|
|
83
|
+
const artifacts = o.artifacts ?? new LocalArtifactStore(join(o.outDir, "screenshots"));
|
|
84
|
+
const origin = new URL(o.url).origin;
|
|
85
|
+
const started = new Date();
|
|
86
|
+
const runTag = started.getTime().toString(36);
|
|
87
|
+
const emailOf = (p: Persona) => agentEmail(p, runTag, o.emailDomain ?? "agents.test");
|
|
88
|
+
const { store } = o;
|
|
89
|
+
const runId = store ? (o.runId ?? (await store.createRun({ targetUrl: o.url, goal: o.goal, ...o.run }))) : o.runId;
|
|
90
|
+
if (store && runId) await store.recordUnits(runId, o.units);
|
|
91
|
+
// The run kill switch: the store's (so another process can cancel) or the caller's.
|
|
92
|
+
const shouldStop = store && runId ? () => store.isCancelled(runId) : o.shouldStop;
|
|
93
|
+
// Queue mode: workers run the agents (and record them); otherwise they run here.
|
|
94
|
+
const browser = o.dispatch ? undefined : await chromium.launch();
|
|
95
|
+
try {
|
|
96
|
+
const runAgent: RunAgent = o.dispatch
|
|
97
|
+
? o.dispatch({ runId, runTag })
|
|
98
|
+
: (persona, goal, brief) =>
|
|
99
|
+
runAgentSession(
|
|
100
|
+
{ browser: browser!, llm: o.llm, origin, maxSteps: o.maxSteps, artifacts, emailOf, inbox: o.inbox, caps: o.caps, price: o.price, shouldStop, store, runId, browserStateDir: o.browserStateDir },
|
|
101
|
+
persona,
|
|
102
|
+
goal,
|
|
103
|
+
brief,
|
|
104
|
+
);
|
|
105
|
+
const perUnit = await mapWithConcurrency(o.units, o.concurrency, (unit) => runUnit(unit, o.goal, { runAgent, emailOf, inbox: o.inbox }));
|
|
106
|
+
const sessions = perUnit.flat();
|
|
107
|
+
// Teammates who were never invited never ran; record them too.
|
|
108
|
+
if (store && runId) for (const s of sessions.filter((x) => x.outcome === "not_invited")) await store.finishSession(runId, s);
|
|
109
|
+
|
|
110
|
+
let sideEffects: SideEffect[] = [];
|
|
111
|
+
if (o.sandboxOutbox) {
|
|
112
|
+
try {
|
|
113
|
+
sideEffects = await o.sandboxOutbox.entriesSince(started);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn(`Could not read the sandbox outbox, so failures are not checked against it: ${err instanceof Error ? err.message : err}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const findings = detectFindings(sessions, { blocked: blockedCalls(sideEffects), sandbox: !!o.sandboxOutbox });
|
|
119
|
+
const usage = runUsage(sessions);
|
|
120
|
+
const reportMd = renderReport({ targetUrl: o.url, goal: o.goal, sessions, findings, units: o.units, usage, model: o.run?.model });
|
|
121
|
+
const reportPath = join(o.outDir, "report.md");
|
|
122
|
+
await writeFile(join(o.outDir, "sessions.json"), JSON.stringify(sessions, null, 2));
|
|
123
|
+
await writeFile(join(o.outDir, "findings.json"), JSON.stringify(findings, null, 2));
|
|
124
|
+
await writeFile(join(o.outDir, PEOPLE_FILE), JSON.stringify(o.units, null, 2));
|
|
125
|
+
await writeFile(reportPath, reportMd);
|
|
126
|
+
if (store && runId) {
|
|
127
|
+
const status = sessions.some((s) => s.outcome === "cancelled") ? "cancelled" : "finished";
|
|
128
|
+
await store.finishRun(runId, { status, findings, reportMd, usage, sideEffects });
|
|
129
|
+
}
|
|
130
|
+
return { runId, sessions, findings, reportPath, usage };
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (store && runId) {
|
|
133
|
+
await store
|
|
134
|
+
.finishRun(runId, { status: "failed", findings: [], reportMd: `Run failed: ${err instanceof Error ? err.message : err}`, usage: { inputTokens: 0, outputTokens: 0 }, sideEffects: [] })
|
|
135
|
+
.catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
} finally {
|
|
139
|
+
await browser?.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SideEffect } from "../store/run-store";
|
|
2
|
+
|
|
3
|
+
/** An outbound call the app under test tried to make and its sandbox refused. */
|
|
4
|
+
export interface BlockedCall {
|
|
5
|
+
host: string;
|
|
6
|
+
url: string;
|
|
7
|
+
/** When the sandbox blocked it (epoch ms). */
|
|
8
|
+
at: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SandboxOutbox {
|
|
12
|
+
/** Everything the sandbox faked or blocked since `since`: the run's side effects. */
|
|
13
|
+
entriesSince(since: Date): Promise<SideEffect[]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Calls a failure can come from: ones the sandbox refused, and AI calls it answered with a stand-in
|
|
17
|
+
// (an app's AI proxy failed on the stand-in answers, 2026-10-16 to 18). A faked email is not
|
|
18
|
+
// one: the app is told it was sent.
|
|
19
|
+
export const blockedCalls = (entries: SideEffect[]): BlockedCall[] =>
|
|
20
|
+
entries
|
|
21
|
+
.filter((e) => e.outcome === "blocked" || (e.outcome === "faked" && e.channel === "ai"))
|
|
22
|
+
.map((e) => ({ host: e.host, url: e.url, at: e.at.getTime() }));
|
|
23
|
+
|
|
24
|
+
interface OutboxEntry {
|
|
25
|
+
outcome?: string;
|
|
26
|
+
rule?: string | null;
|
|
27
|
+
channel?: string;
|
|
28
|
+
host?: string;
|
|
29
|
+
url?: string;
|
|
30
|
+
recipient?: string | null;
|
|
31
|
+
summary?: string | null;
|
|
32
|
+
created_at?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads a throwaway environment's sandbox outbox (GET <url>?since=…, bearer token),
|
|
37
|
+
* the record every Debutly sandbox keeps of what it faked or blocked.
|
|
38
|
+
*/
|
|
39
|
+
export class HttpSandboxOutbox implements SandboxOutbox {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly url: string,
|
|
42
|
+
private readonly token: string,
|
|
43
|
+
private readonly fetchFn: typeof fetch = fetch,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
async entriesSince(since: Date): Promise<SideEffect[]> {
|
|
47
|
+
const res = await this.fetchFn(`${this.url}?since=${encodeURIComponent(since.toISOString())}`, {
|
|
48
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) throw new Error(`sandbox outbox: HTTP ${res.status}`);
|
|
51
|
+
const { entries = [] } = (await res.json()) as { entries?: OutboxEntry[] };
|
|
52
|
+
return entries
|
|
53
|
+
.filter((e) => e.outcome && e.host && e.created_at)
|
|
54
|
+
.map((e) => ({
|
|
55
|
+
outcome: e.outcome!,
|
|
56
|
+
rule: e.rule ?? null,
|
|
57
|
+
channel: e.channel ?? "unknown",
|
|
58
|
+
host: e.host!,
|
|
59
|
+
url: e.url ?? "",
|
|
60
|
+
recipient: e.recipient ?? null,
|
|
61
|
+
summary: e.summary ?? null,
|
|
62
|
+
at: new Date(e.created_at!),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/store/db.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
|
|
3
|
+
import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
|
|
4
|
+
import { migrate as migratePglite } from "drizzle-orm/pglite/migrator";
|
|
5
|
+
import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js";
|
|
6
|
+
import { migrate as migratePostgres } from "drizzle-orm/postgres-js/migrator";
|
|
7
|
+
import postgres from "postgres";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import * as schema from "./schema";
|
|
11
|
+
import { postgresUrl } from "./url";
|
|
12
|
+
|
|
13
|
+
export type Db = PgDatabase<PgQueryResultHKT, typeof schema>;
|
|
14
|
+
|
|
15
|
+
const MIGRATIONS = fileURLToPath(new URL("../../drizzle", import.meta.url));
|
|
16
|
+
|
|
17
|
+
export interface OpenDb {
|
|
18
|
+
db: Db;
|
|
19
|
+
close: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A Postgres connection with the schema migrated. `memory:` gives an in-process
|
|
24
|
+
* PGlite database (tests, one-off local runs), `pglite:<folder>` one kept in a folder (local
|
|
25
|
+
* dashboard work without a server); anything else is a postgres:// URL.
|
|
26
|
+
*/
|
|
27
|
+
export async function openDb(url: string): Promise<OpenDb> {
|
|
28
|
+
if (url === "memory:" || url.startsWith("pglite:")) {
|
|
29
|
+
const folder = url === "memory:" ? undefined : url.slice("pglite:".length);
|
|
30
|
+
if (folder) mkdirSync(folder, { recursive: true });
|
|
31
|
+
const client = folder ? new PGlite(folder) : new PGlite();
|
|
32
|
+
const db = drizzlePglite(client, { schema });
|
|
33
|
+
await migratePglite(db, { migrationsFolder: MIGRATIONS });
|
|
34
|
+
return { db: db as unknown as Db, close: () => client.close() };
|
|
35
|
+
}
|
|
36
|
+
const client = postgres(postgresUrl(url), { max: 10, onnotice: () => {} });
|
|
37
|
+
const db = drizzlePostgres(client, { schema });
|
|
38
|
+
await migratePostgres(db, { migrationsFolder: MIGRATIONS });
|
|
39
|
+
return { db: db as unknown as Db, close: () => client.end() };
|
|
40
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Load a finished run's folder (report.md, sessions.json, findings.json, screenshots/) into the
|
|
2
|
+
// store, so runs made without --store (every nightly CI run before the dashboard) can be seen in
|
|
3
|
+
// it. Screenshots go into the database as compact JPEGs, like a stored run's.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { eq } from "drizzle-orm";
|
|
8
|
+
import { DbArtifactStore } from "../artifacts/db";
|
|
9
|
+
import type { Business, CustomerUnit, Finding, InclusionNeed, Persona, Session } from "../types";
|
|
10
|
+
import type { Db } from "./db";
|
|
11
|
+
import { PgRunStore } from "./pg-run-store";
|
|
12
|
+
import { runs } from "./schema";
|
|
13
|
+
|
|
14
|
+
export interface ImportOptions {
|
|
15
|
+
app?: string;
|
|
16
|
+
trigger?: string;
|
|
17
|
+
ciUrl?: string;
|
|
18
|
+
/** When the run happened (its CI run's start); the folder's report has no date. */
|
|
19
|
+
at?: Date;
|
|
20
|
+
/** PNG to JPEG, e.g. through a browser; without one, screenshots are left out. */
|
|
21
|
+
toJpeg?: (png: Buffer) => Promise<Buffer>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReportHeader {
|
|
25
|
+
targetUrl: string;
|
|
26
|
+
goal: string;
|
|
27
|
+
costUsd?: number;
|
|
28
|
+
inputTokens?: number;
|
|
29
|
+
outputTokens?: number;
|
|
30
|
+
businesses: Business[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What report.md says about the run that sessions.json doesn't: target, goal, cost, businesses. */
|
|
34
|
+
export function parseReportHeader(md: string): ReportHeader {
|
|
35
|
+
const line = (label: string) => md.match(new RegExp(`^${label}: (.*?)\\s*$`, "m"))?.[1];
|
|
36
|
+
const cost = md.match(/^Cost: \$([\d.]+) \(([\d,]+) input \+ ([\d,]+) output tokens\)/m);
|
|
37
|
+
const units = md.match(/## Customer units\n\n([\s\S]*?)\n\n## /)?.[1] ?? "";
|
|
38
|
+
const businesses = [...units.matchAll(/^- \*\*(.+?)\*\* \((.+?), ~(\d+) people, \$(\d+)\/mo\)/gm)].map(
|
|
39
|
+
([, name, archetype, size, budget]) => ({ name, archetype, size: Number(size), monthlyBudget: Number(budget), goal: "" }),
|
|
40
|
+
);
|
|
41
|
+
const num = (s?: string) => (s ? Number(s.replace(/,/g, "")) : undefined);
|
|
42
|
+
return {
|
|
43
|
+
targetUrl: line("Target") ?? "",
|
|
44
|
+
goal: line("Goal") ?? "",
|
|
45
|
+
costUsd: cost ? Number(cost[1]) : undefined,
|
|
46
|
+
inputTokens: num(cost?.[2]),
|
|
47
|
+
outputTokens: num(cost?.[3]),
|
|
48
|
+
businesses,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The customer units the sessions belong to: a business per unit id (u1, u2…), else one person each. */
|
|
53
|
+
export function unitsFromSessions(sessions: Session[], businesses: Business[]): CustomerUnit[] {
|
|
54
|
+
const persona = (s: Session): Persona => ({
|
|
55
|
+
id: s.agentId,
|
|
56
|
+
name: s.personaName,
|
|
57
|
+
background: "",
|
|
58
|
+
goal: s.goal,
|
|
59
|
+
patience: "medium",
|
|
60
|
+
techSavvy: "medium",
|
|
61
|
+
...(s.role ? { role: s.role } : {}),
|
|
62
|
+
...(s.inclusion ? { inclusion: s.inclusion as InclusionNeed } : {}),
|
|
63
|
+
});
|
|
64
|
+
const byUnit = new Map<string, Session[]>();
|
|
65
|
+
for (const s of sessions) {
|
|
66
|
+
const unit = s.unitId ?? s.agentId;
|
|
67
|
+
byUnit.set(unit, [...(byUnit.get(unit) ?? []), s]);
|
|
68
|
+
}
|
|
69
|
+
return [...byUnit.entries()].map(([id, members], i): CustomerUnit => {
|
|
70
|
+
const owner = members.find((m) => m.agentId.endsWith("-o"));
|
|
71
|
+
if (!owner) return { kind: "individual", id, person: persona(members[0]) };
|
|
72
|
+
return {
|
|
73
|
+
kind: "business",
|
|
74
|
+
id,
|
|
75
|
+
business: businesses[i] ?? { name: id, archetype: "", size: members.length, monthlyBudget: 0, goal: "" },
|
|
76
|
+
owner: persona(owner),
|
|
77
|
+
employees: members.filter((m) => m !== owner).map(persona),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Import one run folder; returns the new run's id. */
|
|
83
|
+
export async function importRunFolder(db: Db, dir: string, o: ImportOptions = {}): Promise<string> {
|
|
84
|
+
const read = (name: string) => readFile(join(dir, name), "utf8");
|
|
85
|
+
const header = parseReportHeader(await read("report.md"));
|
|
86
|
+
const sessions = JSON.parse(await read("sessions.json")) as Session[];
|
|
87
|
+
const findings = JSON.parse(await read("findings.json")) as Finding[];
|
|
88
|
+
const reportMd = await read("report.md");
|
|
89
|
+
|
|
90
|
+
const store = new PgRunStore(db);
|
|
91
|
+
const runId = await store.createRun({ app: o.app, trigger: o.trigger, ciUrl: o.ciUrl, targetUrl: header.targetUrl, goal: header.goal });
|
|
92
|
+
await store.recordUnits(runId, unitsFromSessions(sessions, header.businesses));
|
|
93
|
+
|
|
94
|
+
const shots = new DbArtifactStore(db, runId);
|
|
95
|
+
for (const s of sessions) {
|
|
96
|
+
await store.startSession(runId, s.agentId, s.goal);
|
|
97
|
+
for (const step of s.steps) {
|
|
98
|
+
let screenshotPath: string | undefined;
|
|
99
|
+
const file = step.screenshotPath ? join(dir, "screenshots", basename(step.screenshotPath)) : undefined;
|
|
100
|
+
if (file && o.toJpeg && existsSync(file)) {
|
|
101
|
+
screenshotPath = await shots.put(basename(file).replace(/\.png$/i, ".jpg"), await o.toJpeg(await readFile(file)), "image/jpeg");
|
|
102
|
+
}
|
|
103
|
+
await store.appendStep(runId, s.agentId, { ...step, screenshotPath });
|
|
104
|
+
}
|
|
105
|
+
await store.finishSession(runId, s);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const usage = {
|
|
109
|
+
inputTokens: header.inputTokens ?? sessions.reduce((n, s) => n + (s.usage?.inputTokens ?? 0), 0),
|
|
110
|
+
outputTokens: header.outputTokens ?? sessions.reduce((n, s) => n + (s.usage?.outputTokens ?? 0), 0),
|
|
111
|
+
costUsd: header.costUsd,
|
|
112
|
+
};
|
|
113
|
+
await store.finishRun(runId, { status: "finished", findings, reportMd, usage, sideEffects: [] });
|
|
114
|
+
if (o.at) await db.update(runs).set({ createdAt: o.at, startedAt: o.at, finishedAt: o.at }).where(eq(runs.id, runId));
|
|
115
|
+
return runId;
|
|
116
|
+
}
|