chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* CI check: enforces block-registry invariants that protect the runtime.
|
||||
*
|
||||
* Two checks run in sequence:
|
||||
*
|
||||
* 1. **Subblock ID stability** — diffs the current registry against a base ref
|
||||
* and fails if any subblock ID was removed without a corresponding entry in
|
||||
* `SUBBLOCK_ID_MIGRATIONS`. Removing IDs without a migration breaks
|
||||
* deployed workflows.
|
||||
*
|
||||
* 2. **Canonical-id contract** — for every (block, tool) pair where the tool
|
||||
* param is `required: true` and `visibility: 'user-only'`, the block must
|
||||
* expose a subBlock whose `id` or `canonicalParamId` equals the tool param
|
||||
* id. The serializer's pre-execution validator depends on this contract to
|
||||
* resolve values via direct lookup; mismatches false-flag fields as missing
|
||||
* at submit time.
|
||||
*
|
||||
* Usage:
|
||||
* bun run apps/sim/scripts/check-block-registry.ts [base-ref]
|
||||
*
|
||||
* `base-ref` defaults to `HEAD~1`. In a PR CI pipeline, pass the merge base:
|
||||
* bun run apps/sim/scripts/check-block-registry.ts origin/main
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process'
|
||||
import { SUBBLOCK_ID_MIGRATIONS } from '@/lib/workflows/migrations/subblock-migrations'
|
||||
import { getAllBlocks, getBlockMeta } from '@/blocks/registry'
|
||||
import { tools as toolRegistry } from '@/tools/registry'
|
||||
|
||||
const baseRef = process.argv[2] || 'HEAD~1'
|
||||
|
||||
const gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim()
|
||||
const gitOpts = { encoding: 'utf-8' as const, cwd: gitRoot }
|
||||
|
||||
type IdMap = Record<string, Set<string>>
|
||||
|
||||
/**
|
||||
* Extracts subblock IDs from the `subBlocks: [ ... ]` section of a block
|
||||
* definition. Only grabs the top-level `id:` of each subblock object —
|
||||
* ignores nested IDs inside `options`, `columns`, etc.
|
||||
*/
|
||||
function extractSubBlockIds(source: string): string[] {
|
||||
const startIdx = source.indexOf('subBlocks:')
|
||||
if (startIdx === -1) return []
|
||||
|
||||
const bracketStart = source.indexOf('[', startIdx)
|
||||
if (bracketStart === -1) return []
|
||||
|
||||
const ids: string[] = []
|
||||
let braceDepth = 0
|
||||
let bracketDepth = 0
|
||||
let i = bracketStart + 1
|
||||
bracketDepth = 1
|
||||
|
||||
while (i < source.length && bracketDepth > 0) {
|
||||
const ch = source[i]
|
||||
|
||||
if (ch === '[') bracketDepth++
|
||||
else if (ch === ']') {
|
||||
bracketDepth--
|
||||
if (bracketDepth === 0) break
|
||||
} else if (ch === '{') {
|
||||
braceDepth++
|
||||
if (braceDepth === 1) {
|
||||
const ahead = source.slice(i, i + 200)
|
||||
const idMatch = ahead.match(/{\s*(?:\/\/[^\n]*\n\s*)*id:\s*['"]([^'"]+)['"]/)
|
||||
if (idMatch) {
|
||||
ids.push(idMatch[1])
|
||||
}
|
||||
}
|
||||
} else if (ch === '}') {
|
||||
braceDepth--
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
function getCurrentIds(): IdMap {
|
||||
const map: IdMap = {}
|
||||
for (const block of getAllBlocks()) {
|
||||
map[block.type] = new Set(block.subBlocks.map((sb) => sb.id))
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
type PreviousIdsResult =
|
||||
| { kind: 'skip'; reason: string }
|
||||
| { kind: 'noop' }
|
||||
| { kind: 'ok'; map: IdMap }
|
||||
|
||||
function getPreviousIds(): PreviousIdsResult {
|
||||
const registryPath = 'apps/sim/blocks/registry.ts'
|
||||
const blocksDir = 'apps/sim/blocks/blocks'
|
||||
|
||||
let hasChanges = false
|
||||
try {
|
||||
const diff = execSync(
|
||||
`git diff --name-only ${baseRef} HEAD -- ${registryPath} ${blocksDir}`,
|
||||
gitOpts
|
||||
).trim()
|
||||
hasChanges = diff.length > 0
|
||||
} catch {
|
||||
return { kind: 'skip', reason: 'Could not diff against base ref' }
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
return { kind: 'noop' }
|
||||
}
|
||||
|
||||
const map: IdMap = {}
|
||||
|
||||
try {
|
||||
const blockFiles = execSync(`git ls-tree -r --name-only ${baseRef} -- ${blocksDir}`, gitOpts)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
|
||||
|
||||
for (const filePath of blockFiles) {
|
||||
let content: string
|
||||
try {
|
||||
content = execSync(`git show ${baseRef}:${filePath}`, gitOpts)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const typeMatch = content.match(/BlockConfig\s*=\s*\{[\s\S]*?type:\s*['"]([^'"]+)['"]/)
|
||||
if (!typeMatch) continue
|
||||
const blockType = typeMatch[1]
|
||||
|
||||
const ids = extractSubBlockIds(content)
|
||||
if (ids.length === 0) continue
|
||||
|
||||
map[blockType] = new Set(ids)
|
||||
}
|
||||
} catch (err) {
|
||||
return { kind: 'skip', reason: `Could not read previous block files from ${baseRef}: ${err}` }
|
||||
}
|
||||
|
||||
return { kind: 'ok', map }
|
||||
}
|
||||
|
||||
type CheckResult =
|
||||
| { kind: 'pass'; message: string }
|
||||
| { kind: 'skip'; message: string }
|
||||
| { kind: 'fail'; errors: string[] }
|
||||
|
||||
function checkSubblockIdStability(): CheckResult {
|
||||
const previous = getPreviousIds()
|
||||
|
||||
if (previous.kind === 'skip') {
|
||||
return { kind: 'skip', message: `${previous.reason} — skipping subblock ID stability check` }
|
||||
}
|
||||
if (previous.kind === 'noop') {
|
||||
return {
|
||||
kind: 'skip',
|
||||
message: 'No block definition changes detected — skipping subblock ID stability check',
|
||||
}
|
||||
}
|
||||
|
||||
const current = getCurrentIds()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const [blockType, prevIds] of Object.entries(previous.map)) {
|
||||
const currIds = current[blockType]
|
||||
if (!currIds) continue
|
||||
|
||||
const migrations = SUBBLOCK_ID_MIGRATIONS[blockType] ?? {}
|
||||
|
||||
for (const oldId of prevIds) {
|
||||
if (currIds.has(oldId)) continue
|
||||
if (oldId in migrations) continue
|
||||
|
||||
errors.push(
|
||||
`Block "${blockType}": subblock ID "${oldId}" was removed.\n` +
|
||||
` → Add a migration in SUBBLOCK_ID_MIGRATIONS (lib/workflows/migrations/subblock-migrations.ts)\n` +
|
||||
` mapping "${oldId}" to its replacement ID.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
return { kind: 'pass', message: 'Subblock ID stability check passed' }
|
||||
}
|
||||
return { kind: 'fail', errors }
|
||||
}
|
||||
|
||||
function checkCanonicalIdContract(): CheckResult {
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of getAllBlocks()) {
|
||||
const access: string[] = block.tools?.access ?? []
|
||||
if (access.length === 0) continue
|
||||
|
||||
// A subBlock with `canonicalParamId` has its raw `id` deleted from `params` during
|
||||
// canonical-group resolution in `extractParams` (serializer/index.ts), so the raw id is
|
||||
// NOT a valid lookup key at execution time — only the canonical is. Tool params must
|
||||
// align with the canonical, not the raw id.
|
||||
const subBlockKeys = new Set<string>()
|
||||
for (const sb of block.subBlocks ?? []) {
|
||||
const canonical = (sb as { canonicalParamId?: string }).canonicalParamId
|
||||
if (canonical) {
|
||||
subBlockKeys.add(canonical)
|
||||
} else if (sb.id) {
|
||||
subBlockKeys.add(sb.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const toolId of access) {
|
||||
const tool = toolRegistry[toolId]
|
||||
if (!tool) continue
|
||||
|
||||
for (const [paramId, paramConfig] of Object.entries(tool.params ?? {})) {
|
||||
if (!paramConfig || typeof paramConfig !== 'object') continue
|
||||
const required = (paramConfig as { required?: boolean }).required === true
|
||||
const userOnly = (paramConfig as { visibility?: string }).visibility === 'user-only'
|
||||
if (!required || !userOnly) continue
|
||||
|
||||
if (!subBlockKeys.has(paramId)) {
|
||||
errors.push(
|
||||
`Block "${block.type}" → tool "${toolId}": required user-only param "${paramId}" has no subBlock with id or canonicalParamId === "${paramId}".\n` +
|
||||
' → Rename a subBlock id or canonicalParamId to match the tool param id,\n' +
|
||||
" and update the block's inputs + tools.config.params mapper to read from that key."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
return { kind: 'pass', message: 'Canonical-id contract check passed' }
|
||||
}
|
||||
return { kind: 'fail', errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every catalog-visible integration block must expose a `BlockMeta` entry in
|
||||
* `BLOCK_META_REGISTRY`. The integration detail pages
|
||||
* (`getTemplatesForBlock`/`getSuggestedSkillsForBlock`) and the landing catalog
|
||||
* read metas by block type; a `tools`-category block that ships in the toolbar
|
||||
* without a meta renders an empty detail page (no templates, no skills).
|
||||
*
|
||||
* "Catalog-visible integration" mirrors the `isIntegrationBlock` predicate in
|
||||
* `scripts/generate-docs.ts`: `category === 'tools' && !hideFromToolbar`. Core
|
||||
* primitives (`agent`/`api`/`function`/…), hidden/legacy base versions, and
|
||||
* first-party `blocks`-category blocks intentionally carry no meta and are not
|
||||
* checked. `getBlockMeta` resolves through the version suffix, so a versioned
|
||||
* block (e.g. `github_v2`) passes via its base meta.
|
||||
*/
|
||||
function checkIntegrationMetaCoverage(): CheckResult {
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of getAllBlocks()) {
|
||||
// Unreleased preview blocks ship no BlockMeta until GA (they are absent
|
||||
// from every catalog surface), so meta coverage must not force one. The
|
||||
// registry projection already hides them here (no visibility context in a
|
||||
// script), but the explicit check keeps this true regardless.
|
||||
const isCatalogIntegration =
|
||||
block.category === 'tools' && !block.hideFromToolbar && !block.preview
|
||||
if (!isCatalogIntegration) continue
|
||||
|
||||
if (!getBlockMeta(block.type)) {
|
||||
errors.push(
|
||||
`Block "${block.type}" is a catalog integration (category: 'tools', not hidden) but has no BlockMeta.\n` +
|
||||
` → Export a \`${block.type}\`-keyed \`{Service}BlockMeta\` (tags + templates + skills) from its block file\n` +
|
||||
' and register it in BLOCK_META_REGISTRY (apps/sim/blocks/registry-maps.ts), alphabetically.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
return { kind: 'pass', message: 'Integration meta coverage check passed' }
|
||||
}
|
||||
return { kind: 'fail', errors }
|
||||
}
|
||||
|
||||
function reportResult(label: string, failureHeader: string, result: CheckResult): boolean {
|
||||
if (result.kind === 'pass') {
|
||||
console.log(`✓ ${result.message}`)
|
||||
return true
|
||||
}
|
||||
if (result.kind === 'skip') {
|
||||
console.log(`⚠ ${result.message}`)
|
||||
return true
|
||||
}
|
||||
console.error(`\n✗ ${label} FAILED\n`)
|
||||
if (failureHeader) console.error(`${failureHeader}\n`)
|
||||
for (const err of result.errors) {
|
||||
console.error(` ${err}\n`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const stabilityResult = checkSubblockIdStability()
|
||||
const canonicalResult = checkCanonicalIdContract()
|
||||
const metaCoverageResult = checkIntegrationMetaCoverage()
|
||||
|
||||
const stabilityOk = reportResult(
|
||||
'Subblock ID stability check',
|
||||
'Removing subblock IDs breaks deployed workflows.\nEither revert the rename or add a migration entry.',
|
||||
stabilityResult
|
||||
)
|
||||
|
||||
const canonicalOk = reportResult(
|
||||
'Canonical-id contract check',
|
||||
"Misaligned ids cause the serializer's pre-execution validator to false-flag fields as missing at submit time.",
|
||||
canonicalResult
|
||||
)
|
||||
|
||||
const metaCoverageOk = reportResult(
|
||||
'Integration meta coverage check',
|
||||
'Catalog integrations without a BlockMeta render empty detail pages (no templates, no skills).',
|
||||
metaCoverageResult
|
||||
)
|
||||
|
||||
process.exit(stabilityOk && canonicalOk && metaCoverageOk ? 0 : 1)
|
||||
Reference in New Issue
Block a user