@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.29",
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
- // Origin metadata does not change these counts; retain the byte-sized v2 cache.
8
- export const SUMMARY_FILENAME = 'v2-summary.json'
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
  }
@@ -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
- await sendUploadedBundle(req, res, deps, cookie, { id, integrity, filename, byteSize: bytes.length, repoId, repoDirectory: directory }, false)
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 'Conditional replacement rejected (object version precondition failed)'
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)