diff --git a/docs/arch/cron.md b/docs/arch/cron.md
index 848f2eab..d89f3e13 100644
--- a/docs/arch/cron.md
+++ b/docs/arch/cron.md
@@ -2,7 +2,7 @@
Generated from literal `defineScheduledTask` declarations. Run `pnpm cron:docs` after schedule changes.
-Tasks: 22. Unique Cloudflare triggers: 14.
+Tasks: 23. Unique Cloudflare triggers: 15.
| Task | Cron | Source |
| --- | --- | --- |
@@ -23,6 +23,7 @@ Tasks: 22. Unique Cloudflare triggers: 14.
| `scheduled-cadence-watchdog` | `*/5 * * * *` | `server/tasks/scheduled-cadence-watchdog.ts` |
| `send-digests` | `0 9 1 * *` | `layers/identity/server/tasks/send-digests.ts` |
| `send-weekly` | `0 9 * * MON` | `layers/identity/server/tasks/send-weekly.ts` |
+| `sweep-skill-runs` | `7,22,37,52 * * * *` | `layers/artifact-delivery/server/tasks/sweep-skill-runs.ts` |
| `sync-bsky-mentions` | `17 */2 * * *` | `layers/registry/server/tasks/sync-bsky-mentions.ts` |
| `sync-github-skills` | `0 * * * *` | `layers/registry/server/tasks/sync-github-skills.ts` |
| `sync-reviewed-skill-repos` | `*/5 * * * *` | `layers/registry/server/tasks/sync-reviewed-skill-repos.ts` |
diff --git a/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts b/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts
index 3a5ad670..631a6983 100644
--- a/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts
+++ b/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts
@@ -10,11 +10,8 @@ import { withArtifactProblems } from '../../../utils/artifact-problem'
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 { setSkillPageUrlHeader } from '../../../utils/skill-page'
-import {
- createResolution,
- resolutionRequestIdentity,
-} from '../../../utils/state'
export default withArtifactProblems(defineApiHandler({
schema: createResolutionRequestSchema,
@@ -36,14 +33,15 @@ export default withArtifactProblems(defineApiHandler({
body.source.repository,
)
: { _tag: 'not-found' as const }
- const accountId = privateAccess._tag === 'allowed' ? user!.id : undefined
- const identity = await resolutionRequestIdentity(body.source, idempotencyKey, accountId)
- const result = await createResolution(
- platform.db,
- body.source,
- identity,
- Math.floor(Date.now() / 1000),
- privateAccess._tag === 'allowed'
+ const result = await requestResolution({
+ db: platform.db,
+ lookupAdmitted: fetchAdmittedSkillIdentity(event.context),
+ enqueue: resolutionId => enqueueArtifactBuild(platform.env, resolutionId),
+ now: () => Math.floor(Date.now() / 1000),
+ }, {
+ source: body.source,
+ idempotencyKey,
+ access: privateAccess._tag === 'allowed'
? {
visibility: 'private',
accountId: privateAccess.accountId,
@@ -51,7 +49,7 @@ export default withArtifactProblems(defineApiHandler({
repositoryId: privateAccess.repositoryId,
}
: { visibility: 'public' },
- )
+ })
if (result._tag === 'idempotency-conflict') {
throw createError({
statusCode: 409,
@@ -80,9 +78,6 @@ export default withArtifactProblems(defineApiHandler({
data: { code: problemCodeSchema.parse(result.row.error_code) },
})
}
- if (result.row.state === 'requested')
- await enqueueArtifactBuild(platform.env, result.row.id)
-
const response = presentArtifactResolution(result.row)
setResponseStatus(event, response.state === 'pending' ? 202 : 200)
setHeader(event, 'cache-control', 'private, no-store')
diff --git a/layers/artifact-delivery/server/schemas/contracts.ts b/layers/artifact-delivery/server/schemas/contracts.ts
index 0755f8cc..42c0b07c 100644
--- a/layers/artifact-delivery/server/schemas/contracts.ts
+++ b/layers/artifact-delivery/server/schemas/contracts.ts
@@ -140,6 +140,8 @@ export const resolutionFailedSchema = z.object({
resolutionId: resolutionIdSchema,
code: problemCodeSchema,
retryable: z.boolean(),
+ /** Seconds until the same request can work. skilld 3.2.0 and later read it. */
+ retryAfterSeconds: z.number().int().positive().optional(),
}).strict()
export const resolutionRevokedSchema = z.object({
diff --git a/layers/artifact-delivery/server/tasks/sweep-skill-runs.ts b/layers/artifact-delivery/server/tasks/sweep-skill-runs.ts
new file mode 100644
index 00000000..87876138
--- /dev/null
+++ b/layers/artifact-delivery/server/tasks/sweep-skill-runs.ts
@@ -0,0 +1,108 @@
+///
+
+import type { SkillRunFailure } from '../utils/run-sweep'
+import { resolveCloudflareBindings } from '@harlan-zw/nuxt-cloudflare/bindings'
+import { runObservedScheduledTask } from '~~/server/utils/scheduled-run'
+import { reportJobRun } from '~~/server/utils/sync-job-reporter'
+import { observedSchedulePolicy } from '#shared/schedule-policy'
+import { enqueueArtifactBuild } from '../utils/queue'
+import { fetchAdmittedSkillIdentity, requestResolution } from '../utils/request-resolution'
+import { runSkillRunSweep } from '../utils/run-sweep'
+
+const CRON = '7,22,37,52 * * * *'
+/**
+ * 25 checks every 15 minutes is 2,400 a day, so about 2,100 indexed Skills
+ * cycle in under a day. A ready build of the same commit is reused without a
+ * GitHub read, so a repeat check of an unchanged Skill costs D1 rows only.
+ */
+const BATCH_SIZE = 25
+
+/**
+ * Scheduled task: check that every indexed Skill still runs.
+ *
+ * Each check is the Resolution request `skilld run OWNER/REPOSITORY/NAME`
+ * sends, through the same code and build queue. The next run reads how it
+ * ended. A failure a retry cannot change, on a Skill that did not fail that
+ * way before, fails the run: the cadence watchdog alerts on a failed run.
+ *
+ * The minutes avoid the top of the hour, when the hourly sync spends most of
+ * the GitHub quota.
+ */
+export default defineScheduledTask({
+ name: 'sweep-skill-runs',
+ cron: '7,22,37,52 * * * *',
+ description: 'Check that each indexed Skill resolves through skilld run',
+ async run({ context }) {
+ const env = resolveCloudflareBindings(context)
+ const db = env?.DB as D1Database | undefined
+ if (!env || !db) {
+ emitOperationalEvent(createWideEvent({ operation: 'sweep-skill-runs', outcome: 'binding-missing' }))
+ return { result: { error: 'no-db' } }
+ }
+
+ return await runObservedScheduledTask({
+ db,
+ env,
+ context,
+ policy: observedSchedulePolicy('sweep-skill-runs'),
+ }, async () => {
+ const startedAt = Date.now()
+ const now = () => Math.floor(Date.now() / 1000)
+ const report = await runSkillRunSweep({
+ db,
+ listRunnableSkills: async () => (await $fetch<{ items: Array<{ owner: string, repository: string, name: string }> }>('/api/skills/runnable')).items,
+ requestResolution: input => requestResolution({
+ db,
+ lookupAdmitted: fetchAdmittedSkillIdentity(),
+ enqueue: resolutionId => enqueueArtifactBuild(env, resolutionId),
+ now,
+ }, input),
+ newIdempotencyKey: () => `sweep-${crypto.randomUUID()}`,
+ now,
+ batchSize: BATCH_SIZE,
+ })
+
+ for (const failure of report.newFailures) {
+ emitOperationalEvent(createWideEvent({
+ operation: 'sweep-skill-runs',
+ outcome: 'new-failure',
+ repo: skillRef(failure),
+ reason: failure.tag,
+ }), 'error')
+ }
+ emitOperationalEvent(createWideEvent({
+ 'operation': 'sweep-skill-runs',
+ 'outcome': 'completed',
+ 'item.count': report.started,
+ 'processed.count': report.settled,
+ 'failed.count': report.failing,
+ 'error.count': report.transientFailures.length,
+ }), 'info')
+
+ const alarm = report.newFailures.length > 0
+ ? `skilld run fails for ${report.newFailures.length} more Skill(s): ${report.newFailures.slice(0, 10).map(failure => `${skillRef(failure)} (${failure.tag})`).join(', ')}`
+ : null
+ await reportJobRun(db, 'sweep-skill-runs', {
+ cron: CRON,
+ status: alarm ? 'error' : 'ok',
+ durationMs: Date.now() - startedAt,
+ error: alarm,
+ })
+ if (alarm)
+ throw new Error(alarm)
+ return {
+ result: {
+ started: report.started,
+ settled: report.settled,
+ failing: report.failing,
+ transient: report.transientFailures.length,
+ recovered: report.recovered.length,
+ },
+ }
+ })
+ },
+})
+
+function skillRef(failure: SkillRunFailure): string {
+ return `${failure.owner}/${failure.repository}/${failure.name}`
+}
diff --git a/layers/artifact-delivery/server/utils/admitted-identity.ts b/layers/artifact-delivery/server/utils/admitted-identity.ts
new file mode 100644
index 00000000..08dbe4a2
--- /dev/null
+++ b/layers/artifact-delivery/server/utils/admitted-identity.ts
@@ -0,0 +1,51 @@
+import type { SourceRequest } from '../schemas/contracts'
+
+export interface AdmittedSkillIdentity {
+ /** The admitted Skill folder inside the Repository, or `.` for the root. */
+ skillPath: string
+ /** The commit the Skill page names, or null when the registry has none. */
+ commitSha: string | null
+}
+
+/** Ask the registry for the identity it admitted under one name; null when it holds none. */
+export type FetchAdmittedSkillIdentity = (skill: {
+ owner: string
+ repository: string
+ name: string
+}) => Promise
+
+/**
+ * The source a public request resolves: the registry's admitted identity for
+ * a name it holds, and the request itself otherwise.
+ *
+ * The registry names one Skill per name, and the Skill page shows its folder
+ * and its source commit. A run of that name resolves exactly that folder at
+ * exactly that commit, so nothing in the Repository can disagree with it:
+ * copies in Agent folders, a frontmatter name, or the size of the tree.
+ *
+ * A reference the request names wins over the admitted commit, because the
+ * person asked for it. A name the registry does not hold, such as a private
+ * Skill, keeps the tree search by name.
+ */
+export async function admittedSourceRequest(
+ source: SourceRequest,
+ lookup: FetchAdmittedSkillIdentity,
+): Promise {
+ if (source.selector.type !== 'named-skill')
+ return source
+ const identity = await lookup({
+ owner: source.owner,
+ repository: source.repository,
+ name: source.selector.name,
+ })
+ if (!identity)
+ return source
+ const ref = source.ref ?? (identity.commitSha ? { type: 'commit' as const, value: identity.commitSha } : undefined)
+ return {
+ provider: source.provider,
+ owner: source.owner,
+ repository: source.repository,
+ selector: { type: 'path', path: identity.skillPath },
+ ...(ref ? { ref } : {}),
+ }
+}
diff --git a/layers/artifact-delivery/server/utils/checks.ts b/layers/artifact-delivery/server/utils/checks.ts
index d3e40f08..37181be1 100644
--- a/layers/artifact-delivery/server/utils/checks.ts
+++ b/layers/artifact-delivery/server/utils/checks.ts
@@ -2,14 +2,21 @@ import type { ArtifactFile, CheckResult, ResolvedSource } from '../schemas/contr
import type { ArtifactSourceFile } from './github-source'
import { digestHex } from './encoding'
-const AGENT_SKILLS_CHECK_VERSION = '2026-08-20'
+/**
+ * 2026-10-07: the check became advisory. It still reports every finding, but
+ * no finding blocks delivery. The registry admits a Skill by its folder, so a
+ * frontmatter name, a long description or a missing field never changes which
+ * Skill runs. Safety checks stay required.
+ */
+const AGENT_SKILLS_CHECK_VERSION = '2026-10-07'
+const MAX_DESCRIPTION_CHARACTERS = 1024
const PATH_POLICY_VERSION = '1'
const CREDENTIAL_MATERIAL_VERSION = '1'
const EXECUTABLE_FILES_VERSION = '1'
const CURRENT_ARTIFACT_CHECKS = new Map([
['path-policy', { version: PATH_POLICY_VERSION, required: true }],
- ['agent-skills-spec', { version: AGENT_SKILLS_CHECK_VERSION, required: true }],
+ ['agent-skills-spec', { version: AGENT_SKILLS_CHECK_VERSION, required: false }],
['credential-material', { version: CREDENTIAL_MATERIAL_VERSION, required: true }],
['executable-files', { version: EXECUTABLE_FILES_VERSION, required: false }],
])
@@ -68,12 +75,16 @@ export async function checkArtifactSource(
const directoryName = source.skillPath === '.'
? source.repository
: source.skillPath.split('/').at(-1)!
- if (!frontmatter.name || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(frontmatter.name) || frontmatter.name.length > 64)
- specFindings.push('The Skill name must use 1 to 64 lowercase letters, numbers, or single hyphens.')
+ if (!frontmatter.name)
+ specFindings.push('The frontmatter has no name.')
+ else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(frontmatter.name) || frontmatter.name.length > 64)
+ specFindings.push(`The frontmatter name \`${frontmatter.name.slice(0, 100)}\` must use 1 to 64 lowercase letters, numbers, or single hyphens.`)
else if (frontmatter.name !== directoryName)
- specFindings.push('The Skill name must match its directory name.')
- if (!frontmatter.description || frontmatter.description.length > 1024)
- specFindings.push('The Skill description must contain 1 to 1024 characters.')
+ specFindings.push(`The frontmatter name \`${frontmatter.name}\` does not match the folder \`${directoryName}\`.`)
+ if (!frontmatter.description)
+ specFindings.push('The frontmatter has no description.')
+ else if (frontmatter.description.length > MAX_DESCRIPTION_CHARACTERS)
+ specFindings.push(`The description has ${frontmatter.description.length.toLocaleString('en-US')} characters. The limit is ${MAX_DESCRIPTION_CHARACTERS.toLocaleString('en-US')}.`)
}
}
}
@@ -81,8 +92,8 @@ export async function checkArtifactSource(
? {
name: 'agent-skills-spec',
version: AGENT_SKILLS_CHECK_VERSION,
- outcome: 'fail',
- required: true,
+ outcome: 'warn',
+ required: false,
summary: 'The Skill does not match the Agent Skills specification.',
findings: specFindings,
}
@@ -90,7 +101,7 @@ export async function checkArtifactSource(
name: 'agent-skills-spec',
version: AGENT_SKILLS_CHECK_VERSION,
outcome: 'pass',
- required: true,
+ required: false,
}
const credentialFindings: string[] = []
@@ -190,11 +201,14 @@ function readSkillFrontmatter(raw: string): { name?: string, description?: strin
if (key !== 'name' && key !== 'description')
continue
const value = line.slice(separator + 1).trim()
- if (value === '>' || value === '|') {
- const block: string[] = []
+ // A block scalar (`>`, `|-`, `>+2`) or a plain scalar that continues on
+ // indented lines. Reading `>-` as the value reported a two-character
+ // description for Skills whose description is over a thousand.
+ if (/^[>|][+-]?\d*$/.test(value) || value === '') {
+ const continued: string[] = []
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1]!))
- block.push(lines[++index]!.trim())
- values[key] = value === '>' ? block.join(' ').trim() : block.join('\n').trim()
+ continued.push(lines[++index]!.trim())
+ values[key] = continued.join(value.startsWith('|') ? '\n' : ' ').trim()
}
else {
values[key] = value.replace(/^(['"])([\s\S]*)\1$/, '$2').trim()
diff --git a/layers/artifact-delivery/server/utils/github-source.ts b/layers/artifact-delivery/server/utils/github-source.ts
index b6edb147..6909dcad 100644
--- a/layers/artifact-delivery/server/utils/github-source.ts
+++ b/layers/artifact-delivery/server/utils/github-source.ts
@@ -2,7 +2,7 @@ import type { ProblemCode, ResolvedSource, SourceRequest } from '../schemas/cont
import type { GithubReadTry } from './github-read'
import type { TarballExtraction } from './tarball-source'
import { z } from 'zod'
-import { isRegistrySkillPath } from '#shared/skill-path'
+import { canonicalSkillFolder, isRegistrySkillPath } from '#shared/skill-path'
import { base64ToBytes, gitBlobShaHex } from './encoding'
import { fetchNoRedirect } from './fetch-no-redirect'
import {
@@ -316,7 +316,7 @@ export function createGithubSourceClient(options: GithubClientOptions): PublicGi
return readRejection(recursive)
if (!recursive.value.truncated) {
if (recursive.value.tree.length > MAX_TREE_ENTRIES)
- return sourceLimitRejection(`The Skill has more than ${MAX_TREE_ENTRIES} source entries.`)
+ return sourceLimitRejection(`The Skill folder has more than ${MAX_TREE_ENTRIES} entries.`)
return { _tag: 'listed', entries: recursive.value.tree, truncated: false }
}
@@ -325,7 +325,7 @@ export function createGithubSourceClient(options: GithubClientOptions): PublicGi
let requests = 0
while (queue.length > 0) {
if (++requests > MAX_TREE_REQUESTS)
- return sourceLimitRejection(`The Skill needs more than ${MAX_TREE_REQUESTS} tree reads.`)
+ return sourceLimitRejection(`The Skill folder needs more than ${MAX_TREE_REQUESTS} tree reads.`)
const current = queue.shift()!
const response = await getTree(owner, repository, current.sha, false)
if (response._tag !== 'ok')
@@ -337,7 +337,7 @@ export function createGithubSourceClient(options: GithubClientOptions): PublicGi
const nested = { ...entry, path }
entries.push(nested)
if (entries.length > MAX_TREE_ENTRIES)
- return sourceLimitRejection(`The Skill has more than ${MAX_TREE_ENTRIES} source entries.`)
+ return sourceLimitRejection(`The Skill folder has more than ${MAX_TREE_ENTRIES} entries.`)
if (entry.type === 'tree')
queue.push({ sha: entry.sha, prefix: path })
}
@@ -368,24 +368,42 @@ export function createGithubSourceClient(options: GithubClientOptions): PublicGi
return currentSha
}
+ /**
+ * The folder of the Skill a name selects, searched across the whole
+ * Repository.
+ *
+ * The search reads one recursive tree and loads nothing, so the Skill
+ * limits do not apply to it. It used to read the tree through
+ * `listTreeBounded`, whose 2,000 entry ceiling is a limit for one Skill:
+ * every Repository larger than that failed with INVALID_SOURCE, whatever
+ * the Skill size. Measured 2026-10-07: github/awesome-copilot (3,958
+ * entries), garrytan/gstack (3,398), heygen-com/hyperframes (10,342).
+ *
+ * Copies of one Skill in several folders resolve to the canonical copy.
+ * They used to fail as "More than one Skill matched".
+ */
const resolveNamedSkill = async (
owner: string,
repository: string,
treeSha: string,
name: string,
): Promise => {
- const tree = await listTreeBounded(owner, repository, treeSha)
- if (tree._tag !== 'listed')
- return tree
- const matches = tree.entries
+ const tree = await getTree(owner, repository, treeSha, true)
+ if (tree._tag !== 'ok')
+ return readRejection(tree)
+ if (tree.value.truncated) {
+ return reject(
+ 'INVALID_SOURCE',
+ 'The Repository is too large to find a Skill by name. Name the Skill by its path.',
+ [name],
+ )
+ }
+ const matches = tree.value.tree
.filter(entry => entry.type === 'blob' && isRegistrySkillPath(entry.path))
.map(entry => entry.path === 'SKILL.md' ? '.' : entry.path.slice(0, -'/SKILL.md'.length))
.filter(path => (path === '.' ? repository : path.split('/').at(-1)) === name)
- if (matches.length === 0)
- return reject('SOURCE_NOT_FOUND', 'No Skill matched the requested name.', [name])
- if (matches.length > 1)
- return reject('INVALID_SOURCE', 'More than one Skill matched the requested name.', matches.slice(0, 100))
- return matches[0]!
+ return canonicalSkillFolder(matches)
+ ?? reject('SOURCE_NOT_FOUND', 'No Skill matched the requested name.', [name])
}
return {
@@ -500,7 +518,7 @@ export function createGithubSourceClient(options: GithubClientOptions): PublicGi
const listed = await listTreeBounded(source.owner, source.repository, skillTree)
if (listed._tag !== 'listed')
return listed
- const selected = selectArtifactEntries(listed.entries)
+ const selected = selectArtifactEntries(listed.entries, source.skillPath)
if (selected._tag === 'rejected')
return selected
@@ -664,7 +682,14 @@ function normalizeRequestedSkillPath(input: string): string | null {
return path
}
-function selectArtifactEntries(entries: TreeEntry[]): { _tag: 'selected', entries: Array } | SourceRejection {
+/**
+ * The blobs one Skill folder packs, or the rule that refuses them.
+ *
+ * Every limit counts the Skill folder only. A Skill at the Repository root has
+ * the whole Repository as its folder, so each size message says so: a README
+ * image then counts against the Skill.
+ */
+function selectArtifactEntries(entries: TreeEntry[], skillPath: string): { _tag: 'selected', entries: Array } | SourceRejection {
const findings: string[] = []
const identities = new Map()
const blobs: Array = []
@@ -695,22 +720,50 @@ function selectArtifactEntries(entries: TreeEntry[]): { _tag: 'selected', entrie
return reject('INVALID_SOURCE', 'The Skill source layout was rejected.', findings.slice(0, 100))
if (!blobs.some(entry => entry.path === 'SKILL.md'))
return reject('INVALID_SOURCE', 'The Skill directory has no SKILL.md file.', ['SKILL.md'])
+ const rootNote = skillPath === '.'
+ ? ' The Skill folder is the Repository root, so every file in the Repository counts.'
+ : ''
if (blobs.length > MAX_ARTIFACT_FILES)
- return sourceLimitRejection(`The Skill has more than ${MAX_ARTIFACT_FILES} files.`)
+ return sourceLimitRejection(`${skillFolderLabel(skillPath)} has ${blobs.length} files. The limit is ${MAX_ARTIFACT_FILES}.`)
const oversized = blobs.filter(entry => entry.size > MAX_FILE_BYTES)
- if (oversized.length > 0)
- return sourceLimitRejection(`A Skill file exceeds ${MAX_FILE_BYTES} bytes.`, oversized.map(entry => entry.path))
+ if (oversized.length > 0) {
+ return sourceLimitRejection(
+ `The file \`${oversized[0]!.path}\` is ${mebibytes(oversized[0]!.size)}. The limit for one file is ${mebibytes(MAX_FILE_BYTES)}.${rootNote}`,
+ oversized.map(entry => `${entry.path}: ${entry.size.toLocaleString('en-US')} bytes`),
+ )
+ }
// The signer checks `content_bytes` against this same ceiling, and
// `content_bytes` is the packed archive rather than the sum of the blobs. A
// Skill that cleared a source-total check could therefore be refused after
// the R2 write, with the signer's error instead of a named rejection. Guard
// the number the signer will actually see.
const projectedBytes = projectedUstarBytes(blobs.map(entry => entry.size))
- if (projectedBytes > MAX_ARTIFACT_BYTES)
- return sourceLimitRejection(`The packaged Skill would exceed ${MAX_ARTIFACT_BYTES} bytes.`)
+ if (projectedBytes > MAX_ARTIFACT_BYTES) {
+ return sourceLimitRejection(
+ `${skillFolderLabel(skillPath)} packs to ${mebibytes(projectedBytes)}. The limit is ${mebibytes(MAX_ARTIFACT_BYTES)}.${rootNote}`,
+ largestFirst(blobs).map(entry => `${entry.path}: ${entry.size.toLocaleString('en-US')} bytes`),
+ )
+ }
return { _tag: 'selected', entries: blobs.sort((a, b) => comparePath(a.path, b.path)) }
}
+function skillFolderLabel(skillPath: string): string {
+ return skillPath === '.' ? 'The Skill folder' : `The Skill folder \`${skillPath}\``
+}
+
+/**
+ * Bytes as mebibytes, rounded up to two decimals so a size over a limit never
+ * prints equal to it. A whole number prints without decimals.
+ */
+function mebibytes(bytes: number): string {
+ const value = Math.ceil(bytes / 1024 / 1024 * 100) / 100
+ return `${Number.isInteger(value) ? value : value.toFixed(2)} MiB`
+}
+
+function largestFirst(entries: Array): Array {
+ return [...entries].sort((a, b) => b.size - a.size || comparePath(a.path, b.path))
+}
+
function isSafeArtifactPath(path: string): boolean {
if (!path || path.length > 1024 || path.startsWith('/') || path.endsWith('/') || path.includes('\\') || path.includes('\0'))
return false
diff --git a/layers/artifact-delivery/server/utils/request-resolution.ts b/layers/artifact-delivery/server/utils/request-resolution.ts
new file mode 100644
index 00000000..d9556bb6
--- /dev/null
+++ b/layers/artifact-delivery/server/utils/request-resolution.ts
@@ -0,0 +1,62 @@
+import type { H3EventContext } from 'h3'
+import type { SourceRequest } from '../schemas/contracts'
+import type { FetchAdmittedSkillIdentity } from './admitted-identity'
+import type { CreateResolutionResult } from './state'
+import { admittedSourceRequest } from './admitted-identity'
+import { createResolution, resolutionRequestIdentity } from './state'
+
+export interface ResolutionRequestDependencies {
+ db: D1Database
+ /** Ask the registry for the identity it admitted under a public name. */
+ lookupAdmitted: FetchAdmittedSkillIdentity
+ enqueue: (resolutionId: string) => Promise
+ now: () => number
+}
+
+export type ResolutionAccess
+ = | { visibility: 'public' }
+ | {
+ visibility: 'private'
+ accountId: number
+ installationId: number
+ repositoryId: number
+ }
+
+/**
+ * {@link FetchAdmittedSkillIdentity} wired to the registry layer over HTTP,
+ * the only way one layer reads another (ADR-0001). A request passes its event
+ * context so the internal call reuses its platform; a scheduled task passes
+ * none, and the platform plugin resolves the Worker env itself.
+ */
+export function fetchAdmittedSkillIdentity(context?: H3EventContext): FetchAdmittedSkillIdentity {
+ return async ({ owner, repository, name }) => {
+ // A named object passes `context`, which NitroFetchOptions does not declare.
+ const options = { query: { owner, repo: repository, name }, ...(context ? { context } : {}) }
+ const answer = await $fetch<{ skillPath: string | null, commitSha: string | null }>('/api/skills/run-identity', options)
+ return answer.skillPath ? { skillPath: answer.skillPath, commitSha: answer.commitSha } : null
+ }
+}
+
+/**
+ * Create or replay one Resolution and queue its build.
+ *
+ * `POST /api/v1/resolutions` and the run sweep both call this, so the sweep
+ * checks exactly what `skilld run` gets. The fingerprint names the request as
+ * the client sent it, so a replay with the same Idempotency-Key still matches
+ * after the registry moves. A public name the registry admitted resolves its
+ * admitted folder and commit; a private request never asks the registry.
+ */
+export async function requestResolution(
+ dependencies: ResolutionRequestDependencies,
+ input: { source: SourceRequest, idempotencyKey: string, access: ResolutionAccess },
+): Promise {
+ const accountId = input.access.visibility === 'private' ? input.access.accountId : undefined
+ const identity = await resolutionRequestIdentity(input.source, input.idempotencyKey, accountId)
+ const source = input.access.visibility === 'private'
+ ? input.source
+ : await admittedSourceRequest(input.source, dependencies.lookupAdmitted)
+ const result = await createResolution(dependencies.db, source, identity, dependencies.now(), input.access)
+ if (result._tag !== 'idempotency-conflict' && result.row.state === 'requested')
+ await dependencies.enqueue(result.row.id)
+ return result
+}
diff --git a/layers/artifact-delivery/server/utils/run-sweep.ts b/layers/artifact-delivery/server/utils/run-sweep.ts
new file mode 100644
index 00000000..79a7815d
--- /dev/null
+++ b/layers/artifact-delivery/server/utils/run-sweep.ts
@@ -0,0 +1,260 @@
+import type { SourceRequest } from '../schemas/contracts'
+import type { ResolutionAccess } from './request-resolution'
+import type { CreateResolutionResult, ResolutionRow } from './state'
+import { z } from 'zod'
+import { ACTIVE_BUILD_STATES, getResolution, parseCheckResults } from './state'
+
+/** One Skill whose page shows `skilld run OWNER/REPOSITORY/NAME`. */
+export interface RunnableSkill {
+ owner: string
+ repository: string
+ name: string
+}
+
+export interface SkillRunFailure extends RunnableSkill {
+ /** What the CLI prints first: a problem code, or `CHECK_BLOCKED:`. */
+ tag: string
+ detail: string | null
+}
+
+export interface SkillRunSweepDependencies {
+ db: D1Database
+ /** The Skills the registry shows a run command for. */
+ listRunnableSkills: () => Promise
+ /** The request `POST /api/v1/resolutions` makes, with no HTTP around it. */
+ requestResolution: (input: {
+ source: SourceRequest
+ idempotencyKey: string
+ access: ResolutionAccess
+ }) => Promise
+ newIdempotencyKey: () => string
+ now: () => number
+ /** How many new checks one run starts. */
+ batchSize: number
+}
+
+export interface SkillRunSweepReport {
+ started: number
+ settled: number
+ /** Skills that failed for a reason a retry cannot change, and did not before. */
+ newFailures: SkillRunFailure[]
+ /** Failures a later run can clear, such as a spent GitHub quota. Recorded, never alarmed. */
+ transientFailures: SkillRunFailure[]
+ recovered: RunnableSkill[]
+ /** Runnable Skills whose last check failed for a reason a retry cannot change. */
+ failing: number
+}
+
+/**
+ * A check that stays pending this long counts as failed. The CLI gives up after
+ * 60 seconds, so a person would have seen a timeout long before.
+ */
+const PENDING_TIMEOUT_SECONDS = 30 * 60
+
+const checkRowSchema = z.object({
+ owner: z.string(),
+ repository: z.string(),
+ name: z.string(),
+ outcome: z.enum(['ready', 'failing']).nullable(),
+ tag: z.string().nullable(),
+ retryable: z.union([z.literal(0), z.literal(1)]),
+ failing_since: z.number().int().nullable(),
+ settled_at: z.number().int().nullable(),
+ pending_resolution_id: z.string().nullable(),
+ requested_at: z.number().int().nullable(),
+})
+
+type CheckRow = z.infer
+
+type Settled
+ = | { _tag: 'pending' }
+ | { _tag: 'ready' }
+ | { _tag: 'failing', tag: string, detail: string | null, retryable: boolean }
+
+/**
+ * One run of the skilld run sweep.
+ *
+ * It settles the checks the last run started, then starts new ones for the
+ * Skills checked longest ago. Each check is the Resolution request the CLI
+ * sends for `skilld run OWNER/REPOSITORY/NAME`: a named Skill, no reference,
+ * and the same build queue. A build takes longer than one Worker invocation
+ * allows to wait, so a check settles on the next run.
+ *
+ * A new failure is one a retry cannot change, on a Skill whose last check did
+ * not fail with the same tag. The caller alarms on those.
+ */
+export async function runSkillRunSweep(dependencies: SkillRunSweepDependencies): Promise {
+ const report: SkillRunSweepReport = {
+ started: 0,
+ settled: 0,
+ newFailures: [],
+ transientFailures: [],
+ recovered: [],
+ failing: 0,
+ }
+ const rows = await loadCheckRows(dependencies.db)
+ const now = dependencies.now()
+
+ for (const row of rows.values()) {
+ if (!row.pending_resolution_id)
+ continue
+ const resolution = await getResolution(dependencies.db, row.pending_resolution_id)
+ const settled = settleResolution(resolution, now - (row.requested_at ?? now))
+ if (settled._tag === 'pending')
+ continue
+ report.settled++
+ const skill = { owner: row.owner, repository: row.repository, name: row.name }
+ if (settled._tag === 'ready') {
+ if (row.outcome === 'failing' && row.retryable === 0)
+ report.recovered.push(skill)
+ await writeSettled(dependencies.db, row, { outcome: 'ready', tag: null, detail: null, retryable: false, failingSince: null }, now)
+ rows.set(skillKey(skill), { ...row, outcome: 'ready', tag: null, retryable: 0, pending_resolution_id: null, settled_at: now })
+ continue
+ }
+ const failure = { ...skill, tag: settled.tag, detail: settled.detail }
+ const sameFailure = row.outcome === 'failing' && row.tag === settled.tag
+ if (settled.retryable)
+ report.transientFailures.push(failure)
+ else if (!sameFailure)
+ report.newFailures.push(failure)
+ const failingSince = sameFailure ? row.failing_since ?? now : now
+ await writeSettled(dependencies.db, row, {
+ outcome: 'failing',
+ tag: settled.tag,
+ detail: settled.detail,
+ retryable: settled.retryable,
+ failingSince,
+ }, now)
+ rows.set(skillKey(skill), {
+ ...row,
+ outcome: 'failing',
+ tag: settled.tag,
+ retryable: settled.retryable ? 1 : 0,
+ failing_since: failingSince,
+ pending_resolution_id: null,
+ settled_at: now,
+ })
+ }
+
+ const runnable = await dependencies.listRunnableSkills()
+ report.failing = runnable.filter((skill) => {
+ const row = rows.get(skillKey(skill))
+ return row?.outcome === 'failing' && row.retryable === 0
+ }).length
+
+ const due = runnable
+ .filter(skill => !rows.get(skillKey(skill))?.pending_resolution_id)
+ .sort((left, right) =>
+ (rows.get(skillKey(left))?.settled_at ?? 0) - (rows.get(skillKey(right))?.settled_at ?? 0)
+ || compareText(skillKey(left), skillKey(right)))
+ .slice(0, Math.max(0, dependencies.batchSize))
+ for (const skill of due) {
+ const result = await dependencies.requestResolution({
+ source: {
+ provider: 'github',
+ owner: skill.owner,
+ repository: skill.repository,
+ selector: { type: 'named-skill', name: skill.name },
+ },
+ idempotencyKey: dependencies.newIdempotencyKey(),
+ access: { visibility: 'public' },
+ })
+ if (result._tag === 'idempotency-conflict')
+ throw new Error('A fresh sweep Idempotency-Key named another request')
+ await writePending(dependencies.db, skill, result.row.id, now)
+ report.started++
+ }
+ return report
+}
+
+/**
+ * The outcome `skilld run` would print for one Resolution.
+ *
+ * A blocked Resolution names its first failing required check, like the CLI.
+ */
+export function settleResolution(resolution: ResolutionRow | null, pendingSeconds: number): Settled {
+ if (!resolution)
+ return { _tag: 'failing', tag: 'RESOLUTION_MISSING', detail: null, retryable: true }
+ if ((ACTIVE_BUILD_STATES as readonly string[]).includes(resolution.state)) {
+ return pendingSeconds >= PENDING_TIMEOUT_SECONDS
+ ? { _tag: 'failing', tag: 'RESOLUTION_TIMEOUT', detail: `Pending at ${resolution.state}.`, retryable: true }
+ : { _tag: 'pending' }
+ }
+ if (resolution.state === 'ready')
+ return { _tag: 'ready' }
+ if (resolution.state === 'blocked') {
+ const failed = parseCheckResults(resolution.check_results_json)
+ .find(check => check.required && (check.outcome === 'fail' || check.outcome === 'error'))
+ return {
+ _tag: 'failing',
+ tag: `CHECK_BLOCKED:${failed?.name ?? 'unknown'}`,
+ detail: [failed?.summary, failed?.findings?.[0]].filter(Boolean).join(' ').slice(0, 500) || null,
+ retryable: false,
+ }
+ }
+ if (resolution.state === 'revoked')
+ return { _tag: 'failing', tag: 'ARTIFACT_REVOKED', detail: null, retryable: false }
+ return {
+ _tag: 'failing',
+ tag: resolution.error_code ?? 'SERVICE_UNAVAILABLE',
+ detail: null,
+ retryable: resolution.error_retryable === 1,
+ }
+}
+
+async function loadCheckRows(db: D1Database): Promise