@preventive/triage 1.0.0-alpha.29 → 1.0.0-alpha.30
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/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-metadata.d.ts +1 -1
- package/common/bundle-metadata.js +4 -1
- package/out/client-managed.js +64 -64
- package/out/graph.js +7 -7
- package/out/view.css +1 -1
- package/out/view.js +166 -166
- package/package.json +10 -3
- package/server-managed/bundle-cache.ts +1 -1
- package/server-managed/bundle-summary-cache.ts +4 -3
- package/server-managed/http.ts +6 -4
- package/server-managed/storage-maintenance.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.30",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"./reap": "./api/reap.ts",
|
|
33
33
|
"./server": "./server.ts",
|
|
34
34
|
"./server-e2e": "./server-e2e/index.ts",
|
|
35
|
+
"./bundle-build-worker": "./server-managed/bundle-build-worker.js",
|
|
35
36
|
"./strip-types-loader": "./strip-types-loader.js",
|
|
36
37
|
"./package.json": "./package.json"
|
|
37
38
|
},
|
|
@@ -172,6 +173,8 @@
|
|
|
172
173
|
"server-common/storage-crypto.ts",
|
|
173
174
|
"server-common/vercel-blob.ts",
|
|
174
175
|
"server-common/scan-config.ts",
|
|
176
|
+
"common/bundle-commit.d.ts",
|
|
177
|
+
"common/bundle-commit.js",
|
|
175
178
|
"common/bundle-metadata.d.ts",
|
|
176
179
|
"common/bundle-metadata.js",
|
|
177
180
|
"common/bundle-reasons.d.ts",
|
|
@@ -248,6 +251,7 @@
|
|
|
248
251
|
"@stylistic/stylelint-plugin": "^5.3.0",
|
|
249
252
|
"@types/node": "^26.2.0",
|
|
250
253
|
"@types/ws": "^8.18.1",
|
|
254
|
+
"@vercel/functions": "^3.9.11",
|
|
251
255
|
"brotli": "^1.3.3",
|
|
252
256
|
"esbuild": "^0.28.2",
|
|
253
257
|
"lit": "^3.3.3",
|
|
@@ -263,12 +267,12 @@
|
|
|
263
267
|
"@exodus/stasis-core": "1.0.0-beta.5",
|
|
264
268
|
"@preventive/report": "1.0.0-alpha.4",
|
|
265
269
|
"@preventive/upstream": "1.0.0-alpha.4",
|
|
266
|
-
"@vercel/functions": "^3.9.11",
|
|
267
270
|
"ws": "^8.20.1"
|
|
268
271
|
},
|
|
269
272
|
"peerDependencies": {
|
|
270
273
|
"@neondatabase/serverless": "^1.0.2",
|
|
271
|
-
"@vercel/blob": "^2.3.3"
|
|
274
|
+
"@vercel/blob": "^2.3.3",
|
|
275
|
+
"@vercel/functions": "^3.9.11"
|
|
272
276
|
},
|
|
273
277
|
"peerDependenciesMeta": {
|
|
274
278
|
"@neondatabase/serverless": {
|
|
@@ -276,6 +280,9 @@
|
|
|
276
280
|
},
|
|
277
281
|
"@vercel/blob": {
|
|
278
282
|
"optional": true
|
|
283
|
+
},
|
|
284
|
+
"@vercel/functions": {
|
|
285
|
+
"optional": true
|
|
279
286
|
}
|
|
280
287
|
},
|
|
281
288
|
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
|
@@ -19,7 +19,7 @@ const MAX_DECODED_BYTES = 512 * 1024 * 1024
|
|
|
19
19
|
export const MAX_PACKAGE_INVENTORY_BYTES = 1024 * 1024
|
|
20
20
|
export type BundleCachePart = 'metadata' | 'contents'
|
|
21
21
|
export type BundleCacheRecord = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'kind' | 'byteSize'>
|
|
22
|
-
export interface BundleSummary { files: number; codeFiles: number; lines: number }
|
|
22
|
+
export interface BundleSummary { files: number; codeFiles: number; lines: number; commit?: string }
|
|
23
23
|
|
|
24
24
|
export async function readBundleDetails(record: BundleCacheRecord, store: BundleStore) {
|
|
25
25
|
const bytes = await store.get(record.id, record.kind)
|
|
@@ -2,10 +2,11 @@ import { Buffer } from 'node:buffer'
|
|
|
2
2
|
import { setImmediate } from 'node:timers/promises'
|
|
3
3
|
import { CacheMissError } from './cache-storage.ts'
|
|
4
4
|
import { decodeUtf8 } from '../common/utf8.js'
|
|
5
|
+
import { bundleCommitHash } from '../common/bundle-commit.js'
|
|
5
6
|
import type { BundleCacheRecord, BundleCacheStorage, BundleSummary } from './bundle-cache.ts'
|
|
6
7
|
|
|
7
|
-
//
|
|
8
|
-
export const SUMMARY_FILENAME = '
|
|
8
|
+
// Version 3 adds the optional source commit; older bundles use bounded backfill.
|
|
9
|
+
export const SUMMARY_FILENAME = 'v3-summary.json'
|
|
9
10
|
const RETRY_MS = 5 * 60_000
|
|
10
11
|
const BACKFILL_LIMIT = 4
|
|
11
12
|
type CachedSummary = BundleSummary | { retryAt: number }
|
|
@@ -24,7 +25,7 @@ async function readSummary(storage: BundleCacheStorage, id: string): Promise<Cac
|
|
|
24
25
|
if (summary && 'retryAt' in summary && Number.isSafeInteger(summary.retryAt) && summary.retryAt > 0) return summary
|
|
25
26
|
const counts = summary as BundleSummary
|
|
26
27
|
if (!counts || ![counts.files, counts.codeFiles, counts.lines].every(value => Number.isSafeInteger(value) && value >= 0)
|
|
27
|
-
|| counts.codeFiles > counts.files) throw new Error('Invalid bundle summary')
|
|
28
|
+
|| counts.codeFiles > counts.files || (counts.commit !== undefined && !bundleCommitHash(counts.commit))) throw new Error('Invalid bundle summary')
|
|
28
29
|
return counts
|
|
29
30
|
} finally { cached.stream.destroy() }
|
|
30
31
|
}
|
package/server-managed/http.ts
CHANGED
|
@@ -1175,16 +1175,16 @@ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, ses
|
|
|
1175
1175
|
await backfillBundleSummaries(catalog.bundles, deps.bundleCache)
|
|
1176
1176
|
}
|
|
1177
1177
|
|
|
1178
|
-
type UploadedBundle = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
|
|
1178
|
+
type UploadedBundle = Pick<ManagedBundle, 'id' | 'slug' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
|
|
1179
1179
|
async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, bundle: UploadedBundle, deduped: boolean): Promise<void> {
|
|
1180
1180
|
const s = await manageMutation(req, res, deps, cookie)
|
|
1181
1181
|
if (!s) return
|
|
1182
1182
|
if (deduped && !(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1183
|
-
const { id, integrity, filename, byteSize, repoId, repoDirectory } = bundle
|
|
1183
|
+
const { id, slug, integrity, filename, byteSize, repoId, repoDirectory } = bundle
|
|
1184
1184
|
// Re-uploading also repairs reports uploaded while the bundle was inaccessible.
|
|
1185
1185
|
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1186
1186
|
prebuildBundle(deps, id)
|
|
1187
|
-
sendJson(res, deduped ? 200 : 201, { id, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
|
|
1187
|
+
sendJson(res, deduped ? 200 : 201, { id, slug, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
|
|
1188
1188
|
}
|
|
1189
1189
|
|
|
1190
1190
|
async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
@@ -1327,7 +1327,9 @@ async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, de
|
|
|
1327
1327
|
if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, true); return }
|
|
1328
1328
|
// Our insert committed: continue with the ordinary creation response.
|
|
1329
1329
|
}
|
|
1330
|
-
|
|
1330
|
+
const stored = await deps.db.getBundle(id)
|
|
1331
|
+
if (!stored) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1332
|
+
await sendUploadedBundle(req, res, deps, cookie, stored, false)
|
|
1331
1333
|
}
|
|
1332
1334
|
|
|
1333
1335
|
// GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
|
|
@@ -4,6 +4,22 @@ import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow,
|
|
|
4
4
|
import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
|
|
5
5
|
import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
|
|
6
6
|
|
|
7
|
+
async function replacementFailure(raw: RawObjectStorage, identity: string, version: string, signal: AbortSignal): Promise<string> {
|
|
8
|
+
const format = (value: string) => value.startsWith('W/') ? 'weak' : value.startsWith('"') && value.endsWith('"') ? 'quoted' : 'unquoted'
|
|
9
|
+
const reason = `Conditional replacement rejected (object version precondition failed); download=${format(version)}`
|
|
10
|
+
// Diagnose only failed writes. A metadata read cannot authorize a retry with
|
|
11
|
+
// a different version: the downloaded bytes must remain bound to their own.
|
|
12
|
+
let current
|
|
13
|
+
try { current = await raw.head(identity, signal) }
|
|
14
|
+
catch { return `${reason}; metadata=unavailable` }
|
|
15
|
+
if (!current) return `${reason}; metadata=missing`
|
|
16
|
+
// Normalization is diagnostic only. Never use it for conditional writes or
|
|
17
|
+
// log the actual ETags, which can contain hashes of private payloads.
|
|
18
|
+
const value = (tag: string) => tag.replace(/^W\//u, '').replace(/^"(.*)"$/u, '$1')
|
|
19
|
+
const comparison = current.version === version ? 'same' : value(current.version) === value(version) ? 'format-only' : 'different'
|
|
20
|
+
return `${reason}; metadata=${format(current.version)}; comparison=${comparison}`
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal): Promise<string | void> {
|
|
8
24
|
if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
|
|
9
25
|
const row = await db.ensureStorageDataKey(item.type, item.id)
|
|
@@ -39,7 +55,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
39
55
|
let replaced: boolean
|
|
40
56
|
try { replaced = await raw.put(identity, encrypted, signal, source.version, current.size ?? undefined) }
|
|
41
57
|
finally { encrypted.destroy(); current.stream.destroy() }
|
|
42
|
-
if (!replaced) return
|
|
58
|
+
if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
|
|
43
59
|
// Do not mark the row encrypted until the stored bytes authenticate and
|
|
44
60
|
// match the original upload (including decompression for sourcemaps).
|
|
45
61
|
const replacement = await raw.open(identity, signal)
|