@broberg/lens-daemon 0.0.0-stage → 0.1.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.
@@ -0,0 +1,207 @@
1
+ // F082 — Lens flow-run persistence.
2
+ //
3
+ // Mirrors store.ts (captures): per-step screenshot BYTES go to the storage
4
+ // adapter; the run report (one row, per-step status in steps_json) goes to the
5
+ // daemon's SQLite. Hard-scoped to project_slug.
6
+
7
+ import type { Database } from 'bun:sqlite';
8
+ import { randomUUID } from 'node:crypto';
9
+ import type { LensFlowRunRow } from './schema.ts';
10
+ import { artifactKeys, type LensStorage } from './storage.ts';
11
+ import { enqueueArtifacts } from './artifact-uploads.ts';
12
+ import type { FlowResult, FailedResponse } from './flow.ts';
13
+
14
+ /** A per-step entry as persisted (screenshot bytes already offloaded to an
15
+ * artifact key — null when the step took no shot). */
16
+ export interface StoredFlowStep {
17
+ index: number;
18
+ type: string;
19
+ label: string;
20
+ status: 'pass' | 'fail' | 'skip';
21
+ testid: string | null;
22
+ detail: string | null;
23
+ artifact_key: string | null;
24
+ /** F074.29 — set when the step failed because the PROBE was broken (an assert
25
+ * body that cannot compile, or one that threw every time) rather than because
26
+ * the app was wrong. `status` stays 'fail' either way, so no consumer can
27
+ * accidentally stop counting it. */
28
+ fault?: 'instrument';
29
+ /** F074.49 — the failed HTTP responses the app gave around this step, so the
30
+ * report carries the ANSWER and not only the assertion. Persisted, because a
31
+ * flow-run is read back from this row long after the browser is gone: a field
32
+ * produced in flow.ts but not written here reaches nobody. */
33
+ network?: FailedResponse[];
34
+ /** F126.7 — the composition report for a screenshot step. Persisted for the
35
+ * same reason `network` is: a flow-run is read back from this row long after
36
+ * the browser is gone, and a field produced in flow.ts but not written here
37
+ * reaches nobody. xrt81 (#21050) measured the cost of the string-only version
38
+ * — a caller fixed the one finding it was shown, went green, and the rest were
39
+ * silently recorded as resolved. */
40
+ critic?: unknown;
41
+ /** F213.12 — how long after the last navigation this step ran. THE THIRD TIME
42
+ * this file's rule caught someone, and this time it was the author of the
43
+ * rule's most recent entry: F213.11 produced the field in flow.ts, proved it
44
+ * against `runFlow`'s return value, and never wrote it here — so the number
45
+ * reached nobody through the MCP, which is the ONLY surface the session that
46
+ * asked for it can read. Measured on the wire, not reasoned about.
47
+ *
48
+ * UNDEFINED means the step never ran (skip). ZERO is a real, common reading —
49
+ * a `goto` runs at 0ms after its own navigation — so this must be written on
50
+ * a `typeof === 'number'` test, never on truthiness. Every other optional
51
+ * field here is correctly gated on truthiness; copying that pattern for a
52
+ * number silently reports the strongest evidence of an early write as
53
+ * "not measured". */
54
+ ms_after_navigation?: number;
55
+ /** F213.13 — how long the step itself took. Written here IN THE SAME COMMIT as
56
+ * the producer, which is the entire lesson of F213.12 one card earlier: a field
57
+ * that exists only in flow.ts reaches nobody. Same `typeof` gate — a step that
58
+ * completed in under a millisecond reports 0, and that is a reading, not a gap. */
59
+ ms_step_duration?: number;
60
+ }
61
+
62
+ export async function recordFlowRun(
63
+ db: Database,
64
+ storage: LensStorage,
65
+ projectSlug: string,
66
+ result: FlowResult,
67
+ /** F074.36 — which credential the run used, or why it had none. Persisted so a
68
+ * past GREEN run can still be asked "were you even logged in?". */
69
+ auth?: { source: string; adapter: string | null; note?: string },
70
+ ): Promise<LensFlowRunRow> {
71
+ const id = randomUUID();
72
+ const now = Date.now();
73
+
74
+ const storedSteps: StoredFlowStep[] = [];
75
+ for (const s of result.steps) {
76
+ let artifactKey: string | null = null;
77
+ if (s.screenshot) {
78
+ artifactKey = artifactKeys.flowStep(projectSlug, id, s.index);
79
+ await storage.put(artifactKey, s.screenshot);
80
+ }
81
+ storedSteps.push({
82
+ index: s.index,
83
+ type: s.type,
84
+ label: s.label,
85
+ status: s.status,
86
+ testid: s.testid,
87
+ detail: s.detail,
88
+ artifact_key: artifactKey,
89
+ ...(s.fault ? { fault: s.fault } : {}),
90
+ ...(s.network?.length ? { network: s.network } : {}),
91
+ ...(s.critic ? { critic: s.critic } : {}),
92
+ // `typeof === 'number'`, NOT truthiness — 0 is the reading that matters most.
93
+ ...(typeof s.ms_after_navigation === 'number' ? { ms_after_navigation: s.ms_after_navigation } : {}),
94
+ ...(typeof s.ms_step_duration === 'number' ? { ms_step_duration: s.ms_step_duration } : {}),
95
+ });
96
+ }
97
+
98
+ // F255.5 — store the run's recording next to its step stills, under the same
99
+ // flow-run prefix so both are cleaned up together.
100
+ let videoKey: string | null = null;
101
+ if (result.video && result.video.length > 0) {
102
+ videoKey = artifactKeys.flowVideo(projectSlug, id);
103
+ await storage.put(videoKey, result.video);
104
+ }
105
+
106
+ const row: LensFlowRunRow = {
107
+ id,
108
+ project_slug: projectSlug,
109
+ flow_name: result.name,
110
+ status: result.status,
111
+ failed_at: result.failed_at,
112
+ reason: result.reason,
113
+ steps_json: JSON.stringify(storedSteps),
114
+ step_count: storedSteps.length,
115
+ duration_ms: result.duration_ms,
116
+ created_at: now,
117
+ video_key: videoKey,
118
+ build: result.build ?? null,
119
+ final_url: result.final_url ?? null,
120
+ // F074.59 — which motion state the run executed under. Absent on an older row.
121
+ reduced_motion: result.reduced_motion ?? null,
122
+ // F272.2 — WHERE the bytes above actually went, read off the storage adapter
123
+ // that just wrote them rather than from config at read time. This is the only
124
+ // moment the answer is certainly true.
125
+ artifact_backend: storage.backend,
126
+ // Only {source, adapter, note} — never the credential object itself. The
127
+ // caller already knows what it passed, and echoing statePath/url back into
128
+ // every report is noise in a surface many sessions read.
129
+ auth_json: auth
130
+ ? JSON.stringify({
131
+ source: auth.source,
132
+ adapter: auth.adapter,
133
+ ...(auth.note ? { note: auth.note } : {}),
134
+ // F074.36 — the measured verdict, not the intent: "a credential was
135
+ // configured" and "a credential can reach this host" are different
136
+ // facts, and only the second one means anything.
137
+ ...(result.auth_reach ? { reach: result.auth_reach } : {}),
138
+ })
139
+ : null,
140
+ // F074.99 — the run-level findings, persisted for the same reason `network`
141
+ // and `critic` above are: a flow-run is read back from this row long after
142
+ // the browser is gone. ALWAYS written, even when empty — '{}' means the run
143
+ // looked and found nothing, and NULL means the row predates the column. Both
144
+ // findings were produced in flow.ts and written nowhere until fd-sundhed
145
+ // measured it from a consumer's seat: correct instrument, nobody told.
146
+ findings_json: JSON.stringify({
147
+ ...(result.password_in_url ? { password_in_url: result.password_in_url } : {}),
148
+ ...(result.not_listening ? { not_listening: result.not_listening } : {}),
149
+ }),
150
+ };
151
+
152
+ db.prepare(
153
+ `INSERT INTO lens_flow_runs (
154
+ id, project_slug, flow_name, status, failed_at, reason,
155
+ steps_json, step_count, duration_ms, created_at, video_key, build,
156
+ final_url, auth_json, reduced_motion, artifact_backend, findings_json
157
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
158
+ ).run(
159
+ row.id, row.project_slug, row.flow_name, row.status, row.failed_at, row.reason,
160
+ row.steps_json, row.step_count, row.duration_ms, row.created_at, row.video_key ?? null,
161
+ row.build ?? null, row.final_url ?? null, row.auth_json ?? null,
162
+ row.reduced_motion ?? null,
163
+ row.artifact_backend ?? null,
164
+ row.findings_json ?? null,
165
+ );
166
+
167
+ // F276.1 — queue every still for the cloud, in the SAME function that wrote it
168
+ // locally. Doing it at the three call sites instead would mean a fourth call site
169
+ // added tomorrow silently produces phone-invisible evidence — the F213.12 lesson
170
+ // this file has already learned three times: a field produced in one place and not
171
+ // written in the other reaches nobody.
172
+ enqueueArtifacts(
173
+ db,
174
+ projectSlug,
175
+ [...storedSteps.map((s) => s.artifact_key), videoKey],
176
+ );
177
+
178
+ return row;
179
+ }
180
+
181
+ export interface ListFlowRunsFilter {
182
+ flow_name?: string;
183
+ status?: string;
184
+ limit?: number;
185
+ }
186
+
187
+ export function listFlowRuns(db: Database, projectSlug: string, filter: ListFlowRunsFilter = {}): LensFlowRunRow[] {
188
+ const clauses = ['project_slug = ?'];
189
+ const params: (string | number)[] = [projectSlug];
190
+ if (filter.flow_name) {
191
+ clauses.push('flow_name = ?');
192
+ params.push(filter.flow_name);
193
+ }
194
+ if (filter.status) {
195
+ clauses.push('status = ?');
196
+ params.push(filter.status);
197
+ }
198
+ const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
199
+ params.push(limit);
200
+ return db
201
+ .query(`SELECT * FROM lens_flow_runs WHERE ${clauses.join(' AND ')} ORDER BY created_at DESC LIMIT ?`)
202
+ .all(...params) as LensFlowRunRow[];
203
+ }
204
+
205
+ export function getFlowRun(db: Database, id: string): LensFlowRunRow | null {
206
+ return (db.query('SELECT * FROM lens_flow_runs WHERE id = ?').get(id) as LensFlowRunRow | null) ?? null;
207
+ }