Skip to content
Merged
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
12 changes: 10 additions & 2 deletions layers/artifact-delivery/server/api/v1/resolutions/[id].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '../../../utils/github-app'
import { canReadPrivateResolution } from '../../../utils/private-access'
import { privateArtifactAccessEnabled } from '../../../utils/private-feature'
import { waitForResolutionChange } from '../../../utils/resolution-wait'
import { setSkillPageUrlHeader } from '../../../utils/skill-page'
import { getResolution } from '../../../utils/state'

Expand Down Expand Up @@ -50,8 +51,15 @@ export default withArtifactProblems(defineApiHandler({
throw createError({ statusCode: 404, message: 'Resolution not found' })
}
}
await setSkillPageUrlHeader(event, row)
return row
// A building Resolution answers when its state changes, so the CLI sees
// each stage, and the finished build, within one check of it landing.
const current = await waitForResolutionChange({
load: () => getResolution(platform.db, row.id),
sleep: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
now: Date.now,
}, row)
await setSkillPageUrlHeader(event, current)
return current
},
presenter: presentArtifactResolution,
}))
38 changes: 36 additions & 2 deletions layers/artifact-delivery/server/api/v1/resolutions/index.post.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { H3Event } from 'h3'
import { getHeader, setHeader, setResponseStatus } from 'h3'
import { defineApiHandler } from '#shared/server/handler'
import { presentArtifactResolution } from '../../../presenters/resolution'
Expand All @@ -7,11 +8,13 @@ import {
problemCodeSchema,
} from '../../../schemas/contracts'
import { withArtifactProblems } from '../../../utils/artifact-problem'
import { failResolution } from '../../../utils/build'
import { findPrivateRepositoryAccess } from '../../../utils/private-access'
import { privateArtifactAccessEnabled } from '../../../utils/private-feature'
import { enqueueArtifactBuild } from '../../../utils/queue'
import { fetchAdmittedSkillIdentity, requestResolution } from '../../../utils/request-resolution'
import { enqueueAfterResponse, fetchAdmittedSkillIdentity, requestResolution } from '../../../utils/request-resolution'
import { setSkillPageUrlHeader } from '../../../utils/skill-page'
import { getResolution } from '../../../utils/state'

export default withArtifactProblems(defineApiHandler({
schema: createResolutionRequestSchema,
Expand All @@ -36,7 +39,21 @@ export default withArtifactProblems(defineApiHandler({
const result = await requestResolution({
db: platform.db,
lookupAdmitted: fetchAdmittedSkillIdentity(event.context),
enqueue: resolutionId => enqueueArtifactBuild(platform.env, resolutionId),
enqueue: enqueueAfterResponse({
schedule: work => runAfterResponse(event, work),
enqueue: resolutionId => enqueueArtifactBuild(platform.env, resolutionId),
failUnqueued: async (resolutionId, error) => {
console.error(JSON.stringify({
operation: 'artifact-build',
outcome: 'enqueue-failed',
resolutionId,
error: error instanceof Error ? error.message : String(error),
}))
const row = await getResolution(platform.db, resolutionId)
if (row)
await failResolution({ db: platform.db, now: () => Math.floor(Date.now() / 1000) }, row, 'SERVICE_UNAVAILABLE', true)
},
}),
now: () => Math.floor(Date.now() / 1000),
}, {
source: body.source,
Expand Down Expand Up @@ -88,3 +105,20 @@ export default withArtifactProblems(defineApiHandler({
},
presenter: presentArtifactResolution,
}))

/**
* Keep the queue send alive past the response on Workers.
*
* H3Event has no `waitUntil` on h3 1.15, so the send goes through the
* Cloudflare context Nitro mounts. Off Workers (local dev, tests) it does not
* block the response. The registry layer keeps the same helper, and ADR-0001
* keeps utilities out of other layers, so this route holds its own copy.
*/
function runAfterResponse(event: H3Event, promise: Promise<unknown>): void {
const ctx = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise<unknown>) => void } } }).cloudflare?.context
if (ctx?.waitUntil) {
ctx.waitUntil(promise)
return
}
void promise
}
28 changes: 28 additions & 0 deletions layers/artifact-delivery/server/utils/request-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,31 @@ export async function requestResolution(
await dependencies.enqueue(result.row.id)
return result
}

export interface AfterResponseEnqueueDependencies {
/** Keep work alive after the response, such as the Worker's `waitUntil`. */
schedule: (work: Promise<unknown>) => void
enqueue: (resolutionId: string) => Promise<void>
/** Settle a Resolution whose build never reached the queue. */
failUnqueued: (resolutionId: string, error: unknown) => Promise<void>
}

/**
* An `enqueue` that sends the build message after the response.
*
* The queue send took 446 to 500 ms of a 590 to 650 ms request in three
* production traces from Sydney and Bangkok on 2026-10-07. The CLI polls
* for the result anyway, so the send need not hold the first answer. A send
* that fails settles the Resolution as a retryable failure, so the polling
* CLI requests a new Resolution instead of waiting out its deadline.
*/
export function enqueueAfterResponse(
dependencies: AfterResponseEnqueueDependencies,
): (resolutionId: string) => Promise<void> {
return async (resolutionId) => {
dependencies.schedule(
dependencies.enqueue(resolutionId)
.catch(error => dependencies.failUnqueued(resolutionId, error)),
)
}
}
51 changes: 51 additions & 0 deletions layers/artifact-delivery/server/utils/resolution-wait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { ResolutionRow } from './state'
import { ACTIVE_BUILD_STATES } from './state'

/** The longest one Resolution read waits for its build to move. */
export const RESOLUTION_WAIT_MS = 8_000

/** How often a waiting read loads the Resolution again. */
export const RESOLUTION_CHECK_MS = 250

export interface ResolutionWaitDependencies {
/** Load the Resolution as D1 holds it now. */
load: () => Promise<ResolutionRow | null>
sleep: (milliseconds: number) => Promise<void>
/** Milliseconds since the epoch. */
now: () => number
}

/**
* The Resolution once its build leaves the state it had, or as it stands
* when the wait runs out.
*
* A read used to answer at once, and the CLI came back a second later. A
* build that finished just after a read cost the run up to that second, and a
* Resolution passes through seven states. A held read answers within one
* check of each change, so the CLI sees `ready` about 250 ms after it lands.
* A settled Resolution answers at once.
*/
export async function waitForResolutionChange(
dependencies: ResolutionWaitDependencies,
row: ResolutionRow,
budgetMs: number = RESOLUTION_WAIT_MS,
): Promise<ResolutionRow> {
if (!isBuilding(row))
return row
const deadline = dependencies.now() + budgetMs
let current = row
while (dependencies.now() + RESOLUTION_CHECK_MS <= deadline) {
await dependencies.sleep(RESOLUTION_CHECK_MS)
const next = await dependencies.load()
if (!next)
return current
current = next
if (current.state !== row.state)
return current
}
return current
}

function isBuilding(row: ResolutionRow): boolean {
return (ACTIVE_BUILD_STATES as readonly string[]).includes(row.state)
}
4 changes: 3 additions & 1 deletion layers/artifact-delivery/server/utils/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,9 @@ export function presentResolution(row: ResolutionRow): ResolutionResponse {
state: 'pending',
resolutionId: row.id,
stage: row.state as ActiveBuildState,
pollAfterMs: 1000,
// A read of a building Resolution waits for its next state, so the
// CLI may come back at once. See `waitForResolutionChange`.
pollAfterMs: 250,
}
}
if (row.state === 'blocked') {
Expand Down
Loading
Loading