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,163 @@
|
|
|
1
|
+
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
2
|
+
import type { CustomerUnit, Finding, Persona, Session, Step } from "../types";
|
|
3
|
+
import type { Db } from "./db";
|
|
4
|
+
import type { NewRun, RunStore, RunUsage, SideEffect, StoredRun } from "./run-store";
|
|
5
|
+
import * as t from "./schema";
|
|
6
|
+
|
|
7
|
+
const membersOf = (u: CustomerUnit): Persona[] => (u.kind === "individual" ? [u.person] : [u.owner, ...u.employees]);
|
|
8
|
+
|
|
9
|
+
export class PgRunStore implements RunStore {
|
|
10
|
+
constructor(private readonly db: Db) {}
|
|
11
|
+
|
|
12
|
+
async createRun(r: NewRun): Promise<string> {
|
|
13
|
+
const [row] = await this.db
|
|
14
|
+
.insert(t.runs)
|
|
15
|
+
.values({
|
|
16
|
+
app: r.app ?? null,
|
|
17
|
+
trigger: r.trigger ?? null,
|
|
18
|
+
ciUrl: r.ciUrl ?? null,
|
|
19
|
+
targetUrl: r.targetUrl,
|
|
20
|
+
goal: r.goal,
|
|
21
|
+
icp: r.icp,
|
|
22
|
+
tier: r.tier,
|
|
23
|
+
unitKind: r.unitKind,
|
|
24
|
+
model: r.model,
|
|
25
|
+
estimateLowUsd: r.estimate?.low,
|
|
26
|
+
estimateHighUsd: r.estimate?.high,
|
|
27
|
+
status: "running",
|
|
28
|
+
startedAt: new Date(),
|
|
29
|
+
})
|
|
30
|
+
.returning({ id: t.runs.id });
|
|
31
|
+
return row.id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async recordUnits(runId: string, units: CustomerUnit[]): Promise<void> {
|
|
35
|
+
if (!units.length) return;
|
|
36
|
+
await this.db.insert(t.customerUnits).values(units.map((u) => ({ runId, id: u.id, kind: u.kind, business: u.kind === "business" ? u.business : null })));
|
|
37
|
+
const people = units.flatMap((u) => membersOf(u).map((p) => ({ unitId: u.id, p })));
|
|
38
|
+
await this.db.insert(t.agents).values(
|
|
39
|
+
people.map(({ unitId, p }) => ({ runId, id: p.id, unitId, name: p.name, role: p.role ?? null, inclusion: p.inclusion ?? null, persona: p })),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async startSession(runId: string, agentId: string, goal: string): Promise<void> {
|
|
44
|
+
await this.db.insert(t.sessions).values({ runId, agentId, goal }).onConflictDoNothing();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async appendStep(runId: string, agentId: string, s: Step): Promise<void> {
|
|
48
|
+
await this.db
|
|
49
|
+
.insert(t.steps)
|
|
50
|
+
.values({
|
|
51
|
+
runId,
|
|
52
|
+
agentId,
|
|
53
|
+
index: s.index,
|
|
54
|
+
url: s.observation.url,
|
|
55
|
+
title: s.observation.title,
|
|
56
|
+
action: s.action,
|
|
57
|
+
reasoning: s.reasoning,
|
|
58
|
+
observation: s.observation,
|
|
59
|
+
signals: s.signals,
|
|
60
|
+
error: s.error ?? null,
|
|
61
|
+
blocked: s.blocked ?? null,
|
|
62
|
+
screenshotUrl: s.screenshotPath ?? null,
|
|
63
|
+
})
|
|
64
|
+
.onConflictDoNothing();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async finishSession(runId: string, s: Session): Promise<void> {
|
|
68
|
+
const values = {
|
|
69
|
+
outcome: s.outcome,
|
|
70
|
+
outcomeNote: s.outcomeNote,
|
|
71
|
+
inputTokens: s.usage?.inputTokens ?? null,
|
|
72
|
+
outputTokens: s.usage?.outputTokens ?? null,
|
|
73
|
+
costUsd: s.usage?.costUsd ?? null,
|
|
74
|
+
finishedAt: new Date(),
|
|
75
|
+
};
|
|
76
|
+
await this.db
|
|
77
|
+
.insert(t.sessions)
|
|
78
|
+
.values({ runId, agentId: s.agentId, goal: s.goal, ...values })
|
|
79
|
+
.onConflictDoUpdate({ target: [t.sessions.runId, t.sessions.agentId], set: values });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async finishRun(
|
|
83
|
+
runId: string,
|
|
84
|
+
r: { status: "finished" | "failed" | "cancelled"; findings: Finding[]; reportMd: string; usage: RunUsage; sideEffects: SideEffect[] },
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
if (r.findings.length) {
|
|
87
|
+
await this.db.insert(t.findings).values(
|
|
88
|
+
r.findings.map((f) => ({
|
|
89
|
+
runId,
|
|
90
|
+
signature: f.signature,
|
|
91
|
+
kind: f.kind,
|
|
92
|
+
severity: f.severity,
|
|
93
|
+
confidence: f.confidence,
|
|
94
|
+
title: f.title,
|
|
95
|
+
detail: f.detail,
|
|
96
|
+
pagePath: f.pagePath,
|
|
97
|
+
hitBy: f.hitBy,
|
|
98
|
+
visitors: f.visitors,
|
|
99
|
+
evidence: f.evidence,
|
|
100
|
+
sandboxCause: f.sandboxCause ?? null,
|
|
101
|
+
})),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (r.sideEffects.length) await this.db.insert(t.sideEffects).values(r.sideEffects.map((e) => ({ runId, ...e })));
|
|
105
|
+
await this.db
|
|
106
|
+
.update(t.runs)
|
|
107
|
+
.set({
|
|
108
|
+
status: r.status,
|
|
109
|
+
reportMd: r.reportMd,
|
|
110
|
+
inputTokens: r.usage.inputTokens,
|
|
111
|
+
outputTokens: r.usage.outputTokens,
|
|
112
|
+
costUsd: r.usage.costUsd ?? null,
|
|
113
|
+
finishedAt: new Date(),
|
|
114
|
+
})
|
|
115
|
+
.where(eq(t.runs.id, runId));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async requestCancel(runId: string): Promise<void> {
|
|
119
|
+
await this.db
|
|
120
|
+
.update(t.runs)
|
|
121
|
+
.set({ status: "cancelling" })
|
|
122
|
+
.where(and(eq(t.runs.id, runId), inArray(t.runs.status, ["queued", "running"])));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async isCancelled(runId: string): Promise<boolean> {
|
|
126
|
+
const [row] = await this.db.select({ status: t.runs.status }).from(t.runs).where(eq(t.runs.id, runId));
|
|
127
|
+
return row?.status === "cancelling" || row?.status === "cancelled";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async getRun(runId: string): Promise<StoredRun | null> {
|
|
131
|
+
const [run] = await this.db.select().from(t.runs).where(eq(t.runs.id, runId));
|
|
132
|
+
if (!run) return null;
|
|
133
|
+
const [units, agents, sessions, steps, findings, sideEffects] = await Promise.all([
|
|
134
|
+
this.db.select().from(t.customerUnits).where(eq(t.customerUnits.runId, runId)).orderBy(asc(t.customerUnits.id)),
|
|
135
|
+
this.db.select().from(t.agents).where(eq(t.agents.runId, runId)).orderBy(asc(t.agents.id)),
|
|
136
|
+
this.db.select().from(t.sessions).where(eq(t.sessions.runId, runId)).orderBy(asc(t.sessions.agentId)),
|
|
137
|
+
this.db.select().from(t.steps).where(eq(t.steps.runId, runId)).orderBy(asc(t.steps.agentId), asc(t.steps.index)),
|
|
138
|
+
this.db.select().from(t.findings).where(eq(t.findings.runId, runId)),
|
|
139
|
+
this.db.select().from(t.sideEffects).where(eq(t.sideEffects.runId, runId)).orderBy(asc(t.sideEffects.id)),
|
|
140
|
+
]);
|
|
141
|
+
return {
|
|
142
|
+
...run,
|
|
143
|
+
units,
|
|
144
|
+
agents,
|
|
145
|
+
sessions,
|
|
146
|
+
steps,
|
|
147
|
+
findings: findings.map((f) => ({
|
|
148
|
+
signature: f.signature,
|
|
149
|
+
kind: f.kind as Finding["kind"],
|
|
150
|
+
severity: f.severity as Finding["severity"],
|
|
151
|
+
confidence: f.confidence as Finding["confidence"],
|
|
152
|
+
title: f.title,
|
|
153
|
+
detail: f.detail,
|
|
154
|
+
pagePath: f.pagePath,
|
|
155
|
+
hitBy: f.hitBy,
|
|
156
|
+
visitors: f.visitors,
|
|
157
|
+
evidence: f.evidence as Finding["evidence"],
|
|
158
|
+
...(f.sandboxCause ? { sandboxCause: f.sandboxCause } : {}),
|
|
159
|
+
})),
|
|
160
|
+
sideEffects: sideEffects.map(({ outcome, rule, channel, host, url, recipient, summary, at }) => ({ outcome, rule, channel, host, url, recipient, summary, at })),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
|
|
2
|
+
import type { Db } from "./db";
|
|
3
|
+
import * as t from "./schema";
|
|
4
|
+
|
|
5
|
+
export type PredictionMetric = "winner" | "top_objection" | "intent_share";
|
|
6
|
+
|
|
7
|
+
export interface NewPrediction {
|
|
8
|
+
metric: PredictionMetric;
|
|
9
|
+
claim: string;
|
|
10
|
+
predicted: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StoredPrediction extends NewPrediction {
|
|
14
|
+
id: string;
|
|
15
|
+
runId: string;
|
|
16
|
+
actual: unknown;
|
|
17
|
+
correct: boolean | null;
|
|
18
|
+
createdAt: Date;
|
|
19
|
+
checkedAt: Date | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether a real outcome matches the prediction; null where we can't score it yet. */
|
|
23
|
+
function isCorrect(metric: string, predicted: Record<string, unknown>, actual: string): boolean | null {
|
|
24
|
+
if (metric === "winner") return String(predicted.winner) === actual.trim();
|
|
25
|
+
if (metric === "top_objection") return String(predicted.category) === actual.trim();
|
|
26
|
+
// Shares (e.g. intent) are not conversion rates; they are scored once calibration exists.
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Calibration loop: verdicts as predictions, checked against real outcomes. */
|
|
31
|
+
export class PgPredictionStore {
|
|
32
|
+
constructor(private readonly db: Db) {}
|
|
33
|
+
|
|
34
|
+
async add(runId: string, items: NewPrediction[]): Promise<string[]> {
|
|
35
|
+
if (!items.length) return [];
|
|
36
|
+
const rows = await this.db
|
|
37
|
+
.insert(t.predictions)
|
|
38
|
+
.values(items.map((p) => ({ runId, metric: p.metric, claim: p.claim, predicted: p.predicted })))
|
|
39
|
+
.returning({ id: t.predictions.id });
|
|
40
|
+
return rows.map((r) => r.id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async list(o: { unresolved?: boolean } = {}): Promise<StoredPrediction[]> {
|
|
44
|
+
const rows = await this.db
|
|
45
|
+
.select()
|
|
46
|
+
.from(t.predictions)
|
|
47
|
+
.where(o.unresolved ? isNull(t.predictions.checkedAt) : undefined)
|
|
48
|
+
.orderBy(desc(t.predictions.createdAt));
|
|
49
|
+
return rows.map((r) => ({ ...r, metric: r.metric as PredictionMetric, predicted: r.predicted as Record<string, unknown> }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Records what really happened (e.g. "B" won the real A/B test, or "price" was the top objection in sales calls). */
|
|
53
|
+
async record(id: string, actual: string): Promise<StoredPrediction> {
|
|
54
|
+
const [row] = await this.db.select().from(t.predictions).where(eq(t.predictions.id, id));
|
|
55
|
+
if (!row) throw new Error(`No prediction ${id}`);
|
|
56
|
+
const correct = isCorrect(row.metric, row.predicted as Record<string, unknown>, actual);
|
|
57
|
+
const [updated] = await this.db
|
|
58
|
+
.update(t.predictions)
|
|
59
|
+
.set({ actual, correct, checkedAt: new Date() })
|
|
60
|
+
.where(eq(t.predictions.id, id))
|
|
61
|
+
.returning();
|
|
62
|
+
return { ...updated, metric: updated.metric as PredictionMetric, predicted: updated.predicted as Record<string, unknown> };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async accuracy(): Promise<{ scored: number; correct: number; rate: number | null }> {
|
|
66
|
+
const scored = await this.db.select({ correct: t.predictions.correct }).from(t.predictions).where(and(isNotNull(t.predictions.correct)));
|
|
67
|
+
const correct = scored.filter((r) => r.correct).length;
|
|
68
|
+
return { scored: scored.length, correct, rate: scored.length ? correct / scored.length : null };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { CustomerUnit, Finding, Session, Step } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface NewRun {
|
|
4
|
+
/** Which app is under test, e.g. "acme". */
|
|
5
|
+
app?: string;
|
|
6
|
+
/** nightly | pr | manual | local */
|
|
7
|
+
trigger?: string;
|
|
8
|
+
/** The CI run behind this one, e.g. its GitHub Actions page. */
|
|
9
|
+
ciUrl?: string;
|
|
10
|
+
targetUrl: string;
|
|
11
|
+
goal: string;
|
|
12
|
+
icp?: string;
|
|
13
|
+
tier?: string;
|
|
14
|
+
unitKind?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
estimate?: { low: number; high: number } | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunUsage {
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
costUsd?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Something the app under test tried to send or reach, from its sandbox outbox. */
|
|
26
|
+
export interface SideEffect {
|
|
27
|
+
outcome: string;
|
|
28
|
+
rule: string | null;
|
|
29
|
+
channel: string;
|
|
30
|
+
host: string;
|
|
31
|
+
url: string;
|
|
32
|
+
recipient: string | null;
|
|
33
|
+
summary: string | null;
|
|
34
|
+
at: Date;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StoredRun {
|
|
38
|
+
id: string;
|
|
39
|
+
app: string | null;
|
|
40
|
+
trigger: string | null;
|
|
41
|
+
ciUrl: string | null;
|
|
42
|
+
targetUrl: string;
|
|
43
|
+
goal: string;
|
|
44
|
+
tier: string | null;
|
|
45
|
+
status: string;
|
|
46
|
+
model: string | null;
|
|
47
|
+
estimateLowUsd: number | null;
|
|
48
|
+
estimateHighUsd: number | null;
|
|
49
|
+
costUsd: number | null;
|
|
50
|
+
inputTokens: number | null;
|
|
51
|
+
outputTokens: number | null;
|
|
52
|
+
reportMd: string | null;
|
|
53
|
+
createdAt: Date;
|
|
54
|
+
finishedAt: Date | null;
|
|
55
|
+
units: { id: string; kind: string; business: unknown }[];
|
|
56
|
+
agents: { id: string; unitId: string; name: string; role: string | null; inclusion: string | null }[];
|
|
57
|
+
sessions: { agentId: string; goal: string; outcome: string | null; outcomeNote: string | null; inputTokens: number | null; outputTokens: number | null; costUsd: number | null }[];
|
|
58
|
+
steps: { agentId: string; index: number; url: string; action: unknown; reasoning: string | null; error: string | null; blocked: string | null; screenshotUrl: string | null }[];
|
|
59
|
+
findings: (Finding & { sandboxCause?: string[] })[];
|
|
60
|
+
sideEffects: SideEffect[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Records a run while it happens, so it can be watched live, compared and audited. */
|
|
64
|
+
export interface RunStore {
|
|
65
|
+
createRun(r: NewRun): Promise<string>;
|
|
66
|
+
recordUnits(runId: string, units: CustomerUnit[]): Promise<void>;
|
|
67
|
+
startSession(runId: string, agentId: string, goal: string): Promise<void>;
|
|
68
|
+
appendStep(runId: string, agentId: string, step: Step): Promise<void>;
|
|
69
|
+
finishSession(runId: string, session: Session): Promise<void>;
|
|
70
|
+
finishRun(
|
|
71
|
+
runId: string,
|
|
72
|
+
r: { status: "finished" | "failed" | "cancelled"; findings: Finding[]; reportMd: string; usage: RunUsage; sideEffects: SideEffect[] },
|
|
73
|
+
): Promise<void>;
|
|
74
|
+
/** The run kill switch: agents stop at their next step. */
|
|
75
|
+
requestCancel(runId: string): Promise<void>;
|
|
76
|
+
isCancelled(runId: string): Promise<boolean>;
|
|
77
|
+
getRun(runId: string): Promise<StoredRun | null>;
|
|
78
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Postgres schema for stored runs. Organizations, apps as records and
|
|
2
|
+
// multi-tenancy arrive later; runs name their app, and the dashboard reads all of this.
|
|
3
|
+
import { sql } from "drizzle-orm";
|
|
4
|
+
import { boolean, customType, doublePrecision, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
5
|
+
|
|
6
|
+
export const runs = pgTable(
|
|
7
|
+
"runs",
|
|
8
|
+
{
|
|
9
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
10
|
+
/** Which app was tested (e.g. "acme"), so the dashboard can group runs by it. */
|
|
11
|
+
app: text("app"),
|
|
12
|
+
/** nightly | pr | manual | local: what started the run. */
|
|
13
|
+
trigger: text("trigger"),
|
|
14
|
+
/** The CI run behind this one (e.g. its GitHub Actions page), when there is one. */
|
|
15
|
+
ciUrl: text("ci_url"),
|
|
16
|
+
targetUrl: text("target_url").notNull(),
|
|
17
|
+
goal: text("goal").notNull(),
|
|
18
|
+
icp: text("icp"),
|
|
19
|
+
tier: text("tier"),
|
|
20
|
+
unitKind: text("unit_kind"),
|
|
21
|
+
/** queued | running | cancelling | cancelled | finished | failed */
|
|
22
|
+
status: text("status").notNull().default("queued"),
|
|
23
|
+
model: text("model"),
|
|
24
|
+
estimateLowUsd: doublePrecision("estimate_low_usd"),
|
|
25
|
+
estimateHighUsd: doublePrecision("estimate_high_usd"),
|
|
26
|
+
costUsd: doublePrecision("cost_usd"),
|
|
27
|
+
inputTokens: integer("input_tokens"),
|
|
28
|
+
outputTokens: integer("output_tokens"),
|
|
29
|
+
reportMd: text("report_md"),
|
|
30
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
31
|
+
startedAt: timestamp("started_at", { withTimezone: true }),
|
|
32
|
+
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
|
33
|
+
},
|
|
34
|
+
(t) => [index("runs_created_at_idx").on(t.createdAt.desc()), index("runs_app_created_at_idx").on(t.app, t.createdAt.desc())],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// postgres-js returns a Buffer, PGlite a Uint8Array: hand back a Buffer either way.
|
|
38
|
+
const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({
|
|
39
|
+
dataType: () => "bytea",
|
|
40
|
+
fromDriver: (value) => (Buffer.isBuffer(value) ? value : Buffer.from(value)),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Step screenshots kept in the database, for a deployment with no object storage (a free
|
|
45
|
+
* Postgres is enough to run the dashboard). Compact JPEGs, pruned after a few days.
|
|
46
|
+
*/
|
|
47
|
+
export const screenshots = pgTable(
|
|
48
|
+
"screenshots",
|
|
49
|
+
{
|
|
50
|
+
runId: uuid("run_id")
|
|
51
|
+
.notNull()
|
|
52
|
+
.references(() => runs.id, { onDelete: "cascade" }),
|
|
53
|
+
key: text("key").notNull(),
|
|
54
|
+
contentType: text("content_type").notNull(),
|
|
55
|
+
data: bytea("data").notNull(),
|
|
56
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
57
|
+
},
|
|
58
|
+
(t) => [primaryKey({ columns: [t.runId, t.key] }), index("screenshots_created_at_idx").on(t.createdAt)],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export const customerUnits = pgTable(
|
|
62
|
+
"customer_units",
|
|
63
|
+
{
|
|
64
|
+
runId: uuid("run_id")
|
|
65
|
+
.notNull()
|
|
66
|
+
.references(() => runs.id, { onDelete: "cascade" }),
|
|
67
|
+
id: text("id").notNull(),
|
|
68
|
+
kind: text("kind").notNull(),
|
|
69
|
+
business: jsonb("business"),
|
|
70
|
+
},
|
|
71
|
+
(t) => [primaryKey({ columns: [t.runId, t.id] })],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export const agents = pgTable(
|
|
75
|
+
"agents",
|
|
76
|
+
{
|
|
77
|
+
runId: uuid("run_id")
|
|
78
|
+
.notNull()
|
|
79
|
+
.references(() => runs.id, { onDelete: "cascade" }),
|
|
80
|
+
id: text("id").notNull(),
|
|
81
|
+
unitId: text("unit_id").notNull(),
|
|
82
|
+
name: text("name").notNull(),
|
|
83
|
+
role: text("role"),
|
|
84
|
+
inclusion: text("inclusion"),
|
|
85
|
+
persona: jsonb("persona").notNull(),
|
|
86
|
+
},
|
|
87
|
+
(t) => [primaryKey({ columns: [t.runId, t.id] })],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
export const sessions = pgTable(
|
|
91
|
+
"sessions",
|
|
92
|
+
{
|
|
93
|
+
runId: uuid("run_id")
|
|
94
|
+
.notNull()
|
|
95
|
+
.references(() => runs.id, { onDelete: "cascade" }),
|
|
96
|
+
agentId: text("agent_id").notNull(),
|
|
97
|
+
goal: text("goal").notNull(),
|
|
98
|
+
outcome: text("outcome"),
|
|
99
|
+
outcomeNote: text("outcome_note"),
|
|
100
|
+
inputTokens: integer("input_tokens"),
|
|
101
|
+
outputTokens: integer("output_tokens"),
|
|
102
|
+
costUsd: doublePrecision("cost_usd"),
|
|
103
|
+
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
|
104
|
+
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
|
105
|
+
},
|
|
106
|
+
(t) => [primaryKey({ columns: [t.runId, t.agentId] })],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
export const steps = pgTable(
|
|
110
|
+
"steps",
|
|
111
|
+
{
|
|
112
|
+
runId: uuid("run_id")
|
|
113
|
+
.notNull()
|
|
114
|
+
.references(() => runs.id, { onDelete: "cascade" }),
|
|
115
|
+
agentId: text("agent_id").notNull(),
|
|
116
|
+
index: integer("index").notNull(),
|
|
117
|
+
url: text("url").notNull(),
|
|
118
|
+
title: text("title"),
|
|
119
|
+
action: jsonb("action").notNull(),
|
|
120
|
+
reasoning: text("reasoning"),
|
|
121
|
+
observation: jsonb("observation").notNull(),
|
|
122
|
+
signals: jsonb("signals").notNull(),
|
|
123
|
+
error: text("error"),
|
|
124
|
+
blocked: text("blocked"),
|
|
125
|
+
screenshotUrl: text("screenshot_url"),
|
|
126
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
127
|
+
},
|
|
128
|
+
(t) => [primaryKey({ columns: [t.runId, t.agentId, t.index] })],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
export const findings = pgTable(
|
|
132
|
+
"findings",
|
|
133
|
+
{
|
|
134
|
+
runId: uuid("run_id")
|
|
135
|
+
.notNull()
|
|
136
|
+
.references(() => runs.id, { onDelete: "cascade" }),
|
|
137
|
+
signature: text("signature").notNull(),
|
|
138
|
+
kind: text("kind").notNull(),
|
|
139
|
+
severity: text("severity").notNull(),
|
|
140
|
+
confidence: text("confidence").notNull(),
|
|
141
|
+
title: text("title").notNull(),
|
|
142
|
+
detail: text("detail").notNull(),
|
|
143
|
+
pagePath: text("page_path").notNull(),
|
|
144
|
+
hitBy: text("hit_by").array().notNull(),
|
|
145
|
+
visitors: integer("visitors").notNull(),
|
|
146
|
+
evidence: jsonb("evidence").notNull(),
|
|
147
|
+
sandboxCause: text("sandbox_cause").array(),
|
|
148
|
+
},
|
|
149
|
+
(t) => [primaryKey({ columns: [t.runId, t.signature] })],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/** What the app under test tried to send or reach, from its sandbox outbox. */
|
|
153
|
+
export const sideEffects = pgTable(
|
|
154
|
+
"side_effects",
|
|
155
|
+
{
|
|
156
|
+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
157
|
+
runId: uuid("run_id")
|
|
158
|
+
.notNull()
|
|
159
|
+
.references(() => runs.id, { onDelete: "cascade" }),
|
|
160
|
+
outcome: text("outcome").notNull(),
|
|
161
|
+
rule: text("rule"),
|
|
162
|
+
channel: text("channel").notNull(),
|
|
163
|
+
host: text("host").notNull(),
|
|
164
|
+
url: text("url").notNull(),
|
|
165
|
+
recipient: text("recipient"),
|
|
166
|
+
summary: text("summary"),
|
|
167
|
+
at: timestamp("at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
168
|
+
},
|
|
169
|
+
(t) => [index("side_effects_run_idx").on(t.runId)],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Calibration loop: every verdict is a prediction; when the real outcome is known it
|
|
174
|
+
* is recorded, and accuracy is tracked over time.
|
|
175
|
+
*/
|
|
176
|
+
export const predictions = pgTable(
|
|
177
|
+
"predictions",
|
|
178
|
+
{
|
|
179
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
180
|
+
runId: uuid("run_id")
|
|
181
|
+
.notNull()
|
|
182
|
+
.references(() => runs.id, { onDelete: "cascade" }),
|
|
183
|
+
/** winner | top_objection | intent_share */
|
|
184
|
+
metric: text("metric").notNull(),
|
|
185
|
+
claim: text("claim").notNull(),
|
|
186
|
+
predicted: jsonb("predicted").notNull(),
|
|
187
|
+
actual: jsonb("actual"),
|
|
188
|
+
correct: boolean("correct"),
|
|
189
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
190
|
+
checkedAt: timestamp("checked_at", { withTimezone: true }),
|
|
191
|
+
},
|
|
192
|
+
(t) => [index("predictions_run_idx").on(t.runId)],
|
|
193
|
+
);
|
package/src/store/url.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Postgres URL as postgres.js should get it. postgres.js sends query parameters it doesn't
|
|
3
|
+
* know to the server as settings, and the server rejects libpq-only ones such as
|
|
4
|
+
* channel_binding, which Neon's connection strings carry. Dropping it keeps the connection on
|
|
5
|
+
* TLS (sslmode stays), so a connection string can be pasted exactly as the host gives it.
|
|
6
|
+
*/
|
|
7
|
+
export function postgresUrl(url: string): string {
|
|
8
|
+
const q = url.indexOf("?");
|
|
9
|
+
if (q < 0) return url;
|
|
10
|
+
const params = url
|
|
11
|
+
.slice(q + 1)
|
|
12
|
+
.split("&")
|
|
13
|
+
.filter((p) => p && !/^channel_binding=/i.test(p));
|
|
14
|
+
return params.length ? `${url.slice(0, q)}?${params.join("&")}` : url.slice(0, q);
|
|
15
|
+
}
|