Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 78 additions & 9 deletions backend/src/api/public/v1/akrites-external/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ info:
Packages, Advisories and Contacts endpoints are implemented. Blast Radius
submit (2a) and poll (2b) are both implemented, backed by a 4-stage
Temporal pipeline (intel, dependents, reachability, report) for npm
packages; other ecosystems fail fast with ECOSYSTEM_NOT_SUPPORTED. The
7-day result cache is specced separately and not yet built.
packages; other ecosystems fail fast with ECOSYSTEM_NOT_SUPPORTED. Submit
reuses a 'done' analysis for the same advisory/package/ecosystem if it
completed within the last day (configurable, see the `force` field below)
instead of starting a new Temporal workflow.


Auth0 now issues a dedicated Akrites Enclave M2M client with its own
Expand Down Expand Up @@ -418,7 +420,10 @@ components:
force:
type: boolean
default: false
description: Bypasses the 7-day cache and always triggers a new run. Use sparingly.
description: >
Bypasses the advisory cache (a 'done' analysis for the same
advisory/package/ecosystem completed within the last day, by
default) and always triggers a new run. Use sparingly.

BlastRadiusJobEntry:
type: object
Expand All @@ -445,8 +450,10 @@ components:
type: string
enum: [pending, running, done, failed]
description: >
Pending in the single-job submit response, always returned before the
Temporal workflow runs. In the batch submit response, a job whose
Pending when a job is freshly submitted, returned before the Temporal
workflow runs. Done when the advisory cache is reused instead — see
force above — in which case analysisId is the cached analysis's own
id, already completed. In the batch submit response, a job whose
workflow failed to start comes back as failed instead — the rest of
the batch is unaffected.

Expand Down Expand Up @@ -514,6 +521,69 @@ components:
allOf:
- $ref: '#/components/schemas/BlastRadiusAnalysis'

BlastRadiusJobBatchRequest:
type: object
required: [jobs]
properties:
jobs:
type: array
minItems: 1
maxItems: 20
description: >
Capped much lower than the 100-item read batches — each entry
starts its own Temporal workflow, so the batch multiplies
workflow starts (and reachability-analysis cost) per request.
10 is the recommended default batch size; 20 is the hard limit.
items:
$ref: '#/components/schemas/BlastRadiusJobRequest'

BlastRadiusJobBatchResponse:
type: object
required: [results]
description: >
Plain array in request order, one entry per submitted job — unlike the
read batches there is no found/not-found case, every job is submitted.
properties:
results:
type: array
items:
$ref: '#/components/schemas/BlastRadiusJobEntry'

BlastRadiusJobPollBatchRequest:
type: object
required: [analysisIds]
properties:
analysisIds:
type: array
minItems: 1
maxItems: 100
items:
type: string
format: uuid
page:
type: integer
minimum: 1
default: 1
pageSize:
type: integer
minimum: 1
maximum: 100
default: 20

BlastRadiusAnalysisBulkEntry:
type: object
required: [requestedAnalysisId, found, analysis]
properties:
requestedAnalysisId:
type: string
found:
type: boolean
analysis:
type: object
nullable: true
allOf:
- $ref: '#/components/schemas/BlastRadiusAnalysis'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate OpenAPI batch schemas

Low Severity

This commit re-adds BlastRadiusJobBatchRequest, BlastRadiusJobBatchResponse, BlastRadiusJobPollBatchRequest, and BlastRadiusAnalysisBulkEntry, which already exist just above. YAML duplicate keys keep the later copies, and their BlastRadiusJobBatchResponse text differs from the earlier one, so the less accurate description wins and the two blocks can drift further apart.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 59c09c1. Configure here.

BlastRadiusResultConfidence:
type: string
enum: [high, medium, low]
Expand Down Expand Up @@ -1115,10 +1185,9 @@ paths:
Starts a Temporal workflow running the 4-stage reachability pipeline
(intel, dependents, reachability, report) for npm; other ecosystems
fail fast with ECOSYSTEM_NOT_SUPPORTED. Poll status/results via
GET /jobs/{analysisId}.


Not yet implemented: the 7-day result cache and force-bypass semantics.
GET /jobs/{analysisId}. Reuses a 'done' analysis for the same
advisory/package/ecosystem completed within the last day (by
default) instead of starting a new workflow, unless force is true.
tags: [Blast Radius]
security:
- M2MBearer:
Expand Down
51 changes: 48 additions & 3 deletions backend/src/api/public/v1/packages/blastRadius.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { z } from 'zod'

import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius'
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'

// The reachability pipeline is npm-only for now — every other ecosystem (including
// a missing one) is rejected by the schema below before the Temporal workflow is
// triggered.
Expand All @@ -10,6 +13,14 @@ export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = ['npm'] as const
// so it is NOT run through purlFieldSchema/normalizePurl like the other endpoints.
const ADVISORY_ID_PATTERN = /^(GHSA-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}|CVE-\d{4}-\d{4,})$/

// How recent a 'done' analysis has to be for submit to reuse it instead of
// starting a new workflow — see getCachedJobEntry. force=true bypasses this.
const blastRadiusCacheMaxAgeDaysEnv = Number(process.env.AKRITES_BLAST_RADIUS_CACHE_MAX_AGE_DAYS)
export const BLAST_RADIUS_CACHE_MAX_AGE_DAYS =
Number.isSafeInteger(blastRadiusCacheMaxAgeDaysEnv) && blastRadiusCacheMaxAgeDaysEnv > 0
? blastRadiusCacheMaxAgeDaysEnv
: 1

export const blastRadiusJobRequestSchema = z.object({
advisoryId: z
.string()
Expand All @@ -36,19 +47,53 @@ export interface BlastRadiusJobEntry {
status: BlastRadiusJobStatus
}

// Builds the 2a response body. The pipeline isn't implemented yet, so every freshly
// submitted job comes back pending — see analyzeBlastRadius in packages_worker.
// Builds the 2a response body. status defaults to 'pending', but a cache hit
// passes 'done' so the caller doesn't need to poll a job that's already finished.
export function toBlastRadiusJobEntry(params: {
analysisId: string
advisoryId: string
package: string | null
ecosystem: BlastRadiusJobEcosystem
status?: BlastRadiusJobStatus
}): BlastRadiusJobEntry {
return {
analysisId: params.analysisId,
advisoryId: params.advisoryId,
package: params.package,
ecosystem: params.ecosystem,
status: 'pending',
status: params.status ?? 'pending',
}
}

// Shared by submitBlastRadiusJob and submitBlastRadiusJobBatch — returns the
// cached job entry on a hit, or null on a cache miss or force=true.
export async function getCachedJobEntry(
qx: QueryExecutor,
params: {
advisoryId: string
package: string | null
ecosystem: BlastRadiusJobEcosystem
force: boolean
},
): Promise<BlastRadiusJobEntry | null> {
if (params.force) {
return null
}

const cached = await blastRadiusDal.getRecentDoneAnalysis(
qx,
{ advisoryOsvId: params.advisoryId, packageName: params.package, ecosystem: params.ecosystem },
BLAST_RADIUS_CACHE_MAX_AGE_DAYS,
)
if (!cached) {
return null
}

return toBlastRadiusJobEntry({
analysisId: cached.id,
advisoryId: params.advisoryId,
package: params.package,
ecosystem: params.ecosystem,
status: 'done',
})
}
11 changes: 2 additions & 9 deletions backend/src/api/public/v1/packages/getBlastRadiusJobBatch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Request, Response } from 'express'

import { groupBy } from '@crowd/common'
import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius'

import { getPackagesQx } from '@/db/packagesDb'
Expand Down Expand Up @@ -37,15 +38,7 @@ export async function getBlastRadiusJobBatch(req: Request, res: Response): Promi
blastRadiusDal.getDependentsExcludedByRangeCountBatch(qx, doneIds),
])

const verdictsByAnalysisId = new Map<string, typeof verdictRows>()
for (const row of verdictRows) {
const bucket = verdictsByAnalysisId.get(row.analysisId)
if (bucket) {
bucket.push(row)
} else {
verdictsByAnalysisId.set(row.analysisId, [row])
}
}
const verdictsByAnalysisId = groupBy(verdictRows, (row) => row.analysisId)
const excludedByRangeCountByAnalysisId = new Map(
excludedByRangeCounts.map(({ analysisId, count }) => [analysisId, count]),
)
Expand Down
64 changes: 63 additions & 1 deletion backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { describe, expect, it, vi } from 'vitest'

import { submitBlastRadiusJob } from './submitBlastRadiusJob'

const { start, createAnalysis, failAnalysis } = vi.hoisted(() => ({
const { start, createAnalysis, failAnalysis, getRecentDoneAnalysis } = vi.hoisted(() => ({
start: vi.fn().mockResolvedValue(undefined),
createAnalysis: vi.fn().mockResolvedValue(undefined),
failAnalysis: vi.fn().mockResolvedValue(undefined),
getRecentDoneAnalysis: vi.fn(),
}))

vi.mock('@/db/packagesTemporal', () => ({
Expand All @@ -20,12 +21,15 @@ vi.mock('@/db/packagesDb', () => ({
vi.mock('@crowd/data-access-layer/src/packages/blastRadius', () => ({
createAnalysis,
failAnalysis,
getRecentDoneAnalysis,
}))

function mockReqRes(body: unknown) {
start.mockClear()
createAnalysis.mockClear()
failAnalysis.mockClear()
getRecentDoneAnalysis.mockClear()
getRecentDoneAnalysis.mockResolvedValue(null)

const req = { body } as unknown as Request

Expand Down Expand Up @@ -152,4 +156,62 @@ describe('submitBlastRadiusJob', () => {
})
expect(errorMessage).toBe('temporal unreachable')
})

it('reuses a recent done analysis instead of starting a workflow', async () => {
const { req, res, start, status, json } = mockReqRes({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'npm',
})
getRecentDoneAnalysis.mockResolvedValue({
id: 'cached-analysis-id',
advisory_osv_id: 'GHSA-jf85-cpcp-j695',
package_name: null,
ecosystem: 'npm',
status: 'done',
error: null,
candidates_considered: 5,
started_at: '2026-07-01T00:00:00.000Z',
completed_at: '2026-07-01T01:00:00.000Z',
})

await submitBlastRadiusJob(req, res)

expect(createAnalysis).not.toHaveBeenCalled()
expect(start).not.toHaveBeenCalled()
expect(status).toHaveBeenCalledWith(202)
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
analysisId: 'cached-analysis-id',
advisoryId: 'GHSA-jf85-cpcp-j695',
package: null,
ecosystem: 'npm',
status: 'done',
}),
)
})

it('bypasses the cache and starts a new workflow when force is true, even with a recent done analysis', async () => {
const { req, res, start } = mockReqRes({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'npm',
force: true,
})
getRecentDoneAnalysis.mockResolvedValue({
id: 'cached-analysis-id',
advisory_osv_id: 'GHSA-jf85-cpcp-j695',
package_name: null,
ecosystem: 'npm',
status: 'done',
error: null,
candidates_considered: 5,
started_at: '2026-07-01T00:00:00.000Z',
completed_at: '2026-07-01T01:00:00.000Z',
})

await submitBlastRadiusJob(req, res)

expect(getRecentDoneAnalysis).not.toHaveBeenCalled()
expect(createAnalysis).toHaveBeenCalledTimes(1)
expect(start).toHaveBeenCalledTimes(1)
})
})
24 changes: 20 additions & 4 deletions backend/src/api/public/v1/packages/submitBlastRadiusJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,40 @@ import { getPackagesQx } from '@/db/packagesDb'
import { getPackagesTemporalClient } from '@/db/packagesTemporal'
import { validateOrThrow } from '@/utils/validation'

import { blastRadiusJobRequestSchema, toBlastRadiusJobEntry } from './blastRadius'
import {
blastRadiusJobRequestSchema,
getCachedJobEntry,
toBlastRadiusJobEntry,
} from './blastRadius'

// 2a — submit a blast-radius analysis job. Always exactly one job per request.
// Every submission gets a fresh analysisId and status pending.
// 2a — submit a blast-radius analysis job. Always exactly one job per request,
// unless getCachedJobEntry returns a hit — then that's returned instead, with
// no new row and no workflow start.
export async function submitBlastRadiusJob(req: Request, res: Response): Promise<void> {
const body = validateOrThrow(blastRadiusJobRequestSchema, req.body)

const jobPackage = body.package ?? null
const jobEcosystem = body.ecosystem

const qx = await getPackagesQx()
const cached = await getCachedJobEntry(qx, {
advisoryId: body.advisoryId,
package: jobPackage,
ecosystem: jobEcosystem,
force: body.force,
})
if (cached) {
res.status(202).json(cached)
return
}

const analysisId = generateUUIDv4()

// Create the pending row synchronously, before starting the workflow — otherwise a
// client that polls GET /jobs/:analysisId immediately after this 202 can race
// blastRadiusStart's own createAnalysis call and get a 404 for a job that was, in
// fact, accepted. blastRadiusStart's createAnalysis upserts the same row, so this
// is safe to run again from the workflow.
const qx = await getPackagesQx()
await blastRadiusDal.createAnalysis(qx, {
id: analysisId,
advisoryOsvId: body.advisoryId,
Expand Down
Loading
Loading