@preventive/triage 1.0.0-alpha.28 → 1.0.0-alpha.29
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 +4 -1
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-worker.js +17 -7
- package/server-managed/bundle-build.ts +41 -14
- package/server-managed/index.ts +3 -2
- package/server-managed/storage-maintenance.ts +13 -3
- package/server-managed/storage.ts +1 -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.29",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -97,6 +97,8 @@
|
|
|
97
97
|
"server-managed/bundle-build.ts",
|
|
98
98
|
"server-managed/bundle-build-leases.ts",
|
|
99
99
|
"server-managed/bundle-build-worker.js",
|
|
100
|
+
"server-managed/bundle-build-diagnostics.js",
|
|
101
|
+
"server-managed/bundle-build-diagnostics.d.ts",
|
|
100
102
|
"server-managed/bundle-catalog.ts",
|
|
101
103
|
"server-managed/bundle-store.ts",
|
|
102
104
|
"server-managed/bundle-summary-cache.ts",
|
|
@@ -261,6 +263,7 @@
|
|
|
261
263
|
"@exodus/stasis-core": "1.0.0-beta.5",
|
|
262
264
|
"@preventive/report": "1.0.0-alpha.4",
|
|
263
265
|
"@preventive/upstream": "1.0.0-alpha.4",
|
|
266
|
+
"@vercel/functions": "^3.9.11",
|
|
264
267
|
"ws": "^8.20.1"
|
|
265
268
|
},
|
|
266
269
|
"peerDependencies": {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface BundleBuildDiagnostic {
|
|
2
|
+
message: string
|
|
3
|
+
name?: string
|
|
4
|
+
code?: string
|
|
5
|
+
status?: number
|
|
6
|
+
stack?: string
|
|
7
|
+
cause?: BundleBuildDiagnostic
|
|
8
|
+
}
|
|
9
|
+
export function bundleBuildDiagnostic(error: unknown, token: string | null): BundleBuildDiagnostic
|
|
10
|
+
export type BundleBuildStage = 'worker-start' | 'build' | 'scope' | 'serialize' | 'compress'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Whitelist diagnostic fields; never inspect/serialize an entire upstream
|
|
2
|
+
// error, request, response, or workerData (which contains the GitHub token).
|
|
3
|
+
export function bundleBuildDiagnostic(error, token, depth = 0) {
|
|
4
|
+
const redact = value => {
|
|
5
|
+
let text = String(value)
|
|
6
|
+
if (token) text = text.replaceAll(token, '[redacted]')
|
|
7
|
+
return text.replaceAll(/\b(?:github_pat_|gh[pousr]_)[\w]+/gu, '[redacted]')
|
|
8
|
+
.replaceAll(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gu, '$1[redacted]@')
|
|
9
|
+
.replaceAll(/([?&](?:access_token|token|key)=)[^\s&#]*/giu, '$1[redacted]')
|
|
10
|
+
}
|
|
11
|
+
if (!error || typeof error !== 'object') return { message: redact(error).slice(0, 2048) }
|
|
12
|
+
// Upstream HttpError messages append the response body after the status.
|
|
13
|
+
// Keep the method, URL and status, but omit that body from message and stack.
|
|
14
|
+
const raw = typeof error.message === 'string' ? error.message : 'Unknown error'
|
|
15
|
+
const message = error.name === 'HttpError' ? raw.replace(/(\s\d{3}):[\s\S]*$/u, '$1') : raw
|
|
16
|
+
const result = { message: redact(message).slice(0, 2048) }
|
|
17
|
+
if (typeof error.name === 'string') result.name = redact(error.name).slice(0, 128)
|
|
18
|
+
if (typeof error.code === 'string') result.code = redact(error.code).slice(0, 128)
|
|
19
|
+
if (typeof error.status === 'number') result.status = error.status
|
|
20
|
+
if (typeof error.stack === 'string') result.stack = redact(error.stack.replace(raw, message)).slice(0, 8192)
|
|
21
|
+
if (error.cause && depth < 3) result.cause = bundleBuildDiagnostic(error.cause, token, depth + 1)
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
@@ -6,39 +6,49 @@ import { buildGitHubBundle } from '@exodus/stasis/vfs-bundle'
|
|
|
6
6
|
import { brotliOptions } from '@exodus/stasis-core/brotli'
|
|
7
7
|
import { HttpError, createClient } from '@preventive/upstream/github.js'
|
|
8
8
|
import { githubBundleFilename } from './bundle-build.ts'
|
|
9
|
+
import { bundleBuildDiagnostic } from './bundle-build-diagnostics.js'
|
|
9
10
|
|
|
10
|
-
export async function buildStasisBundle({ input, github, token, maxBytes, scopes }, client = createClient({ token })) {
|
|
11
|
+
export async function buildStasisBundle({ input, github, token, maxBytes, scopes }, client = createClient({ token }), progress = () => {}) {
|
|
11
12
|
const allowed = path => scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))
|
|
12
13
|
// Stasis/upstream cache writes are disabled by default; this fresh worker
|
|
13
14
|
// never enables a cache directory or inherits application credentials.
|
|
15
|
+
progress('build')
|
|
14
16
|
const { bundle } = await buildGitHubBundle({
|
|
15
17
|
github, sha: input.commit, directory: input.directory || undefined,
|
|
16
18
|
entries: input.entries.map(entry => posix.relative(input.directory || '.', entry)),
|
|
17
19
|
...input.options, client,
|
|
18
20
|
})
|
|
19
21
|
const directory = bundle.repo?.directory ?? ''
|
|
22
|
+
progress('scope')
|
|
20
23
|
// A workspace lockfile may widen the build root. Never publish it under a
|
|
21
24
|
// narrower team grant: the storage location must cover the actual bundle.
|
|
22
25
|
if (!allowed(directory)) throw new Error('build-scope')
|
|
26
|
+
progress('serialize')
|
|
23
27
|
const serialized = bundle.serialize()
|
|
24
28
|
if (Buffer.byteLength(serialized) > 200 * 1024 * 1024) throw new Error('too-large')
|
|
29
|
+
progress('compress')
|
|
25
30
|
const bytes = brotliCompressSync(serialized, brotliOptions())
|
|
26
31
|
if (bytes.length > maxBytes) throw new Error('too-large')
|
|
27
32
|
return { bytes, directory, filename: githubBundleFilename(github, input.directory, input.commit) }
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
if (parentPort && workerData?.type === 'managed-bundle-build') {
|
|
31
|
-
|
|
36
|
+
// Send diagnostics to the request's parent thread, which writes them before
|
|
37
|
+
// returning the HTTP response. Worker stdout can be lost on termination.
|
|
38
|
+
const send = message => {
|
|
32
39
|
// Node worker messages have no browser target origin.
|
|
33
40
|
// oxlint-disable-next-line unicorn/require-post-message-target-origin
|
|
34
|
-
parentPort.postMessage(
|
|
41
|
+
parentPort.postMessage(message)
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
send(await buildStasisBundle(workerData, undefined, stage => send({ type: 'progress', stage })))
|
|
35
45
|
} catch (error) {
|
|
46
|
+
const diagnostic = bundleBuildDiagnostic(error, workerData.token)
|
|
36
47
|
// Do not return upstream bodies or paths outside the caller's grants.
|
|
37
|
-
const code = ['build-scope', 'too-large'].includes(
|
|
48
|
+
const code = ['build-scope', 'too-large'].includes(diagnostic.message) ? diagnostic.message
|
|
38
49
|
: error instanceof HttpError ? (error.status === 429 ? 'github-rate-limited' : 'github-build-failed')
|
|
39
|
-
: /no packageManager given|lockfile installs/u.test(
|
|
50
|
+
: /no packageManager given|lockfile installs/u.test(diagnostic.message) ? 'build-lockfile'
|
|
40
51
|
: 'build-failed'
|
|
41
|
-
|
|
42
|
-
parentPort.postMessage({ error: code, status: code === 'too-large' ? 413 : code === 'build-scope' ? 403 : 422 })
|
|
52
|
+
send({ error: code, status: code === 'too-large' ? 413 : code === 'build-scope' ? 403 : 422, diagnostic })
|
|
43
53
|
}
|
|
44
54
|
}
|
|
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
3
3
|
import { posix } from 'node:path'
|
|
4
4
|
import { Worker } from 'node:worker_threads'
|
|
5
5
|
import { BUNDLE_BUILD_TIMEOUT_MS, type BundleBuildLeaseStore } from './bundle-build-leases.ts'
|
|
6
|
+
import { type BundleBuildDiagnostic, type BundleBuildStage, bundleBuildDiagnostic } from './bundle-build-diagnostics.js'
|
|
6
7
|
|
|
7
8
|
export class BundleBuildError extends Error {
|
|
8
9
|
status: number
|
|
@@ -113,28 +114,54 @@ export async function buildRepositoryBundle(userId: string, request: BuildReques
|
|
|
113
114
|
signal.throwIfAborted()
|
|
114
115
|
active.add(userId)
|
|
115
116
|
let worker: Worker | undefined
|
|
117
|
+
let workerUrl: URL | undefined
|
|
118
|
+
let stage: BundleBuildStage = 'worker-start'
|
|
119
|
+
const buildId = randomUUID(), started = performance.now()
|
|
120
|
+
const context = { buildId, repoId: request.input.repoId, github: request.github, commit: request.input.commit,
|
|
121
|
+
directory: request.input.directory, entryCount: request.input.entries.length, node: process.version }
|
|
122
|
+
const log = (event: string, details: Record<string, unknown> = {}) => {
|
|
123
|
+
const write = event === 'failed' ? console.error : console.info
|
|
124
|
+
write('managed-bundle-build:', JSON.stringify({ ...context, workerUrl: workerUrl?.href, event, stage, elapsedMs: Math.round(performance.now() - started), ...details }))
|
|
125
|
+
}
|
|
116
126
|
try {
|
|
117
|
-
|
|
127
|
+
workerUrl = new URL('./bundle-build-worker.js', import.meta.url)
|
|
128
|
+
log('started')
|
|
129
|
+
worker = new Worker(workerUrl, {
|
|
118
130
|
workerData: { ...request, type: 'managed-bundle-build' }, env: {}, execArgv: [], resourceLimits: { maxOldGenerationSizeMb: 512 },
|
|
119
131
|
})
|
|
120
132
|
const running = worker
|
|
121
133
|
return await new Promise((resolve, reject) => {
|
|
122
|
-
|
|
123
|
-
const
|
|
134
|
+
let settled = false
|
|
135
|
+
const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', abort) }
|
|
136
|
+
const fail = (status: number, code: string, details: Record<string, unknown> = {}) => {
|
|
137
|
+
if (settled) return
|
|
138
|
+
settled = true
|
|
139
|
+
cleanup()
|
|
140
|
+
log(code === 'build-cancelled' ? 'cancelled' : 'failed', { code, ...details })
|
|
141
|
+
reject(new BundleBuildError(status, code))
|
|
142
|
+
}
|
|
143
|
+
const timer = setTimeout(() => fail(504, 'build-timeout'), BUNDLE_BUILD_TIMEOUT_MS)
|
|
144
|
+
const abort = () => fail(signal.reason instanceof BundleBuildError ? signal.reason.status : 499,
|
|
145
|
+
signal.reason instanceof BundleBuildError ? signal.reason.code : 'build-cancelled')
|
|
124
146
|
signal.addEventListener('abort', abort, { once: true })
|
|
125
|
-
running.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (message.error)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
clearTimeout(timer)
|
|
134
|
-
signal.removeEventListener('abort', abort)
|
|
135
|
-
reject(new BundleBuildError(422, 'build-failed'))
|
|
147
|
+
running.on('message', (message: BuiltBundle & { type?: string; stage?: BundleBuildStage; error?: string; status?: number; diagnostic?: BundleBuildDiagnostic }) => {
|
|
148
|
+
if (settled) return
|
|
149
|
+
if (message.type === 'progress' && message.stage) { stage = message.stage; log('progress'); return }
|
|
150
|
+
if (message.error) { fail(message.status ?? 422, message.error, { diagnostic: message.diagnostic }); return }
|
|
151
|
+
settled = true
|
|
152
|
+
cleanup()
|
|
153
|
+
log('completed', { byteSize: message.bytes.length })
|
|
154
|
+
resolve({ bytes: Buffer.from(message.bytes), directory: message.directory, filename: message.filename })
|
|
136
155
|
})
|
|
156
|
+
running.once('error', error => fail(422, 'build-failed', { diagnostic: bundleBuildDiagnostic(error, request.token) }))
|
|
157
|
+
running.once('exit', exitCode => fail(422, 'build-failed', { exitCode }))
|
|
158
|
+
if (signal.aborted) abort()
|
|
137
159
|
})
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!(error instanceof BundleBuildError)) {
|
|
162
|
+
log('failed', { code: 'build-failed', diagnostic: bundleBuildDiagnostic(error, request.token) })
|
|
163
|
+
}
|
|
164
|
+
throw error
|
|
138
165
|
} finally {
|
|
139
166
|
try { await worker?.terminate() } finally { active.delete(userId) }
|
|
140
167
|
}
|
package/server-managed/index.ts
CHANGED
|
@@ -88,8 +88,9 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
88
88
|
}
|
|
89
89
|
async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
|
|
90
90
|
const pendingMaintenance = automaticReap()
|
|
91
|
-
// Send the normal response without waiting for storage housekeeping
|
|
92
|
-
//
|
|
91
|
+
// Send the normal response without waiting for storage housekeeping. The
|
|
92
|
+
// Vercel entrypoint registers this whole promise with waitUntil; awaiting
|
|
93
|
+
// work after res.end() alone cannot extend a serverless invocation.
|
|
93
94
|
try { await routeRequest(req, res) }
|
|
94
95
|
finally { await pendingMaintenance }
|
|
95
96
|
}
|
|
@@ -134,6 +134,13 @@ interface StorageMigrationResult extends StorageEncryptionState {
|
|
|
134
134
|
retryAt: number | null
|
|
135
135
|
failures: { type: StorageMigrationRow['type']; id: string; message: string }[]
|
|
136
136
|
}
|
|
137
|
+
function rowFailure(row: StorageMigrationRow, message: string) {
|
|
138
|
+
const failure = { type: row.type, id: row.id, message }
|
|
139
|
+
// Emit before continuing: a later failure or terminated invocation must not
|
|
140
|
+
// hide earlier diagnostics. Never include provider errors or storage values.
|
|
141
|
+
console.warn('managed-storage-migration-row:', JSON.stringify(failure))
|
|
142
|
+
return failure
|
|
143
|
+
}
|
|
137
144
|
interface MigrationOptions { maxObjects?: number; maxMs?: number | undefined; signal?: AbortSignal | undefined }
|
|
138
145
|
export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
139
146
|
{ maxObjects = 64, maxMs = 150_000, signal: stopping }: MigrationOptions = {}): Promise<StorageMigrationResult> {
|
|
@@ -152,9 +159,10 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
|
|
|
152
159
|
if (rows.length === 0) { await db.advanceStorageMigration(state.cursor, null); break }
|
|
153
160
|
for (const row of rows) {
|
|
154
161
|
if (Date.now() >= deadline || signal.aborted) break
|
|
162
|
+
console.info('managed-storage-migration-row-start:', JSON.stringify({ type: row.type, id: row.id }))
|
|
155
163
|
try {
|
|
156
164
|
const pending = await migrateRow(raw, db, key, row, signal)
|
|
157
|
-
if (pending) failures.push(
|
|
165
|
+
if (pending) failures.push(rowFailure(row, pending))
|
|
158
166
|
}
|
|
159
167
|
catch (err) {
|
|
160
168
|
// A row started late gets a full budget next time. Skip an oversized
|
|
@@ -163,8 +171,10 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
|
|
|
163
171
|
const failure = signal.aborted
|
|
164
172
|
? `Could not migrate ${row.type} ${row.id} within MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS=${maxMs}; increase the budget`
|
|
165
173
|
: err instanceof StoragePayloadError ? err.message : null
|
|
166
|
-
if (failure === null)
|
|
167
|
-
|
|
174
|
+
if (failure === null) {
|
|
175
|
+
rowFailure(row, 'Migration operation failed')
|
|
176
|
+
errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
|
|
177
|
+
} else failures.push(rowFailure(row, failure))
|
|
168
178
|
}
|
|
169
179
|
if (stopping?.aborted) break
|
|
170
180
|
await db.advanceStorageMigration(state.cursor, row.position)
|
|
@@ -41,8 +41,8 @@ export async function openManagedStorage(config: ManagedConfig) {
|
|
|
41
41
|
if (!config.storageEncryptionMigrate || signal?.aborted) return
|
|
42
42
|
const state = await db.getStorageEncryption()
|
|
43
43
|
if (state?.complete && state.cleanupComplete) return
|
|
44
|
+
console.info('managed-storage-migration-start:', JSON.stringify({ migrated: state?.migrated, cursor: state?.cursor }))
|
|
44
45
|
const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs, signal })
|
|
45
|
-
for (const failure of result.failures) console.warn('managed-storage-migration-row:', JSON.stringify(failure))
|
|
46
46
|
console.info('managed-storage-migration:', JSON.stringify({
|
|
47
47
|
complete: result.complete === 1, cleanupComplete: result.cleanupComplete === 1,
|
|
48
48
|
migrated: result.migrated, cursor: result.cursor, retryAt: result.retryAt,
|