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
3 changes: 2 additions & 1 deletion docs/arch/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand All @@ -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` |
Expand Down
27 changes: 11 additions & 16 deletions layers/artifact-delivery/server/api/v1/resolutions/index.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,22 +33,23 @@ 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,
installationId: privateAccess.installationId,
repositoryId: privateAccess.repositoryId,
}
: { visibility: 'public' },
)
})
if (result._tag === 'idempotency-conflict') {
throw createError({
statusCode: 409,
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions layers/artifact-delivery/server/schemas/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
108 changes: 108 additions & 0 deletions layers/artifact-delivery/server/tasks/sweep-skill-runs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/// <reference types="@cloudflare/workers-types" />

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<Cloudflare.Env>(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}`
}
51 changes: 51 additions & 0 deletions layers/artifact-delivery/server/utils/admitted-identity.ts
Original file line number Diff line number Diff line change
@@ -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<AdmittedSkillIdentity | null>

/**
* 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<SourceRequest> {
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 } : {}),
}
}
42 changes: 28 additions & 14 deletions layers/artifact-delivery/server/utils/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
])
Expand Down Expand Up @@ -68,29 +75,33 @@ 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')}.`)
}
}
}
const agentSkillsSpec: CheckResult = specFindings.length > 0
? {
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,
}
: {
name: 'agent-skills-spec',
version: AGENT_SKILLS_CHECK_VERSION,
outcome: 'pass',
required: true,
required: false,
}

const credentialFindings: string[] = []
Expand Down Expand Up @@ -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()
Expand Down
Loading