@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.
package/src/frames.ts ADDED
@@ -0,0 +1,195 @@
1
+ // F312.2 / F312.3 — cut a recording into stills, and rank them.
2
+ //
3
+ // Frames are DERIVED. The video is the artifact; stills are cut on demand around a
4
+ // moment the probe already named, because a 600-frame contact sheet is a disk bill
5
+ // and a token bill that answers nothing. The default is sparse-everywhere plus
6
+ // dense-where-it-matters.
7
+ import { spawn } from 'node:child_process';
8
+ import { mkdir, readdir, rm, stat } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+
11
+ export interface Frame {
12
+ /** ms from the start of the recording. */
13
+ t: number;
14
+ /** Absolute path on disk. */
15
+ file: string;
16
+ }
17
+
18
+ export interface CutOptions {
19
+ /** Frames per second to extract. 4 is a good sparse pass; 15 for a burst. */
20
+ fps?: number;
21
+ /** Start of the window, ms. */
22
+ fromMs?: number;
23
+ /** Length of the window, ms. Omitted = to the end. */
24
+ durationMs?: number;
25
+ }
26
+
27
+ export interface CutResult {
28
+ frames: Frame[];
29
+ /** Set when frames could not be produced. The VIDEO is still fine — this must
30
+ * never fail a run (F255.5's rule, inherited deliberately). */
31
+ unavailable?: string;
32
+ }
33
+
34
+ /** Is ffmpeg reachable at all? Answered once per call, cheaply. */
35
+ export async function ffmpegAvailable(bin = 'ffmpeg'): Promise<boolean> {
36
+ return new Promise((resolve) => {
37
+ const p = spawn(bin, ['-version'], { stdio: 'ignore' });
38
+ p.on('error', () => resolve(false));
39
+ p.on('close', (code) => resolve(code === 0));
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Cut `video` into PNGs in `outDir`.
45
+ *
46
+ * FAILS SOFT. ffmpeg missing, ffmpeg erroring, a zero-length recording — all
47
+ * return `unavailable` with a reason and an empty list. The caller still has the
48
+ * video, which is the thing a human can actually watch.
49
+ */
50
+ export async function cutFrames(
51
+ videoPath: string,
52
+ outDir: string,
53
+ opts: CutOptions = {},
54
+ bin = 'ffmpeg',
55
+ ): Promise<CutResult> {
56
+ const fps = opts.fps ?? 4;
57
+ if (!(await ffmpegAvailable(bin))) {
58
+ return { frames: [], unavailable: `${bin} is not installed — video kept, no stills` };
59
+ }
60
+ await mkdir(outDir, { recursive: true });
61
+ const args: string[] = [];
62
+ // -ss BEFORE -i seeks by keyframe and is fast; accuracy inside a webm at 4fps is
63
+ // ample for pointing a human at a moment.
64
+ if (opts.fromMs != null) args.push('-ss', (opts.fromMs / 1000).toFixed(3));
65
+ args.push('-i', videoPath);
66
+ if (opts.durationMs != null) args.push('-t', (opts.durationMs / 1000).toFixed(3));
67
+ // NO `-vsync`. It was REMOVED in ffmpeg 9 (measured: "Unrecognized option
68
+ // 'vsync'", exit 8) and the fps filter already decides the output rate, so the
69
+ // flag was doing nothing even on the versions that still accepted it.
70
+ args.push('-vf', `fps=${fps}`, join(outDir, 'f-%05d.png'));
71
+
72
+ const code = await new Promise<number>((resolve) => {
73
+ const p = spawn(bin, args, { stdio: 'ignore' });
74
+ p.on('error', () => resolve(-1));
75
+ p.on('close', (c) => resolve(c ?? -1));
76
+ });
77
+ if (code !== 0) {
78
+ await rm(outDir, { recursive: true, force: true }).catch(() => {});
79
+ return { frames: [], unavailable: `ffmpeg exited ${code} — video kept, no stills` };
80
+ }
81
+ const names = (await readdir(outDir)).filter((n) => n.endsWith('.png')).sort();
82
+ if (names.length === 0) return { frames: [], unavailable: 'ffmpeg produced no frames' };
83
+
84
+ // F312.26 — EXIT 0 DOES NOT PROVE A PICTURE EXISTS. Asked for a window past the
85
+ // end of the recording, ffmpeg exits 0 and writes an EMPTY file. Reported by
86
+ // fd-sundhed, who found it BECAUSE they were told this file had no tests and so
87
+ // wrote a negative control instead of assuming coverage.
88
+ //
89
+ // The list was built from FILENAMES, so a zero-byte PNG became a Frame pointing
90
+ // at nothing and the failure surfaced much later — in a report, as a picture
91
+ // that will not render.
92
+ const sized = await Promise.all(
93
+ names.map(async (n) => {
94
+ const file = join(outDir, n);
95
+ try {
96
+ return { n, file, bytes: (await stat(file)).size };
97
+ } catch {
98
+ return { n, file, bytes: 0 };
99
+ }
100
+ }),
101
+ );
102
+ const real = sized.filter((f) => f.bytes > 0);
103
+ if (real.length === 0) {
104
+ // NOT an empty frame list: "the video had nothing in it" and "every file
105
+ // ffmpeg wrote was empty" are different facts, and only one is about the video.
106
+ return {
107
+ frames: [],
108
+ unavailable: `ffmpeg wrote ${names.length} empty file(s) at exit 0 — the window is probably past the end of the recording`,
109
+ };
110
+ }
111
+
112
+ const base = opts.fromMs ?? 0;
113
+ // The Nth extracted frame sits at N/fps into the window — the timestamp is
114
+ // RECONSTRUCTED, not read, so it maps onto the probe's timeline exactly.
115
+ // INDEXED OVER THE SURVIVING frames, and that ordering matters: numbering them
116
+ // before dropping the empties would shift every timestamp after a gap.
117
+ return {
118
+ frames: real.map((f, i) => ({ t: Math.round(base + (i * 1000) / fps), file: f.file })),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The frame nearest a moment — how a probe finding becomes a picture.
124
+ *
125
+ * F312.26 — AND IT REFUSES A MOMENT IT DOES NOT COVER. This used to return the
126
+ * nearest frame however far away it was, so asking about a second outside the
127
+ * recording handed back the first or last picture with nothing saying it was
128
+ * minutes off. A still from another minute, presented as the moment the defect
129
+ * happened, is a wrong answer that looks right.
130
+ *
131
+ * `toleranceMs` DEFAULTS to the frame spacing plus a little, derived from the
132
+ * frames themselves — so a caller asking about a moment inside the recording is
133
+ * unaffected, and only the out-of-range question changes its answer. fd-sundhed
134
+ * refused the same guess in their own build: "der gættes ikke på nærmeste frame".
135
+ */
136
+ export function frameAt(frames: Frame[], tMs: number, toleranceMs?: number): Frame | null {
137
+ if (frames.length === 0) return null;
138
+ let best = frames[0]!;
139
+ for (const f of frames) if (Math.abs(f.t - tMs) < Math.abs(best.t - tMs)) best = f;
140
+
141
+ // The spacing is what the caller implicitly accepted by choosing an fps: a
142
+ // moment between two frames is always within half a step of one of them.
143
+ const spacing =
144
+ frames.length > 1
145
+ ? Math.abs(frames[1]!.t - frames[0]!.t)
146
+ : 1000;
147
+ const tolerance = toleranceMs ?? spacing * 2;
148
+ return Math.abs(best.t - tMs) <= tolerance ? best : null;
149
+ }
150
+
151
+ export interface DiffPoint {
152
+ t: number;
153
+ /** Fraction of pixels that changed between this frame and the one before. */
154
+ changed: number;
155
+ }
156
+
157
+ /**
158
+ * Consecutive-frame difference — the SECOND ranker, for defects with no numeric
159
+ * signal (a flash, a repaint, a colour flicker).
160
+ *
161
+ * It is deliberately dumb and deliberately free. It cannot invent a finding; it
162
+ * can only over-report, which a human dismisses in a second. That is the opposite
163
+ * failure mode from asking a model to "find the flicker" in 600 near-identical
164
+ * frames, which answers with the same confidence whether or not one is there.
165
+ */
166
+ export async function diffTimeline(
167
+ frames: Frame[],
168
+ load: (file: string) => Promise<{ data: Buffer; width: number; height: number } | null>,
169
+ compare: (a: Buffer, b: Buffer, w: number, h: number) => number,
170
+ ): Promise<DiffPoint[]> {
171
+ const out: DiffPoint[] = [];
172
+ let prev: { data: Buffer; width: number; height: number } | null = null;
173
+ for (const f of frames) {
174
+ const img = await load(f.file);
175
+ if (!img) {
176
+ prev = null;
177
+ continue;
178
+ }
179
+ if (prev && prev.width === img.width && prev.height === img.height) {
180
+ const n = compare(prev.data, img.data, img.width, img.height);
181
+ out.push({ t: f.t, changed: n / (img.width * img.height) });
182
+ }
183
+ prev = img;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /** The busiest moments, worst first. Zero is a valid answer and is returned as
189
+ * zero — never padded to look useful. */
190
+ export function busiest(points: DiffPoint[], minChanged = 0.02, limit = 8): DiffPoint[] {
191
+ return points
192
+ .filter((p) => p.changed >= minChanged)
193
+ .sort((a, b) => b.changed - a.changed)
194
+ .slice(0, limit);
195
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,123 @@
1
+ // F074.7 — Lens gate: strict data-testid enforcement + verdict→action.
2
+ //
3
+ // The verification CONTRACT made strict. F073 Auto Review (and the cardmem
4
+ // audit) consume this:
5
+ //
6
+ // 1. checkAnchors() — every element-mode surface in a project's manifest
7
+ // must anchor on a [data-testid] that ACTUALLY EXISTS in the source
8
+ // (cross-checked against the testid inventory). A surface that points at
9
+ // a non-existent testid is a BROKEN ANCHOR (hard gate fail) — "a visual
10
+ // AC cannot pass without its anchor". A surface that uses a CSS/text
11
+ // selector instead of a testid is a CONVENTION WARNING (migrate it).
12
+ //
13
+ // 2. decideVerdict() — fold the catalogue result + the anchor report into a
14
+ // single green/red verdict + an action. F073's review loop OWNS the
15
+ // execution (auto-merge the PR on green; open a GitHub issue + ping
16
+ // Discord on red) since it holds the PR + dispatch context; this module
17
+ // produces the decision it acts on.
18
+
19
+ import type { LensManifest } from './manifest.ts';
20
+
21
+ /** Pull the testid out of a selector like [data-testid="board-column-ready"]. */
22
+ export function testidFromSelector(selector: string | undefined): string | null {
23
+ if (!selector) return null;
24
+ const m = selector.match(/\[data-testid\s*=\s*["']?([^"'\]]+)["']?\]/);
25
+ return m ? m[1]! : null;
26
+ }
27
+
28
+ export type AnchorStatus = 'testid-ok' | 'testid-missing' | 'not-testid' | 'n/a';
29
+
30
+ export interface AnchorCheck {
31
+ surface: string;
32
+ mode: string;
33
+ selector: string | null;
34
+ status: AnchorStatus;
35
+ testid: string | null;
36
+ }
37
+
38
+ export interface AnchorReport {
39
+ anchors: AnchorCheck[];
40
+ /** Element surfaces pointing at a testid that doesn't exist in source — hard. */
41
+ broken: AnchorCheck[];
42
+ /** Element surfaces using a non-testid selector — convention warning. */
43
+ warnings: AnchorCheck[];
44
+ }
45
+
46
+ /** Cross-check a manifest's element surfaces against the repo's testid inventory. */
47
+ export function checkAnchors(manifest: LensManifest, inventoryTestIds: string[]): AnchorReport {
48
+ const present = new Set(inventoryTestIds);
49
+ const anchors: AnchorCheck[] = manifest.surfaces.map((s) => {
50
+ if (s.mode !== 'element') {
51
+ return { surface: s.name, mode: s.mode ?? 'viewport', selector: s.selector ?? null, status: 'n/a' as const, testid: null };
52
+ }
53
+ const testid = testidFromSelector(s.selector);
54
+ if (!testid) {
55
+ return { surface: s.name, mode: 'element', selector: s.selector ?? null, status: 'not-testid' as const, testid: null };
56
+ }
57
+ return {
58
+ surface: s.name,
59
+ mode: 'element',
60
+ selector: s.selector ?? null,
61
+ status: present.has(testid) ? ('testid-ok' as const) : ('testid-missing' as const),
62
+ testid,
63
+ };
64
+ });
65
+ return {
66
+ anchors,
67
+ broken: anchors.filter((a) => a.status === 'testid-missing'),
68
+ warnings: anchors.filter((a) => a.status === 'not-testid'),
69
+ };
70
+ }
71
+
72
+ export interface CatalogueLike {
73
+ total: number;
74
+ passed: number;
75
+ failed: number;
76
+ surfaces: Array<{ name: string; status: string; reason?: string | null }>;
77
+ }
78
+
79
+ export type Verdict = 'green' | 'red';
80
+ export type GateAction = 'allow' | 'block';
81
+
82
+ export interface GateVerdict {
83
+ verdict: Verdict;
84
+ action: GateAction;
85
+ reasons: string[];
86
+ failed_surfaces: Array<{ name: string; status: string; reason?: string | null }>;
87
+ broken_anchors: AnchorCheck[];
88
+ convention_warnings: AnchorCheck[];
89
+ /** A one-line summary for an issue title / Discord ping / audit row. */
90
+ summary: string;
91
+ }
92
+
93
+ /** Fold catalogue verdicts + the anchor report into a single gate decision.
94
+ * RED (block) when any surface failed/errored OR any anchor is broken.
95
+ * Convention warnings (non-testid element selectors) do NOT block on their
96
+ * own — they're surfaced for migration but don't fail a working AC. */
97
+ export function decideVerdict(catalogue: CatalogueLike, anchors: AnchorReport): GateVerdict {
98
+ const failed = catalogue.surfaces.filter((s) => s.status === 'fail' || s.status === 'error');
99
+ const reasons: string[] = [];
100
+ if (failed.length) reasons.push(`${failed.length}/${catalogue.total} surface(s) failed: ${failed.map((f) => f.name).join(', ')}`);
101
+ if (anchors.broken.length) {
102
+ reasons.push(`${anchors.broken.length} broken testid anchor(s): ${anchors.broken.map((a) => `${a.surface}→${a.testid}`).join(', ')}`);
103
+ }
104
+ if (anchors.warnings.length) {
105
+ reasons.push(`${anchors.warnings.length} surface(s) not using a [data-testid] anchor (convention): ${anchors.warnings.map((a) => a.surface).join(', ')}`);
106
+ }
107
+
108
+ const isRed = failed.length > 0 || anchors.broken.length > 0;
109
+ const verdict: Verdict = isRed ? 'red' : 'green';
110
+ const summary = isRed
111
+ ? `Lens gate RED — ${[failed.length ? `${failed.length} failed` : '', anchors.broken.length ? `${anchors.broken.length} broken anchor(s)` : ''].filter(Boolean).join(' + ')}`
112
+ : `Lens gate GREEN — ${catalogue.passed}/${catalogue.total} surface(s) pass`;
113
+
114
+ return {
115
+ verdict,
116
+ action: isRed ? 'block' : 'allow',
117
+ reasons,
118
+ failed_surfaces: failed,
119
+ broken_anchors: anchors.broken,
120
+ convention_warnings: anchors.warnings,
121
+ summary,
122
+ };
123
+ }
package/src/http.ts ADDED
@@ -0,0 +1,175 @@
1
+ // F074.5 — Lens HTTP surface (daemon /lens/*).
2
+ //
3
+ // Thin Hono router over the service layer. The REST surface beneath the MCP
4
+ // tools: CLI, CI, the SPA dashboard (F074.8), and any agent that prefers HTTP
5
+ // all hit these. Artifact bytes are served here too, so an <img> in the
6
+ // dashboard points straight at the daemon.
7
+
8
+ import { Hono } from 'hono';
9
+ import { setSandboxResolver } from './sandbox.ts';
10
+ import { registerDaemonRoutes } from './daemon-routes.ts';
11
+ import {
12
+ lensCapture,
13
+ lensVerify,
14
+ lensCaptureCatalogue,
15
+ lensApproveBaseline,
16
+ lensClearBaseline,
17
+ lensListRuns,
18
+ lensListBaselines,
19
+ lensListBaselineVersions,
20
+ lensRevertBaseline,
21
+ lensBillboard,
22
+ lensRunFlow,
23
+ runFlatFlow,
24
+ isFlatFlowBody,
25
+ lensRunManuscript,
26
+ lensListFlowRuns,
27
+ type LensDeps,
28
+ promoteEvidence,
29
+ lensExport,
30
+ } from './service.ts';
31
+ import { drainUploads, uploadStats } from './artifact-uploads.ts';
32
+ import { backfillFromDisk } from './artifact-backfill.ts';
33
+ import { artifactContentType } from './storage.ts';
34
+
35
+ export function createLensRouter(deps: LensDeps): Hono {
36
+ const r = new Hono();
37
+ // lens F001.5 — the daemon's sandbox is injected, never copied (sandbox.ts).
38
+ if (deps.resolveSandboxedPath) setSandboxResolver(deps.resolveSandboxedPath);
39
+ // lens F001.5 — the routes that used to be registered one by one in the
40
+ // daemon's index.ts. First, so their specific paths win as they did there.
41
+ registerDaemonRoutes(r, deps);
42
+
43
+ const handle = <T>(fn: (body: T) => Promise<unknown> | unknown) => async (c: import('hono').Context) => {
44
+ const body = (await c.req.json().catch(() => ({}))) as T;
45
+ try {
46
+ const result = await fn(body);
47
+ return c.json({ ok: true, ...(result as object) });
48
+ } catch (err) {
49
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
50
+ }
51
+ };
52
+
53
+ r.post('/capture', handle((body) => lensCapture(deps, body as Parameters<typeof lensCapture>[1])));
54
+ r.post('/verify', handle((body) => lensVerify(deps, body as Parameters<typeof lensVerify>[1])));
55
+ // F074.63 — the export verb beside capture. Same pipeline, one terminal call
56
+ // changed: a document whose text a designer can still edit, instead of a
57
+ // picture of one.
58
+ r.post('/export', handle((body) => lensExport(deps, body as Parameters<typeof lensExport>[1])));
59
+ r.post('/capture-catalogue', handle((body) => lensCaptureCatalogue(deps, body as Parameters<typeof lensCaptureCatalogue>[1])));
60
+ r.post('/baseline/approve', handle((body) => lensApproveBaseline(deps, body as Parameters<typeof lensApproveBaseline>[1])));
61
+ r.post('/baseline/clear', handle((body) => lensClearBaseline(deps, body as Parameters<typeof lensClearBaseline>[1])));
62
+ // F094.3 — baseline version history + revert.
63
+ r.post('/baseline/revert', handle((body) => lensRevertBaseline(deps, body as Parameters<typeof lensRevertBaseline>[1])));
64
+
65
+ // F082 — run an E2E manuscript flow + list past flow runs. F074.24: a flat cloud
66
+ // FlowRequest (from @broberg/lens-client) is dispatched to runFlatFlow (engine-hosted,
67
+ // returns the native cloud FlowResult); the native {project, flow} body → lensRunFlow.
68
+ r.post('/flow', handle((body) => (isFlatFlowBody(body)
69
+ ? runFlatFlow(deps, body)
70
+ : lensRunFlow(deps, body as Parameters<typeof lensRunFlow>[1]))));
71
+ r.post('/manuscript', handle((body) => lensRunManuscript(deps, body as Parameters<typeof lensRunManuscript>[1])));
72
+
73
+ // F272.2 — copy a flow run's step stills into the project artifact store, so the
74
+ // evidence an AC cites survives this machine.
75
+ r.post('/promote-evidence', handle((body) => promoteEvidence(deps, body as Parameters<typeof promoteEvidence>[1])));
76
+
77
+ // F276.1/F276.4 — the upload queue: what it holds, and one drain pass.
78
+ r.get('/uploads', (c) => c.json({ ok: true, ...uploadStats(deps.db) }));
79
+ r.post('/uploads/drain', handle(async (body) => {
80
+ const b = (body ?? {}) as { batch?: number };
81
+ return drainUploads(deps.db, deps.storage, deps.cloud?.() ?? null, { batch: b.batch });
82
+ }));
83
+ // F276.4 — enqueue everything already on disk. Idempotent: artifact_key is the
84
+ // primary key, so re-running adds nothing and re-uploads nothing.
85
+ r.post('/uploads/backfill', handle(async () => backfillFromDisk(deps)));
86
+
87
+ r.get('/flow-runs', (c) => {
88
+ const project = c.req.query('project');
89
+ if (!project) return c.json({ ok: false, error: 'project query param required' }, 400);
90
+ try {
91
+ const out = lensListFlowRuns(deps, {
92
+ project,
93
+ flow_name: c.req.query('flow_name'),
94
+ status: c.req.query('status'),
95
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
96
+ });
97
+ return c.json({ ok: true, ...out });
98
+ } catch (err) {
99
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
100
+ }
101
+ });
102
+
103
+ r.get('/runs', (c) => {
104
+ const project = c.req.query('project');
105
+ if (!project) return c.json({ ok: false, error: 'project query param required' }, 400);
106
+ try {
107
+ const out = lensListRuns(deps, {
108
+ project,
109
+ status: c.req.query('status'),
110
+ surface_key: c.req.query('surface_key'),
111
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
112
+ });
113
+ return c.json({ ok: true, ...out });
114
+ } catch (err) {
115
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
116
+ }
117
+ });
118
+
119
+ r.get('/baselines', (c) => {
120
+ const project = c.req.query('project');
121
+ if (!project) return c.json({ ok: false, error: 'project query param required' }, 400);
122
+ try {
123
+ return c.json({ ok: true, ...lensListBaselines(deps, project) });
124
+ } catch (err) {
125
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
126
+ }
127
+ });
128
+
129
+ // F094.3 — the approved-version history for one surface (newest first).
130
+ r.get('/baseline/versions', (c) => {
131
+ const project = c.req.query('project');
132
+ const baseline_key = c.req.query('key');
133
+ if (!project || !baseline_key) {
134
+ return c.json({ ok: false, error: 'project + key query params required' }, 400);
135
+ }
136
+ try {
137
+ return c.json({ ok: true, ...lensListBaselineVersions(deps, { project, baseline_key }) });
138
+ } catch (err) {
139
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
140
+ }
141
+ });
142
+
143
+ // F074.8 — billboard contact-sheet: a Sharp grid of recent run thumbnails.
144
+ r.get('/billboard', async (c) => {
145
+ const project = c.req.query('project');
146
+ if (!project) return c.json({ ok: false, error: 'project query param required' }, 400);
147
+ try {
148
+ const png = await lensBillboard(deps, project, c.req.query('limit') ? Number(c.req.query('limit')) : undefined);
149
+ return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' } });
150
+ } catch (err) {
151
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
152
+ }
153
+ });
154
+
155
+ // Serve artifact bytes (screenshots, diffs, baselines) from the storage
156
+ // adapter. The dashboard <img src> + cc point here.
157
+ r.get('/artifact', async (c) => {
158
+ const key = c.req.query('key');
159
+ if (!key) return c.json({ ok: false, error: 'key query param required' }, 400);
160
+ const bytes = await deps.storage.get(key);
161
+ if (!bytes) return c.json({ ok: false, error: 'artifact not found' }, 404);
162
+ return new Response(new Uint8Array(bytes), {
163
+ headers: {
164
+ // F074.63 — one map, keyed on the extension. The two-branch guess this
165
+ // replaces defaulted everything-not-.webm to image/png, so a PDF export
166
+ // arrived at the designer labelled as an image and simply would not open
167
+ // — a silent failure indistinguishable from the feature not existing.
168
+ 'Content-Type': artifactContentType(key),
169
+ 'Cache-Control': 'private, max-age=31536000, immutable',
170
+ },
171
+ });
172
+ });
173
+
174
+ return r;
175
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // @broberg/lens-daemon — the cardmem daemon's Lens module as a package (lens F001.5).
2
+ //
3
+ // The daemon mounts two routers and runs the Lens schema in its own DB:
4
+ // ensureLensSchema(db) inside getDb()'s schema transaction
5
+ // app.route('/lens', createLensRouter(deps)) every /lens/* route (incl. the ones
6
+ // that used to live in index.ts)
7
+ // app.route('/mcp', createLensMcpRouter(deps))
8
+ // deps.resolveSandboxedPath MUST be the daemon's own sandbox check.
9
+ export { createLensRouter } from './http.ts';
10
+ export { createLensMcpRouter } from './mcp.ts';
11
+ export { createStorage } from './storage.ts';
12
+ export { ensureLensSchema } from './schema.ts';
13
+ export type { LensRunRow, LensBaselineRow, LensBaselineVersionRow, LensFlowRunRow } from './schema.ts';
14
+ export type { LensDeps } from './service.ts';
15
+ export { durabilityOf, type EvidenceDurability } from './durability.ts';
16
+ export { setSandboxResolver, type SandboxResolver } from './sandbox.ts';
17
+ // Lifecycle the daemon owns: provision Chromium at boot, drain the evidence
18
+ // upload queue on its timer, close the warm browser on shutdown.
19
+ export { ensureChromium } from './ensure-chromium.ts';
20
+ export { drainUploads } from './artifact-uploads.ts';
21
+ export { closeBrowser } from './browser.ts';
22
+
23
+ /** The version the daemon reports on /health as {lens:{source, version}} (cardmem #38390). */
24
+ export { version as LENS_DAEMON_VERSION } from '../package.json';