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
32 changes: 32 additions & 0 deletions packages/worker/src/mcp/capabilities/error-message.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}

export function getErrorCause(error: unknown) {
if (error && typeof error === 'object' && 'cause' in error) {
return (error as { cause?: unknown }).cause
}
return undefined
}

export function getErrorCauseChain(error: unknown) {
const chain: Array<unknown> = []
const seen = new Set<unknown>()
let current: unknown = error
while (current !== undefined && !seen.has(current)) {
seen.add(current)
chain.push(current)
current = getErrorCause(current)
}
return chain
}

export function errorCauseChainIncludes(
error: unknown,
matches: (message: string) => boolean,
) {
return getErrorCauseChain(error).some((entry) =>
matches(getErrorMessage(entry)),
)
}

export function formatErrorCauseChain(error: unknown) {
return getErrorCauseChain(error).map(getErrorMessage).join(' Caused by: ')
}
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,40 @@ test('rebuilds published package bundle artifacts one target at a time after pub
)
})

test('artifact rebuild failures include the failing target and cause', async () => {
setupDefaultMocks()
const target = {
kind: 'module',
artifactName: '.',
entryPoint: 'src/index.ts',
bundleKind: 'module',
}
mockModule.resolveArtifactSourceHead.mockResolvedValue({
branch: 'main',
commit: 'commit-new',
})
mockModule.publishFromExternalRef.mockResolvedValue({
status: 'published',
previous_commit: 'commit-old',
published_commit: 'commit-new',
manifest: {},
checks: [{ kind: 'manifest', ok: true, message: 'ok' }],
})
mockModule.listPublishedPackageArtifactTargets.mockResolvedValue([target])
mockModule.rebuildPublishedPackageArtifact.mockRejectedValueOnce(
new Error('No matching default export for import "default"'),
)

await expect(
publishExternalPushCapability.handler(
{ package_id: 'package-1' },
createContext(),
),
).rejects.toThrow(
'Package source publish succeeded, but bundle artifact rebuild failed for source "source-1" at commit "commit-new" target { kind "module", artifact ".", entry "src/index.ts", bundle "module" }. Cause: No matching default export for import "default". Re-run the publish capability to repair artifacts.',
)
})

test('force publish passes destructive confirmation through and refuses without allow_force', async () => {
setupDefaultMocks()
mockModule.resolveArtifactSourceHead.mockResolvedValue({
Expand Down Expand Up @@ -544,6 +578,59 @@ test('publishExternalPush recovers from transient Durable Object resets', async
}
})

test('publishExternalPush retries when artifact rebuild wraps a transient Durable Object reset cause', async () => {
setupDefaultMocks()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const target = {
kind: 'module',
artifactName: '.',
entryPoint: 'src/index.ts',
bundleKind: 'module',
}
mockModule.resolveArtifactSourceHead.mockResolvedValue({
branch: 'main',
commit: 'commit-new',
})
mockModule.listPublishedPackageArtifactTargets.mockResolvedValue([target])
mockModule.publishFromExternalRef
.mockResolvedValueOnce({
status: 'published',
previous_commit: 'commit-old',
published_commit: 'commit-new',
manifest: {},
checks: [{ kind: 'manifest', ok: true, message: 'ok' }],
})
.mockResolvedValueOnce({
status: 'already_published',
published_commit: 'commit-new',
})
mockModule.rebuildPublishedPackageArtifact
.mockRejectedValueOnce(
new Error('rebuild target failed', {
cause: new Error('Durable Object exceeded its CPU time limit'),
}),
)
.mockResolvedValueOnce({ ok: true, target, kvKey: 'bundle-key' })

try {
const recovered = await publishExternalPushCapability.handler(
{ package_id: 'package-1' },
createContext(),
)

expect(recovered.status).toBe('already_published')
expect(mockModule.publishFromExternalRef).toHaveBeenCalledTimes(2)
expect(mockModule.rebuildPublishedPackageArtifact).toHaveBeenCalledTimes(2)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'package_publish_external_push transient Durable Object reset',
),
)
} finally {
warnSpy.mockRestore()
}
})

test('check failure leaves mutation to the shared publish pipeline', async () => {
setupDefaultMocks()
mockModule.resolveArtifactSourceHead.mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import * as Sentry from '@sentry/cloudflare'
import { z } from 'zod'
import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
import { getErrorMessage } from '#mcp/capabilities/error-message.ts'
import {
errorCauseChainIncludes,
getErrorMessage,
} from '#mcp/capabilities/error-message.ts'
import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
import {
getStaticPackageDependentsSummary,
Expand All @@ -29,11 +32,12 @@ const inputSchema = z.object({
const externalPublishRetryDelaysMs = [100, 500] as const

function isTransientDurableObjectResetError(error: unknown) {
const message = getErrorMessage(error)
return (
message.includes('Durable Object exceeded its CPU time limit') ||
message.includes("Durable Object's isolate exceeded its memory limit") ||
message.includes('Durable Object was reset')
return errorCauseChainIncludes(
error,
(message) =>
message.includes('Durable Object exceeded its CPU time limit') ||
message.includes("Durable Object's isolate exceeded its memory limit") ||
message.includes('Durable Object was reset'),
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
import { repoSessionRpc } from '#worker/repo/repo-session-do.ts'
import { type PublishedPackageArtifactBuildTarget } from '#worker/package-runtime/package-artifact-targets.ts'
import { formatErrorCauseChain } from '#mcp/capabilities/error-message.ts'

function describePackageArtifactTarget(
target: PublishedPackageArtifactBuildTarget,
) {
return [
`kind "${target.kind}"`,
`artifact "${target.artifactName ?? '<default>'}"`,
`entry "${target.entryPoint}"`,
`bundle "${target.bundleKind}"`,
].join(', ')
}

function buildRebuildFailureMessage(input: {
sourceId: string
publishedCommit: string
target?: PublishedPackageArtifactBuildTarget
error: unknown
}) {
const target = input.target
? ` target { ${describePackageArtifactTarget(input.target)} }`
: ''
return `Package source publish succeeded, but bundle artifact rebuild failed for source "${input.sourceId}" at commit "${input.publishedCommit}"${target}. Cause: ${formatErrorCauseChain(input.error)}. Re-run the publish capability to repair artifacts.`
}

export async function rebuildPublishedPackageArtifactsViaRepoSession(input: {
env: Env
Expand All @@ -9,14 +34,26 @@ export async function rebuildPublishedPackageArtifactsViaRepoSession(input: {
publishedCommit: string
baseUrl: string
}) {
const session = repoSessionRpc(input.env, input.rpcSessionId)
let targets: Array<PublishedPackageArtifactBuildTarget>
try {
const session = repoSessionRpc(input.env, input.rpcSessionId)
const targets = await session.listPublishedPackageArtifactTargets({
targets = await session.listPublishedPackageArtifactTargets({
sessionId: input.repoSessionId,
sourceId: input.sourceId,
userId: input.userId,
})
for (const target of targets) {
} catch (error) {
throw new Error(
buildRebuildFailureMessage({
sourceId: input.sourceId,
publishedCommit: input.publishedCommit,
error,
}),
{ cause: error },
)
}
for (const target of targets) {
try {
await session.rebuildPublishedPackageArtifact({
sessionId: input.repoSessionId,
sourceId: input.sourceId,
Expand All @@ -25,11 +62,16 @@ export async function rebuildPublishedPackageArtifactsViaRepoSession(input: {
target,
baseUrl: input.baseUrl,
})
} catch (error) {
throw new Error(
buildRebuildFailureMessage({
sourceId: input.sourceId,
publishedCommit: input.publishedCommit,
target,
error,
}),
{ cause: error },
)
}
} catch (error) {
throw new Error(
`Package source publish succeeded, but bundle artifact rebuild failed for source "${input.sourceId}" at commit "${input.publishedCommit}". Re-run the publish capability to repair artifacts.`,
{ cause: error },
)
}
}
85 changes: 85 additions & 0 deletions packages/worker/src/package-runtime/package-artifact-targets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
getPackageAppEntryPath,
listPackageServices,
listPackageSubscriptions,
normalizePackageWorkspacePath,
} from '#worker/package-registry/manifest.ts'
import {
type AuthoredPackageJson,
type PackageExportTarget,
} from '#worker/package-registry/types.ts'
import { type BundleArtifactKind } from './published-runtime-artifacts.ts'
import { buildPackageSubscriptionArtifactName } from './subscription-artifacts.ts'

export type PublishedPackageArtifactBuildTarget = {
kind: BundleArtifactKind
artifactName?: string | null
entryPoint: string
bundleKind: 'app' | 'module' | 'importable-module'
}

function resolvePackageExportRuntimeEntryPoint(target: PackageExportTarget) {
return typeof target === 'string'
? target
: (target.import ?? target.default ?? null)
}

export function collectPublishedPackageArtifactTargets(
manifest: AuthoredPackageJson,
) {
const targets: Array<PublishedPackageArtifactBuildTarget> = []
if (manifest.kody.app) {
const entryPoint = getPackageAppEntryPath(manifest)
if (entryPoint) {
targets.push({
kind: 'app',
entryPoint,
bundleKind: 'app',
})
}
}
for (const service of listPackageServices(manifest)) {
targets.push({
kind: 'service',
artifactName: service.name,
entryPoint: service.entry,
bundleKind: 'module',
})
}
for (const [exportName, exportTarget] of Object.entries(manifest.exports)) {
const entryPoint = resolvePackageExportRuntimeEntryPoint(exportTarget)
if (!entryPoint) continue
const normalizedEntryPoint = normalizePackageWorkspacePath(entryPoint)
targets.push({
kind: 'module',
artifactName: exportName,
entryPoint: normalizedEntryPoint,
bundleKind: 'module',
})
targets.push({
kind: 'importable-module',
artifactName: exportName,
entryPoint: normalizedEntryPoint,
bundleKind: 'importable-module',
})
}
for (const subscription of listPackageSubscriptions(manifest)) {
targets.push({
kind: 'module',
artifactName: buildPackageSubscriptionArtifactName(subscription.topic),
entryPoint: subscription.handler,
bundleKind: 'module',
})
}
for (const [jobName, jobDefinition] of Object.entries(
manifest.kody.jobs ?? {},
)) {
targets.push({
kind: 'job',
artifactName: jobName,
entryPoint: normalizePackageWorkspacePath(jobDefinition.entry),
bundleKind: 'module',
})
}
return targets
}
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,10 @@ test('rebuildPublishedPackageArtifacts bundles declared subscription handlers',

expect(buildAppBundle).not.toHaveBeenCalled()
expect(buildModuleBundle).toHaveBeenCalledWith({
entryPoint: './src/index.ts',
entryPoint: 'src/index.ts',
})
expect(buildImportableModuleBundle).toHaveBeenCalledWith({
entryPoint: './src/index.ts',
entryPoint: 'src/index.ts',
})
expect(buildModuleBundle).toHaveBeenCalledWith({
entryPoint: 'src/on-email-received.ts',
Expand Down
Loading
Loading