@broberg/lens-daemon 0.0.0-stage → 0.1.5
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/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +239 -0
- package/src/artifact-uploads.ts +397 -0
- package/src/auth/index.ts +301 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2953 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/secret-fill.ts +80 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1493 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// F276.1 — every Lens artifact reaches the cloud, so the owner can open it on a phone.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT THIS CLOSES, in Christian's words (2026-08-25): "Alle Lens billeder bør da
|
|
4
|
+
// placeres i Cloud så jeg kan tilgå dem fra alle steder og maskiner eller kan jeg jo ikke
|
|
5
|
+
// se dem på min iPhone PWA." He was right, and measuring it found something larger — the
|
|
6
|
+
// whole Lens surface reads from 127.0.0.1, so on a phone it does not merely show broken
|
|
7
|
+
// images, it shows "daemon offline". Lens had never worked on his phone at all.
|
|
8
|
+
//
|
|
9
|
+
// A QUEUE, NOT AN INLINE UPLOAD. Three reasons, each of which has a failure attached:
|
|
10
|
+
// · a capture must not get slower, or people stop running them;
|
|
11
|
+
// · an upload failure must not fail the RUN — the local copy is still valid evidence,
|
|
12
|
+
// and losing a run because object storage hiccuped is the worse outcome;
|
|
13
|
+
// · the backfill of 6588 historic files (F276.4) is this same operation at scale, so it
|
|
14
|
+
// uses this machinery rather than a second implementation that drifts from it.
|
|
15
|
+
//
|
|
16
|
+
// WHAT IS DELIBERATELY NOT HERE: deletion. The local copy stays. It is the fast path on
|
|
17
|
+
// the Mac and the fallback when a cloud copy is missing, and removing a working path
|
|
18
|
+
// before its replacement is proven is the naked cutover the harness contract forbids.
|
|
19
|
+
|
|
20
|
+
import type { Database } from 'bun:sqlite';
|
|
21
|
+
import type { LensStorage } from './storage.ts';
|
|
22
|
+
import type { CloudClientLike } from './service.ts';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The queue's schema, owned by the module that reads and writes it.
|
|
26
|
+
*
|
|
27
|
+
* It lives here rather than inline in state.ts so a test can build a real queue table
|
|
28
|
+
* WITHOUT calling getDb — which is a module-level singleton that ignores its path
|
|
29
|
+
* argument after the first call. A second test file that touched it handed the next
|
|
30
|
+
* file someone else's database, and the assertion that failed named a value that test
|
|
31
|
+
* had never written. One definition, two callers, no copy to drift.
|
|
32
|
+
*/
|
|
33
|
+
export const UPLOAD_QUEUE_DDL = `
|
|
34
|
+
CREATE TABLE IF NOT EXISTS lens_artifact_uploads (
|
|
35
|
+
artifact_key TEXT PRIMARY KEY,
|
|
36
|
+
project_slug TEXT NOT NULL,
|
|
37
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
38
|
+
cloud_artifact_id TEXT,
|
|
39
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
last_error TEXT,
|
|
41
|
+
enqueued_at INTEGER NOT NULL,
|
|
42
|
+
uploaded_at INTEGER,
|
|
43
|
+
claimed_at INTEGER
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS lens_artifact_uploads_status_idx
|
|
46
|
+
ON lens_artifact_uploads (status, enqueued_at);
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
/** Create the queue table if it is not there. Idempotent. */
|
|
50
|
+
export function ensureUploadQueue(db: Database): void {
|
|
51
|
+
db.exec(UPLOAD_QUEUE_DDL);
|
|
52
|
+
// Additive for daemons whose table predates F276.5. Look first rather than
|
|
53
|
+
// swallow the ALTER's error: a catch-all would also hide a locked or
|
|
54
|
+
// read-only database, and the drain would then fail later on a column it
|
|
55
|
+
// believes exists.
|
|
56
|
+
const cols = db.query('PRAGMA table_info(lens_artifact_uploads)').all() as { name: string }[];
|
|
57
|
+
if (!cols.some((c) => c.name === 'claimed_at')) {
|
|
58
|
+
db.exec('ALTER TABLE lens_artifact_uploads ADD COLUMN claimed_at INTEGER');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A claim older than this is treated as abandoned and returns to the queue. Long
|
|
63
|
+
* enough that a genuinely slow 7 MB upload is never stolen mid-flight; short enough
|
|
64
|
+
* that a daemon killed mid-batch does not strand its rows for a human to notice. */
|
|
65
|
+
export const CLAIM_TTL_MS = 10 * 60 * 1000;
|
|
66
|
+
|
|
67
|
+
export interface UploadRow {
|
|
68
|
+
artifact_key: string;
|
|
69
|
+
project_slug: string;
|
|
70
|
+
status: 'pending' | 'uploading' | 'done' | 'failed' | 'skipped';
|
|
71
|
+
cloud_artifact_id: string | null;
|
|
72
|
+
attempts: number;
|
|
73
|
+
last_error: string | null;
|
|
74
|
+
enqueued_at: number;
|
|
75
|
+
uploaded_at: number | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Give up after this many tries. A key that fails five times is broken in a way
|
|
79
|
+
* retrying will not fix (bytes gone, file corrupt), and an infinite retry would
|
|
80
|
+
* hide it behind a queue that never drains. */
|
|
81
|
+
export const MAX_ATTEMPTS = 5;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Queue an artifact for upload. Idempotent BY THE SCHEMA — artifact_key is the
|
|
85
|
+
* primary key, so enqueueing the same key twice does nothing.
|
|
86
|
+
*
|
|
87
|
+
* That is not a micro-optimisation: F276.4 re-runs the backfill over 6588 files and
|
|
88
|
+
* must not create 6588 duplicates on the second pass. Expressing it in the table
|
|
89
|
+
* rather than in a guard means no caller can forget it.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* Directories that are deliberately NOT uploaded, each with the reason it is held back.
|
|
93
|
+
*
|
|
94
|
+
* Applied where a file is QUEUED (enqueueArtifact), not only during a backfill —
|
|
95
|
+
* otherwise a live flow under `_external` would be held back only by the project
|
|
96
|
+
* lookup failing, which is the accident this list exists to replace (F006.4).
|
|
97
|
+
*
|
|
98
|
+
* These are held back BY DECISION. Today they also happen to fail the project lookup,
|
|
99
|
+
* which means they are currently safe by accident — and an accident is not a policy.
|
|
100
|
+
* Naming them here makes the hold-back survive someone later fixing the lookup.
|
|
101
|
+
*
|
|
102
|
+
* `capture-sessions` is Christian's call, given 2026-08-25 when asked directly whether
|
|
103
|
+
* 329 screenshots of him signing in to App Store Connect and Google Play should go to
|
|
104
|
+
* the cloud: "Nej." No credentials are visible in them (all 329 verified as PNG), and
|
|
105
|
+
* they stay on the machine anyway.
|
|
106
|
+
*/
|
|
107
|
+
export const SKIP_DIRS: Record<string, string> = {
|
|
108
|
+
'capture-sessions': "owner decision 2026-08-25: login recordings stay on the Mac",
|
|
109
|
+
_external: 'screenshots of third-party sites — not this fleet\'s project history',
|
|
110
|
+
'lens-statepath-test': 'one-off engine test run, not project history',
|
|
111
|
+
'lens-mint-test': 'one-off engine test run, not project history',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
export function enqueueArtifact(db: Database, projectSlug: string, key: string | null): void {
|
|
116
|
+
if (!key) return;
|
|
117
|
+
const skipReason = SKIP_DIRS[key.split('/')[0] ?? ''];
|
|
118
|
+
db.prepare(
|
|
119
|
+
`INSERT OR IGNORE INTO lens_artifact_uploads (artifact_key, project_slug, status, enqueued_at, last_error)
|
|
120
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
121
|
+
).run(key, projectSlug, skipReason ? 'skipped' : 'pending', Date.now(), skipReason ?? null);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Queue several at once — the flow path has one per step. */
|
|
125
|
+
export function enqueueArtifacts(db: Database, projectSlug: string, keys: (string | null)[]): void {
|
|
126
|
+
for (const k of keys) enqueueArtifact(db, projectSlug, k);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DrainResult {
|
|
130
|
+
/** Uploaded successfully in this pass. */
|
|
131
|
+
uploaded: number;
|
|
132
|
+
/** Failed this pass but will be retried. */
|
|
133
|
+
retryable: number;
|
|
134
|
+
/** Given up on (attempts exhausted, or the bytes are gone). */
|
|
135
|
+
abandoned: number;
|
|
136
|
+
/** Still waiting AFTER this pass — so a caller can tell "done" from "batch full". */
|
|
137
|
+
remaining: number;
|
|
138
|
+
/** Why nothing happened, when nothing happened. Stated rather than implied by a
|
|
139
|
+
* row of zeros, because "queue empty" and "not signed in" look identical in
|
|
140
|
+
* counts and mean completely different things. */
|
|
141
|
+
idle_reason: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Upload one batch. Returns counts AND, when nothing moved, the reason.
|
|
146
|
+
*
|
|
147
|
+
* The `idle_reason` field exists because of the failure this whole epic is about: a
|
|
148
|
+
* result of all zeros could mean the queue is empty, or that we are signed out, or
|
|
149
|
+
* that storage is unprovisioned. Three different situations, one indistinguishable
|
|
150
|
+
* report — so the reason is stated.
|
|
151
|
+
*/
|
|
152
|
+
export async function drainUploads(
|
|
153
|
+
db: Database,
|
|
154
|
+
storage: LensStorage,
|
|
155
|
+
cloud: CloudClientLike | null,
|
|
156
|
+
opts: {
|
|
157
|
+
batch?: number;
|
|
158
|
+
projectId?: (slug: string) => Promise<string | null>;
|
|
159
|
+
/** Injectable clock, so the stale-claim path is testable without waiting 10 minutes. */
|
|
160
|
+
now?: () => number;
|
|
161
|
+
} = {},
|
|
162
|
+
): Promise<DrainResult> {
|
|
163
|
+
const empty = (reason: string | null): DrainResult => ({
|
|
164
|
+
uploaded: 0,
|
|
165
|
+
retryable: 0,
|
|
166
|
+
abandoned: 0,
|
|
167
|
+
remaining: pendingCount(db),
|
|
168
|
+
idle_reason: reason,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!cloud) return empty('not signed in to cardmem — run `cardmem login`; nothing was uploaded');
|
|
172
|
+
|
|
173
|
+
const batch = Math.max(1, Math.min(opts.batch ?? 25, 200));
|
|
174
|
+
const now = opts.now?.() ?? Date.now();
|
|
175
|
+
releaseStaleClaims(db, now);
|
|
176
|
+
const rows = claimBatch(db, batch, now);
|
|
177
|
+
|
|
178
|
+
if (rows.length === 0) {
|
|
179
|
+
// "Nothing to do" and "another drain has everything claimed" both produce zero rows
|
|
180
|
+
// and mean opposite things — the first says stop, the second says come back. Say
|
|
181
|
+
// which one it is rather than letting a row of zeros stand for both.
|
|
182
|
+
const inFlight = db
|
|
183
|
+
.query("SELECT COUNT(*) AS n FROM lens_artifact_uploads WHERE status = 'uploading'")
|
|
184
|
+
.get() as { n: number } | undefined;
|
|
185
|
+
return empty(
|
|
186
|
+
Number(inFlight?.n ?? 0) > 0
|
|
187
|
+
? `${inFlight!.n} artifact(s) are claimed by another drain in flight — nothing to take right now`
|
|
188
|
+
: null,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let uploaded = 0;
|
|
193
|
+
let retryable = 0;
|
|
194
|
+
let abandoned = 0;
|
|
195
|
+
const projectIds = new Map<string, string | null>();
|
|
196
|
+
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
try {
|
|
199
|
+
// Resolve the cloud project id once per slug per pass. Guessing one would put a
|
|
200
|
+
// project's screenshots into a different project's store.
|
|
201
|
+
if (!projectIds.has(row.project_slug)) {
|
|
202
|
+
projectIds.set(row.project_slug, await resolveProjectId(cloud, row.project_slug, opts.projectId));
|
|
203
|
+
}
|
|
204
|
+
const projectId = projectIds.get(row.project_slug);
|
|
205
|
+
if (!projectId) throw new Error(`project ${row.project_slug} is not known to cardmem`);
|
|
206
|
+
|
|
207
|
+
const bytes = await storage.get(row.artifact_key);
|
|
208
|
+
if (!bytes) {
|
|
209
|
+
// The row claims an artifact and the storage does not have it. Retrying cannot
|
|
210
|
+
// conjure bytes, so this is ABANDONED immediately rather than burning five
|
|
211
|
+
// attempts — and it is recorded as a distinct outcome, because a missing file
|
|
212
|
+
// is a finding, not a transient error.
|
|
213
|
+
markFailed(db, row.artifact_key, 'bytes are GONE from local storage', MAX_ATTEMPTS);
|
|
214
|
+
abandoned++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const placed = await cloud.callTool<{ artifact_id: string }>('cardmem_place_artifact', {
|
|
219
|
+
project_id: projectId,
|
|
220
|
+
name: artifactName(row.artifact_key),
|
|
221
|
+
mime: row.artifact_key.endsWith('.webm') ? 'video/webm' : 'image/png',
|
|
222
|
+
base64: bytes.toString('base64'),
|
|
223
|
+
source: 'lens',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
db.prepare(
|
|
227
|
+
`UPDATE lens_artifact_uploads
|
|
228
|
+
SET status = 'done', cloud_artifact_id = ?, uploaded_at = ?, last_error = NULL,
|
|
229
|
+
attempts = attempts + 1
|
|
230
|
+
WHERE artifact_key = ?`,
|
|
231
|
+
).run(placed.artifact_id, Date.now(), row.artifact_key);
|
|
232
|
+
uploaded++;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
235
|
+
const attempts = row.attempts + 1;
|
|
236
|
+
markFailed(db, row.artifact_key, msg, attempts);
|
|
237
|
+
if (attempts >= MAX_ATTEMPTS) abandoned++;
|
|
238
|
+
else retryable++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { uploaded, retryable, abandoned, remaining: pendingCount(db), idle_reason: null };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Take a batch of rows OUT of the pending pool in the same statement that reads them.
|
|
247
|
+
*
|
|
248
|
+
* THE BUG THIS CLOSES, measured 2026-08-25: the old code selected `status='pending'`,
|
|
249
|
+
* uploaded (hundreds of ms of network), and only then wrote `done`. For that whole
|
|
250
|
+
* window the row still read `pending` to anyone else — and two drains exist by design,
|
|
251
|
+
* the daemon's 30 s background tick and the HTTP route. On project xrt81-com that put
|
|
252
|
+
* 16 of 27 files in the cloud twice, ids ~65 ms apart, while the local queue showed a
|
|
253
|
+
* flawless 27. A count taken on the side that does the writing cannot see a duplicate
|
|
254
|
+
* it wrote twice.
|
|
255
|
+
*
|
|
256
|
+
* `artifact_key` being the PRIMARY KEY makes ENQUEUEING idempotent, which is true and
|
|
257
|
+
* is an acceptance criterion on F276.4. It says nothing about DRAINING. Idempotent
|
|
258
|
+
* insert and exactly-once processing are different properties.
|
|
259
|
+
*/
|
|
260
|
+
function claimBatch(db: Database, batch: number, now: number): UploadRow[] {
|
|
261
|
+
return db
|
|
262
|
+
.prepare(
|
|
263
|
+
`UPDATE lens_artifact_uploads
|
|
264
|
+
SET status = 'uploading', claimed_at = ?
|
|
265
|
+
WHERE artifact_key IN (
|
|
266
|
+
SELECT artifact_key FROM lens_artifact_uploads
|
|
267
|
+
WHERE status = 'pending' AND attempts < ?
|
|
268
|
+
ORDER BY enqueued_at ASC LIMIT ?
|
|
269
|
+
)
|
|
270
|
+
RETURNING *`,
|
|
271
|
+
)
|
|
272
|
+
.all(now, MAX_ATTEMPTS, batch) as UploadRow[];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Return rows whose claim went stale to the queue.
|
|
277
|
+
*
|
|
278
|
+
* Without this, the cure for double-upload becomes a cause of never-upload: a daemon
|
|
279
|
+
* killed mid-batch would leave its rows in `uploading` forever, and they would be
|
|
280
|
+
* invisible to `pending` and to `failed` alike — the silent gap this epic is about,
|
|
281
|
+
* reintroduced by its own fix.
|
|
282
|
+
*/
|
|
283
|
+
export function releaseStaleClaims(db: Database, now: number = Date.now()): number {
|
|
284
|
+
const r = db
|
|
285
|
+
.prepare(
|
|
286
|
+
`UPDATE lens_artifact_uploads
|
|
287
|
+
SET status = 'pending', claimed_at = NULL
|
|
288
|
+
WHERE status = 'uploading' AND (claimed_at IS NULL OR claimed_at < ?)`,
|
|
289
|
+
)
|
|
290
|
+
.run(now - CLAIM_TTL_MS);
|
|
291
|
+
return Number(r.changes ?? 0);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function markFailed(db: Database, key: string, error: string, attempts: number): void {
|
|
295
|
+
db.prepare(
|
|
296
|
+
`UPDATE lens_artifact_uploads
|
|
297
|
+
SET attempts = ?, last_error = ?, status = ?
|
|
298
|
+
WHERE artifact_key = ?`,
|
|
299
|
+
).run(attempts, error.slice(0, 500), attempts >= MAX_ATTEMPTS ? 'failed' : 'pending', key);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Rows that are not finished — pending AND in flight elsewhere. A row another drain
|
|
303
|
+
* is currently uploading is still not done, and excluding it would make `remaining: 0`
|
|
304
|
+
* mean "nothing left" while an upload is still running. */
|
|
305
|
+
function pendingCount(db: Database): number {
|
|
306
|
+
const r = db
|
|
307
|
+
.query(
|
|
308
|
+
`SELECT COUNT(*) AS n FROM lens_artifact_uploads
|
|
309
|
+
WHERE (status = 'pending' AND attempts < ?) OR status = 'uploading'`,
|
|
310
|
+
)
|
|
311
|
+
.get(MAX_ATTEMPTS) as { n: number } | undefined;
|
|
312
|
+
return Number(r?.n ?? 0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function resolveProjectId(
|
|
316
|
+
cloud: CloudClientLike,
|
|
317
|
+
slug: string,
|
|
318
|
+
override?: (slug: string) => Promise<string | null>,
|
|
319
|
+
): Promise<string | null> {
|
|
320
|
+
if (override) return override(slug);
|
|
321
|
+
const res = await cloud.callTool<{ projects?: Array<{ id: string; slug: string }> }>(
|
|
322
|
+
'cardmem_list_projects',
|
|
323
|
+
{},
|
|
324
|
+
);
|
|
325
|
+
return res?.projects?.find((p) => p.slug === slug)?.id ?? null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** A readable filename from an artifact key, so the Artifacts surface is browsable
|
|
329
|
+
* rather than a wall of hashes. `cardmem/flows/<run>/step-3.png` → `lens-flows-<run>-step-3.png`. */
|
|
330
|
+
export function artifactName(key: string): string {
|
|
331
|
+
const cleaned = key.replace(/^[^/]+\//, '').replace(/\//g, '-');
|
|
332
|
+
return `lens-${cleaned}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** What the queue holds, for the surface and for a human asking "is it done yet?". */
|
|
336
|
+
export function uploadStats(db: Database): {
|
|
337
|
+
pending: number;
|
|
338
|
+
done: number;
|
|
339
|
+
failed: number;
|
|
340
|
+
/** Claimed by a drain and in flight right now (F276.5). Its own bucket: in-flight and
|
|
341
|
+
* not-yet-started are different facts, and sharing one value of `status` between them
|
|
342
|
+
* is what let two drains upload the same file. */
|
|
343
|
+
uploading: number;
|
|
344
|
+
/** Held back on purpose (F276.4). Counted SEPARATELY from failed: one is a decision
|
|
345
|
+
* the owner made, the other is something broken, and a queue that shows them as one
|
|
346
|
+
* number cannot be used to tell whether the backfill is finished. */
|
|
347
|
+
skipped: number;
|
|
348
|
+
total: number;
|
|
349
|
+
/** pending + uploading + done + failed + skipped. If this ever differs from `total`,
|
|
350
|
+
* a row is in a state nothing counts — the silent gap this epic exists to close. */
|
|
351
|
+
accounted: number;
|
|
352
|
+
} {
|
|
353
|
+
const row = db
|
|
354
|
+
.query(
|
|
355
|
+
`SELECT
|
|
356
|
+
SUM(CASE WHEN status = 'pending' AND attempts < ? THEN 1 ELSE 0 END) AS pending,
|
|
357
|
+
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done,
|
|
358
|
+
SUM(CASE WHEN status = 'failed' OR (status = 'pending' AND attempts >= ?) THEN 1 ELSE 0 END) AS failed,
|
|
359
|
+
SUM(CASE WHEN status = 'uploading' THEN 1 ELSE 0 END) AS uploading,
|
|
360
|
+
SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) AS skipped,
|
|
361
|
+
COUNT(*) AS total
|
|
362
|
+
FROM lens_artifact_uploads`,
|
|
363
|
+
)
|
|
364
|
+
.get(MAX_ATTEMPTS, MAX_ATTEMPTS) as Record<string, number> | undefined;
|
|
365
|
+
const pending = Number(row?.pending ?? 0);
|
|
366
|
+
const done = Number(row?.done ?? 0);
|
|
367
|
+
const failed = Number(row?.failed ?? 0);
|
|
368
|
+
const skipped = Number(row?.skipped ?? 0);
|
|
369
|
+
const uploading = Number(row?.uploading ?? 0);
|
|
370
|
+
return {
|
|
371
|
+
pending,
|
|
372
|
+
done,
|
|
373
|
+
failed,
|
|
374
|
+
uploading,
|
|
375
|
+
skipped,
|
|
376
|
+
total: Number(row?.total ?? 0),
|
|
377
|
+
accounted: pending + uploading + done + failed + skipped,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Map artifact keys → their cloud artifact id, for the read path. Only 'done' rows,
|
|
382
|
+
* so a queued-but-not-yet-uploaded key resolves to nothing rather than to a url that
|
|
383
|
+
* 404s — a link in the durable form pointing at nothing is worse than no link. */
|
|
384
|
+
export function cloudIdsFor(db: Database, keys: string[]): Map<string, string> {
|
|
385
|
+
const out = new Map<string, string>();
|
|
386
|
+
if (keys.length === 0) return out;
|
|
387
|
+
const placeholders = keys.map(() => '?').join(',');
|
|
388
|
+
const rows = db
|
|
389
|
+
.query(
|
|
390
|
+
`SELECT artifact_key, cloud_artifact_id FROM lens_artifact_uploads
|
|
391
|
+
WHERE status = 'done' AND cloud_artifact_id IS NOT NULL
|
|
392
|
+
AND artifact_key IN (${placeholders})`,
|
|
393
|
+
)
|
|
394
|
+
.all(...keys) as Array<{ artifact_key: string; cloud_artifact_id: string }>;
|
|
395
|
+
for (const r of rows) out.set(r.artifact_key, r.cloud_artifact_id);
|
|
396
|
+
return out;
|
|
397
|
+
}
|