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,188 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* One-shot, idempotent, resumable backfill that externalizes inline heavy
|
||||
* `execution_data` (traceSpans, finalOutput, workflowInput, ...) into the
|
||||
* execution-context large-value store, matching the completion path (cost-stripped
|
||||
* spans, trace pointer + markers, owner/dependency + execution_log reference
|
||||
* registration). Skips running rows and rows already carrying the pointer.
|
||||
*
|
||||
* Requires object storage to be configured; self-hosted deployments without it
|
||||
* keep `execution_data` inline (reads resolve inline transparently) and can skip
|
||||
* this script entirely.
|
||||
*
|
||||
* NOTE: the companion `cost_total` / `models_used` backfill is done in SQL by
|
||||
* migration 0220 (batched, idempotent), so it runs for everyone — including
|
||||
* self-hosted — and is intentionally NOT part of this script.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=... bun apps/sim/scripts/backfill-trace-spans.ts [--max-batches=<n>]
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, asc, eq, gt, sql } from 'drizzle-orm'
|
||||
import {
|
||||
collectLargeValueReferenceKeys,
|
||||
replaceLargeValueReferenceKeysWithClient,
|
||||
} from '@/lib/execution/payloads/large-value-metadata'
|
||||
import {
|
||||
externalizeExecutionData,
|
||||
stripSpanCosts,
|
||||
TRACE_STORE_REF_KEY,
|
||||
} from '@/lib/logs/execution/trace-store'
|
||||
|
||||
const TRACE_BATCH_SIZE = 100
|
||||
|
||||
/**
|
||||
* Recursively counts trace spans (matching the completion path). Legacy rows
|
||||
* predate the inline hasTraceSpans/traceSpanCount markers, so we derive them
|
||||
* before externalizing — otherwise a post-expiry degraded read can't report
|
||||
* "trace data expired (N spans)".
|
||||
*/
|
||||
function countTraceSpans(spans: unknown): number {
|
||||
if (!Array.isArray(spans)) return 0
|
||||
return spans.reduce(
|
||||
(count: number, span) =>
|
||||
count + 1 + countTraceSpans((span as { children?: unknown } | null)?.children),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
interface Options {
|
||||
maxBatches: number
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const maxBatchesArg = argv.find((a) => a.startsWith('--max-batches='))
|
||||
const maxBatches = maxBatchesArg
|
||||
? Number.parseInt(maxBatchesArg.slice('--max-batches='.length), 10)
|
||||
: Number.POSITIVE_INFINITY
|
||||
|
||||
if (Number.isNaN(maxBatches) || maxBatches <= 0) {
|
||||
throw new Error('--max-batches must be a positive integer')
|
||||
}
|
||||
|
||||
return { maxBatches }
|
||||
}
|
||||
|
||||
/** Externalize inline heavy execution_data into the large-value store. */
|
||||
async function backfillTraceStorage(
|
||||
maxBatches: number
|
||||
): Promise<{ migrated: number; failed: number }> {
|
||||
let migrated = 0
|
||||
let failed = 0
|
||||
// Keyset cursor by id: every row is visited at most once per run, so rows that
|
||||
// can't be externalized (storage error, oversized) aren't re-selected into an
|
||||
// infinite loop. A fresh re-run (cursor reset) retries any that failed.
|
||||
let lastId = ''
|
||||
|
||||
for (let batch = 0; batch < maxBatches; batch++) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workspaceId: workflowExecutionLogs.workspaceId,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.where(
|
||||
and(
|
||||
sql`${workflowExecutionLogs.endedAt} IS NOT NULL`,
|
||||
// Skip deleted-workflow rows: externalization requires a workflowId.
|
||||
sql`${workflowExecutionLogs.workflowId} IS NOT NULL`,
|
||||
sql`${workflowExecutionLogs.executionData} ? 'traceSpans'`,
|
||||
sql`NOT (${workflowExecutionLogs.executionData} ? ${TRACE_STORE_REF_KEY})`,
|
||||
lastId ? gt(workflowExecutionLogs.id, lastId) : undefined
|
||||
)
|
||||
)
|
||||
.orderBy(asc(workflowExecutionLogs.id))
|
||||
.limit(TRACE_BATCH_SIZE)
|
||||
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const executionData = (row.executionData ?? {}) as Record<string, unknown>
|
||||
// Derive the inline markers legacy rows lack so externalizeExecutionData
|
||||
// carries them onto the slim row (they survive object expiry).
|
||||
const traceSpanCount = countTraceSpans(executionData.traceSpans)
|
||||
executionData.hasTraceSpans = traceSpanCount > 0
|
||||
executionData.traceSpanCount = traceSpanCount
|
||||
stripSpanCosts(executionData.traceSpans)
|
||||
// workspace_files.user_id (NOT NULL) needs the execution owner; legacy
|
||||
// rows carry it under executionData.environment.userId. Rows without an
|
||||
// owner can't be externalized — count them as failed and skip.
|
||||
const environment = executionData.environment as { userId?: string } | undefined
|
||||
const ownerUserId = environment?.userId
|
||||
if (!ownerUserId) {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
const slim = await externalizeExecutionData(executionData, {
|
||||
workspaceId: row.workspaceId,
|
||||
workflowId: row.workflowId,
|
||||
executionId: row.executionId,
|
||||
userId: ownerUserId,
|
||||
})
|
||||
|
||||
if (!(TRACE_STORE_REF_KEY in slim)) {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(workflowExecutionLogs)
|
||||
.set({ executionData: slim })
|
||||
.where(eq(workflowExecutionLogs.id, row.id))
|
||||
|
||||
await replaceLargeValueReferenceKeysWithClient(
|
||||
tx,
|
||||
{
|
||||
workspaceId: row.workspaceId,
|
||||
workflowId: row.workflowId,
|
||||
executionId: row.executionId,
|
||||
source: 'execution_log',
|
||||
},
|
||||
collectLargeValueReferenceKeys(slim)
|
||||
)
|
||||
})
|
||||
|
||||
migrated++
|
||||
} catch (error) {
|
||||
failed++
|
||||
console.error(` [trace] row ${row.id} failed: ${toError(error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the cursor past this batch so failed rows aren't re-selected.
|
||||
lastId = rows[rows.length - 1].id
|
||||
|
||||
console.log(` [trace] batch ${batch + 1}: migrated ${migrated}, failed ${failed}`)
|
||||
}
|
||||
|
||||
return { migrated, failed }
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const startedAt = Date.now()
|
||||
|
||||
console.log('Backfilling trace storage (externalizing execution_data)…')
|
||||
const { migrated, failed } = await backfillTraceStorage(options.maxBatches)
|
||||
console.log(`Trace storage done: ${migrated} migrated, ${failed} skipped/failed.`)
|
||||
|
||||
console.log(`Backfill complete in ${((Date.now() - startedAt) / 1000).toFixed(1)}s.`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('Backfill failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Builds the E2B sandbox template that powers the Pi Coding Agent cloud mode.
|
||||
*
|
||||
* Layers the `pi` CLI plus git onto E2B's `code-interpreter` base (which already
|
||||
* ships node + python). The cloud backend runs `pi` and `git clone/commit/push`
|
||||
* inside this sandbox, so both must resolve on PATH — the global npm bin and
|
||||
* `/usr/bin` both are.
|
||||
*
|
||||
* Usage:
|
||||
* E2B_API_KEY=... bun run apps/sim/scripts/build-pi-e2b-template.ts [--name <name>] [--no-cache]
|
||||
*
|
||||
* After it builds, set the printed value in the Sim app's .env:
|
||||
* E2B_PI_TEMPLATE_ID=<name>
|
||||
* `Sandbox.create` resolves by template name, so use the name (not the ID).
|
||||
*/
|
||||
|
||||
import { defaultBuildLogger, Template } from '@e2b/code-interpreter'
|
||||
|
||||
const DEFAULT_TEMPLATE_NAME = 'sim-pi'
|
||||
|
||||
const piTemplate = Template()
|
||||
.fromTemplate('code-interpreter-v1')
|
||||
// git (+ ssh/certs) for clone/commit/push; ripgrep/fd give the agent fast
|
||||
// file search from its bash tool; gh enables richer GitHub workflows.
|
||||
.aptInstall(['git', 'gh', 'openssh-client', 'ca-certificates', 'ripgrep', 'fd-find'])
|
||||
// The `pi` CLI the cloud backend invokes.
|
||||
.npmInstall(['@earendil-works/pi-coding-agent'], { g: true })
|
||||
|
||||
async function main() {
|
||||
if (!process.env.E2B_API_KEY) {
|
||||
console.error('E2B_API_KEY is required')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const nameIdx = args.indexOf('--name')
|
||||
const templateName = nameIdx !== -1 ? args[nameIdx + 1] : DEFAULT_TEMPLATE_NAME
|
||||
const skipCache = args.includes('--no-cache')
|
||||
|
||||
console.log(`Building Pi E2B template: ${templateName}`)
|
||||
console.log(skipCache ? 'Cache: disabled\n' : 'Cache: enabled\n')
|
||||
|
||||
const result = await Template.build(piTemplate, templateName, {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
...(skipCache ? { skipCache: true } : {}),
|
||||
})
|
||||
|
||||
console.log(`\nDone. Template ID: ${result.templateId}`)
|
||||
console.log(`Set in .env: E2B_PI_TEMPLATE_ID=${templateName}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Build failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -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)
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* One-shot reconciliation: copy any messages present in
|
||||
* `copilot_chats.messages` JSONB but missing from `copilot_messages`.
|
||||
*
|
||||
* Idempotent via `ON CONFLICT (chat_id, message_id) DO NOTHING`. Safe to
|
||||
* re-run. Intended to be run manually before cutting reads over to the new
|
||||
* table (R+1 of the dual-write rollout), or after a known dual-write outage.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=... bun apps/sim/scripts/copilot-messages-reconcile.ts [--since=<interval>]
|
||||
*
|
||||
* Examples:
|
||||
* bun apps/sim/scripts/copilot-messages-reconcile.ts
|
||||
* bun apps/sim/scripts/copilot-messages-reconcile.ts --since='7 days'
|
||||
* bun apps/sim/scripts/copilot-messages-reconcile.ts --since='1 hour'
|
||||
*
|
||||
* Omit --since to reconcile the entire table.
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { db } from '../../../packages/db/db.js'
|
||||
|
||||
function parseSinceArg(argv: string[]): string | null {
|
||||
const arg = argv.find((a) => a.startsWith('--since='))
|
||||
if (!arg) return null
|
||||
const value = arg.slice('--since='.length).trim()
|
||||
if (!value) {
|
||||
throw new Error('--since requires a value, e.g. --since="7 days"')
|
||||
}
|
||||
if (!/^[\w\s]+$/.test(value)) {
|
||||
throw new Error(`--since value must be a simple interval like "7 days"; got: ${value}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const since = parseSinceArg(process.argv.slice(2))
|
||||
const windowClause = since
|
||||
? sql`AND c.updated_at > now() - ${sql.raw(`interval '${since}'`)}`
|
||||
: sql``
|
||||
|
||||
console.log(
|
||||
since
|
||||
? `Reconciling copilot_messages for chats updated in the last ${since}…`
|
||||
: 'Reconciling copilot_messages across the full copilot_chats table…'
|
||||
)
|
||||
const startedAt = Date.now()
|
||||
|
||||
const result = await db.execute(sql`
|
||||
INSERT INTO copilot_messages (chat_id, message_id, role, content, model, created_at, updated_at)
|
||||
SELECT
|
||||
c.id,
|
||||
msg.value->>'id',
|
||||
msg.value->>'role',
|
||||
msg.value,
|
||||
c.model,
|
||||
(msg.value->>'timestamp')::timestamptz,
|
||||
(msg.value->>'timestamp')::timestamptz
|
||||
FROM copilot_chats c
|
||||
CROSS JOIN LATERAL jsonb_array_elements(c.messages) AS msg(value)
|
||||
WHERE jsonb_typeof(c.messages) = 'array'
|
||||
AND jsonb_array_length(c.messages) > 0
|
||||
${windowClause}
|
||||
ON CONFLICT (chat_id, message_id) DO NOTHING
|
||||
`)
|
||||
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
const rowCount = (result as { rowCount?: number }).rowCount ?? 0
|
||||
console.log(`Inserted ${rowCount} new rows in ${(elapsedMs / 1000).toFixed(1)}s.`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('Reconciliation failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Export workflow JSON from database
|
||||
*
|
||||
* Usage:
|
||||
* bun apps/sim/scripts/export-workflow.ts <workflow-id>
|
||||
*
|
||||
* This script exports a workflow in the same format as the export API route.
|
||||
* It fetches the workflow state from normalized tables, combines it with metadata
|
||||
* and variables, sanitizes it, and outputs the JSON.
|
||||
*
|
||||
* Make sure DATABASE_URL or POSTGRES_URL is set in your environment.
|
||||
*/
|
||||
|
||||
// Suppress console logs from imported modules - only JSON should go to stdout
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
}
|
||||
console.log = () => {}
|
||||
console.warn = () => {}
|
||||
console.error = () => {}
|
||||
|
||||
import { writeFileSync } from 'fs'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../../../packages/db/db.js'
|
||||
import { workflow } from '../../../packages/db/schema.js'
|
||||
import { loadWorkflowFromNormalizedTables } from '../lib/workflows/persistence/utils.js'
|
||||
import { sanitizeForExport } from '../lib/workflows/sanitization/json-sanitizer.js'
|
||||
|
||||
// ---------- CLI argument parsing ----------
|
||||
const args = process.argv.slice(2)
|
||||
const workflowId = args[0]
|
||||
const outputFile = args[1] // Optional output filename
|
||||
|
||||
if (!workflowId) {
|
||||
process.stderr.write(
|
||||
'Usage: bun apps/sim/scripts/export-workflow.ts <workflow-id> [output-file]\n'
|
||||
)
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Examples:\n')
|
||||
process.stderr.write(' bun apps/sim/scripts/export-workflow.ts abc123\n')
|
||||
process.stderr.write(' bun apps/sim/scripts/export-workflow.ts abc123 workflow.json\n')
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Make sure DATABASE_URL or POSTGRES_URL is set in your environment.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ---------- Main export function ----------
|
||||
async function exportWorkflow(workflowId: string, outputFile?: string): Promise<void> {
|
||||
try {
|
||||
// Fetch workflow metadata
|
||||
const [workflowData] = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowData) {
|
||||
process.stderr.write(`Error: Workflow ${workflowId} not found\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Load workflow from normalized tables
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
if (!normalizedData) {
|
||||
process.stderr.write(`Error: Workflow ${workflowId} has no normalized data\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Get variables in Record format (as stored in database)
|
||||
type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
|
||||
const workflowVariables = workflowData.variables as
|
||||
| Record<string, { id: string; name: string; type: VariableType; value: unknown }>
|
||||
| undefined
|
||||
|
||||
// Prepare export state - match the exact format from the UI
|
||||
const workflowState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
metadata: {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description ?? undefined,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables: workflowVariables,
|
||||
}
|
||||
|
||||
// Sanitize and export - this returns { version, exportedAt, state }
|
||||
const exportState = sanitizeForExport(workflowState)
|
||||
const jsonString = JSON.stringify(exportState, null, 2)
|
||||
|
||||
// Write to file or stdout
|
||||
if (outputFile) {
|
||||
writeFileSync(outputFile, jsonString, 'utf-8')
|
||||
process.stderr.write(`Workflow exported to ${outputFile}\n`)
|
||||
} else {
|
||||
// Output the JSON to stdout only
|
||||
process.stdout.write(`${jsonString}\n`)
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error exporting workflow: ${error}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Execute ----------
|
||||
exportWorkflow(workflowId, outputFile)
|
||||
.then(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(`Unexpected error: ${error}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
# Workflow Load Tests
|
||||
|
||||
These local-only Artillery scenarios exercise `POST /api/workflows/[id]/execute` in async mode.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The app should be running locally, for example with `bun run dev:full`
|
||||
- Each scenario needs valid workflow IDs and API keys
|
||||
- All scenarios default to `http://localhost:3000`
|
||||
|
||||
The default rates are tuned for these local limits:
|
||||
|
||||
- `ADMISSION_GATE_MAX_INFLIGHT=500`
|
||||
|
||||
The admission gate caps total in-flight requests per pod.
|
||||
|
||||
## Baseline Concurrency
|
||||
|
||||
Use this to ramp traffic into one workflow and observe normal queueing behavior.
|
||||
|
||||
Default profile:
|
||||
|
||||
- Starts at `2` requests per second
|
||||
- Ramps to `8` requests per second
|
||||
- Holds there for `20` seconds
|
||||
- Good for validating queueing against a Free workspace concurrency of `5`
|
||||
|
||||
```bash
|
||||
WORKFLOW_ID=<workflow-id> \
|
||||
SIM_API_KEY=<api-key> \
|
||||
bun run load:workflow:baseline
|
||||
```
|
||||
|
||||
Optional variables:
|
||||
|
||||
- `BASE_URL`
|
||||
- `WARMUP_DURATION`
|
||||
- `WARMUP_RATE`
|
||||
- `PEAK_RATE`
|
||||
- `HOLD_DURATION`
|
||||
|
||||
For higher-plan workspaces, a good local starting point is:
|
||||
|
||||
- Pro: `PEAK_RATE=20` to `40`
|
||||
- Team or Enterprise: `PEAK_RATE=50` to `100`
|
||||
|
||||
## Queueing Waves
|
||||
|
||||
Use this to send repeated bursts to one workflow in the same workspace.
|
||||
|
||||
Default profile:
|
||||
|
||||
- Wave 1: `6` requests per second for `10` seconds
|
||||
- Wave 2: `8` requests per second for `15` seconds
|
||||
- Wave 3: `10` requests per second for `20` seconds
|
||||
- Quiet gaps: `5` seconds
|
||||
|
||||
```bash
|
||||
WORKFLOW_ID=<workflow-id> \
|
||||
SIM_API_KEY=<api-key> \
|
||||
bun run load:workflow:waves
|
||||
```
|
||||
|
||||
Optional variables:
|
||||
|
||||
- `BASE_URL`
|
||||
- `WAVE_ONE_DURATION`
|
||||
- `WAVE_ONE_RATE`
|
||||
- `QUIET_DURATION`
|
||||
- `WAVE_TWO_DURATION`
|
||||
- `WAVE_TWO_RATE`
|
||||
- `WAVE_THREE_DURATION`
|
||||
- `WAVE_THREE_RATE`
|
||||
|
||||
## Two-Workspace Isolation
|
||||
|
||||
Use this to send mixed traffic to two workflows from different workspaces and compare whether one workspace's queue pressure appears to affect the other.
|
||||
|
||||
Default profile:
|
||||
|
||||
- Total rate: `9` requests per second for `30` seconds
|
||||
- Weight split: `8:1`
|
||||
- In practice this sends heavy pressure to workspace A while still sending a light stream to workspace B
|
||||
|
||||
```bash
|
||||
WORKFLOW_ID_A=<workspace-a-workflow-id> \
|
||||
SIM_API_KEY_A=<workspace-a-api-key> \
|
||||
WORKFLOW_ID_B=<workspace-b-workflow-id> \
|
||||
SIM_API_KEY_B=<workspace-b-api-key> \
|
||||
bun run load:workflow:isolation
|
||||
```
|
||||
|
||||
Optional variables:
|
||||
|
||||
- `BASE_URL`
|
||||
- `ISOLATION_DURATION`
|
||||
- `TOTAL_RATE`
|
||||
- `WORKSPACE_A_WEIGHT`
|
||||
- `WORKSPACE_B_WEIGHT`
|
||||
|
||||
## Notes
|
||||
|
||||
- `load:workflow` is an alias for `load:workflow:baseline`
|
||||
- All scenarios send `x-execution-mode: async`
|
||||
- Artillery output will show request counts and response codes, which is usually enough for quick local verification
|
||||
- At these defaults, you should see `429` responses when approaching `ADMISSION_GATE_MAX_INFLIGHT=500`
|
||||
- If you still see lots of `429` or `ETIMEDOUT` responses locally, lower the rates again before increasing durations
|
||||
@@ -0,0 +1,24 @@
|
||||
config:
|
||||
target: "{{ $processEnvironment.BASE_URL }}"
|
||||
phases:
|
||||
- duration: "{{ $processEnvironment.WARMUP_DURATION }}"
|
||||
arrivalRate: "{{ $processEnvironment.WARMUP_RATE }}"
|
||||
rampTo: "{{ $processEnvironment.PEAK_RATE }}"
|
||||
name: baseline-ramp
|
||||
- duration: "{{ $processEnvironment.HOLD_DURATION }}"
|
||||
arrivalRate: "{{ $processEnvironment.PEAK_RATE }}"
|
||||
name: baseline-hold
|
||||
defaults:
|
||||
headers:
|
||||
content-type: application/json
|
||||
x-api-key: "{{ $processEnvironment.SIM_API_KEY }}"
|
||||
x-execution-mode: async
|
||||
scenarios:
|
||||
- name: baseline-workflow-concurrency
|
||||
flow:
|
||||
- post:
|
||||
url: "/api/workflows/{{ $processEnvironment.WORKFLOW_ID }}/execute"
|
||||
json:
|
||||
input:
|
||||
source: artillery-baseline
|
||||
runId: "{{ $uuid }}"
|
||||
@@ -0,0 +1,35 @@
|
||||
config:
|
||||
target: "{{ $processEnvironment.BASE_URL }}"
|
||||
phases:
|
||||
- duration: "{{ $processEnvironment.ISOLATION_DURATION }}"
|
||||
arrivalRate: "{{ $processEnvironment.TOTAL_RATE }}"
|
||||
name: mixed-workspace-load
|
||||
defaults:
|
||||
headers:
|
||||
content-type: application/json
|
||||
x-execution-mode: async
|
||||
scenarios:
|
||||
- name: workspace-a-traffic
|
||||
weight: "{{ $processEnvironment.WORKSPACE_A_WEIGHT }}"
|
||||
flow:
|
||||
- post:
|
||||
url: "/api/workflows/{{ $processEnvironment.WORKFLOW_ID_A }}/execute"
|
||||
headers:
|
||||
x-api-key: "{{ $processEnvironment.SIM_API_KEY_A }}"
|
||||
json:
|
||||
input:
|
||||
source: artillery-isolation
|
||||
workspace: a
|
||||
runId: "{{ $uuid }}"
|
||||
- name: workspace-b-traffic
|
||||
weight: "{{ $processEnvironment.WORKSPACE_B_WEIGHT }}"
|
||||
flow:
|
||||
- post:
|
||||
url: "/api/workflows/{{ $processEnvironment.WORKFLOW_ID_B }}/execute"
|
||||
headers:
|
||||
x-api-key: "{{ $processEnvironment.SIM_API_KEY_B }}"
|
||||
json:
|
||||
input:
|
||||
source: artillery-isolation
|
||||
workspace: b
|
||||
runId: "{{ $uuid }}"
|
||||
@@ -0,0 +1,33 @@
|
||||
config:
|
||||
target: "{{ $processEnvironment.BASE_URL }}"
|
||||
phases:
|
||||
- duration: "{{ $processEnvironment.WAVE_ONE_DURATION }}"
|
||||
arrivalRate: "{{ $processEnvironment.WAVE_ONE_RATE }}"
|
||||
name: wave-one
|
||||
- duration: "{{ $processEnvironment.QUIET_DURATION }}"
|
||||
arrivalRate: 1
|
||||
name: quiet-gap
|
||||
- duration: "{{ $processEnvironment.WAVE_TWO_DURATION }}"
|
||||
arrivalRate: "{{ $processEnvironment.WAVE_TWO_RATE }}"
|
||||
name: wave-two
|
||||
- duration: "{{ $processEnvironment.QUIET_DURATION }}"
|
||||
arrivalRate: 1
|
||||
name: quiet-gap-two
|
||||
- duration: "{{ $processEnvironment.WAVE_THREE_DURATION }}"
|
||||
arrivalRate: "{{ $processEnvironment.WAVE_THREE_RATE }}"
|
||||
name: wave-three
|
||||
defaults:
|
||||
headers:
|
||||
content-type: application/json
|
||||
x-api-key: "{{ $processEnvironment.SIM_API_KEY }}"
|
||||
x-execution-mode: async
|
||||
scenarios:
|
||||
- name: workflow-queue-waves
|
||||
flow:
|
||||
- post:
|
||||
url: "/api/workflows/{{ $processEnvironment.WORKFLOW_ID }}/execute"
|
||||
json:
|
||||
input:
|
||||
source: artillery-waves
|
||||
runId: "{{ $uuid }}"
|
||||
waveProfile: single-workspace
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from 'path'
|
||||
import { db } from '@sim/db'
|
||||
import { docsEmbeddings } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { type DocChunk, DocsChunker } from '@/lib/chunkers'
|
||||
import { isDev } from '@/lib/core/config/env-flags'
|
||||
|
||||
const logger = createLogger('ProcessDocs')
|
||||
|
||||
interface ProcessingOptions {
|
||||
/** Clear existing docs embeddings before processing */
|
||||
clearExisting?: boolean
|
||||
/** Path to docs directory */
|
||||
docsPath?: string
|
||||
/** Base URL for generating links */
|
||||
baseUrl?: string
|
||||
/** Chunk size in tokens */
|
||||
chunkSize?: number
|
||||
/** Minimum chunk size in characters */
|
||||
minCharactersPerChunk?: number
|
||||
/** Overlap between chunks in tokens */
|
||||
chunkOverlap?: number
|
||||
/** Dry run - only display results, don't save to DB */
|
||||
dryRun?: boolean
|
||||
/** Verbose output */
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Process documentation files and optionally save embeddings to database
|
||||
*/
|
||||
async function processDocs(options: ProcessingOptions = {}) {
|
||||
const config = {
|
||||
docsPath: options.docsPath || path.join(process.cwd(), '../../apps/docs/content/docs/en'),
|
||||
baseUrl: options.baseUrl || (isDev ? 'http://localhost:4000' : 'https://docs.sim.ai'),
|
||||
chunkSize: options.chunkSize || 1024,
|
||||
minCharactersPerChunk: options.minCharactersPerChunk || 100,
|
||||
chunkOverlap: options.chunkOverlap || 200,
|
||||
clearExisting: options.clearExisting ?? false,
|
||||
dryRun: options.dryRun ?? false,
|
||||
verbose: options.verbose ?? false,
|
||||
}
|
||||
|
||||
let processedChunks = 0
|
||||
let failedChunks = 0
|
||||
|
||||
try {
|
||||
logger.info('🚀 Starting docs processing with config:', {
|
||||
docsPath: config.docsPath,
|
||||
baseUrl: config.baseUrl,
|
||||
chunkSize: config.chunkSize,
|
||||
clearExisting: config.clearExisting,
|
||||
dryRun: config.dryRun,
|
||||
})
|
||||
|
||||
// Initialize the chunker
|
||||
const chunker = new DocsChunker({
|
||||
chunkSize: config.chunkSize,
|
||||
minCharactersPerChunk: config.minCharactersPerChunk,
|
||||
chunkOverlap: config.chunkOverlap,
|
||||
baseUrl: config.baseUrl,
|
||||
})
|
||||
|
||||
// Process all .mdx files
|
||||
logger.info(`📚 Processing docs from: ${config.docsPath}`)
|
||||
const chunks = await chunker.chunkAllDocs(config.docsPath)
|
||||
|
||||
if (chunks.length === 0) {
|
||||
logger.warn('⚠️ No chunks generated from docs')
|
||||
return { success: false, processedChunks: 0, failedChunks: 0 }
|
||||
}
|
||||
|
||||
logger.info(`📊 Generated ${chunks.length} chunks with embeddings`)
|
||||
|
||||
// Group chunks by document for summary
|
||||
const chunksByDoc = chunks.reduce<Record<string, DocChunk[]>>((acc, chunk) => {
|
||||
if (!acc[chunk.sourceDocument]) {
|
||||
acc[chunk.sourceDocument] = []
|
||||
}
|
||||
acc[chunk.sourceDocument].push(chunk)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Display summary
|
||||
logger.info(`\n=== DOCUMENT SUMMARY ===`)
|
||||
for (const [doc, docChunks] of Object.entries(chunksByDoc)) {
|
||||
logger.info(`${doc}: ${docChunks.length} chunks`)
|
||||
}
|
||||
|
||||
// Display sample chunks in verbose or dry-run mode
|
||||
if (config.verbose || config.dryRun) {
|
||||
logger.info(`\n=== SAMPLE CHUNKS ===`)
|
||||
chunks.slice(0, 3).forEach((chunk, index) => {
|
||||
logger.info(`\nChunk ${index + 1}:`)
|
||||
logger.info(` Source: ${chunk.sourceDocument}`)
|
||||
logger.info(` Header: ${chunk.headerText} (Level ${chunk.headerLevel})`)
|
||||
logger.info(` Link: ${chunk.headerLink}`)
|
||||
logger.info(` Tokens: ${chunk.tokenCount}`)
|
||||
logger.info(` Embedding: ${chunk.embedding.length} dimensions (${chunk.embeddingModel})`)
|
||||
if (config.verbose) {
|
||||
logger.info(` Text Preview: ${chunk.text.substring(0, 200)}...`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// If dry run, stop here
|
||||
if (config.dryRun) {
|
||||
logger.info('\n✅ Dry run complete - no data saved to database')
|
||||
return { success: true, processedChunks: chunks.length, failedChunks: 0 }
|
||||
}
|
||||
|
||||
// Clear existing embeddings if requested
|
||||
if (config.clearExisting) {
|
||||
logger.info('🗑️ Clearing existing docs embeddings...')
|
||||
try {
|
||||
await db.delete(docsEmbeddings)
|
||||
logger.info(`✅ Successfully deleted existing embeddings`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete existing embeddings:', error)
|
||||
throw new Error('Failed to clear existing embeddings')
|
||||
}
|
||||
}
|
||||
|
||||
// Save chunks to database in batches
|
||||
const batchSize = 10
|
||||
logger.info(`💾 Saving chunks to database (batch size: ${batchSize})...`)
|
||||
|
||||
for (let i = 0; i < chunks.length; i += batchSize) {
|
||||
const batch = chunks.slice(i, i + batchSize)
|
||||
|
||||
try {
|
||||
const batchData = batch.map((chunk) => ({
|
||||
chunkText: chunk.text,
|
||||
sourceDocument: chunk.sourceDocument,
|
||||
sourceLink: chunk.headerLink,
|
||||
headerText: chunk.headerText,
|
||||
headerLevel: chunk.headerLevel,
|
||||
tokenCount: chunk.tokenCount,
|
||||
embedding: chunk.embedding,
|
||||
embeddingModel: chunk.embeddingModel,
|
||||
metadata: chunk.metadata,
|
||||
}))
|
||||
|
||||
await db.insert(docsEmbeddings).values(batchData)
|
||||
processedChunks += batch.length
|
||||
|
||||
if (i % (batchSize * 5) === 0 || i + batchSize >= chunks.length) {
|
||||
logger.info(
|
||||
` 💾 Saved ${Math.min(i + batchSize, chunks.length)}/${chunks.length} chunks`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to save batch ${Math.floor(i / batchSize) + 1}:`, error)
|
||||
failedChunks += batch.length
|
||||
}
|
||||
}
|
||||
|
||||
// Verify final count
|
||||
const savedCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(docsEmbeddings)
|
||||
.then((res) => res[0]?.count || 0)
|
||||
|
||||
logger.info(
|
||||
`\n✅ Processing complete!\n` +
|
||||
` 📊 Total chunks: ${chunks.length}\n` +
|
||||
` ✅ Processed: ${processedChunks}\n` +
|
||||
` ❌ Failed: ${failedChunks}\n` +
|
||||
` 💾 Total in DB: ${savedCount}`
|
||||
)
|
||||
|
||||
return { success: failedChunks === 0, processedChunks, failedChunks }
|
||||
} catch (error) {
|
||||
logger.error('❌ Fatal error during processing:', error)
|
||||
return { success: false, processedChunks, failedChunks }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point with CLI argument parsing
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
const options: ProcessingOptions = {
|
||||
clearExisting: args.includes('--clear'),
|
||||
dryRun: args.includes('--dry-run'),
|
||||
verbose: args.includes('--verbose'),
|
||||
}
|
||||
|
||||
// Parse custom path if provided
|
||||
const pathIndex = args.indexOf('--path')
|
||||
if (pathIndex !== -1 && args[pathIndex + 1]) {
|
||||
options.docsPath = args[pathIndex + 1]
|
||||
}
|
||||
|
||||
// Parse custom base URL if provided
|
||||
const urlIndex = args.indexOf('--url')
|
||||
if (urlIndex !== -1 && args[urlIndex + 1]) {
|
||||
options.baseUrl = args[urlIndex + 1]
|
||||
}
|
||||
|
||||
// Parse chunk size if provided
|
||||
const chunkSizeIndex = args.indexOf('--chunk-size')
|
||||
if (chunkSizeIndex !== -1 && args[chunkSizeIndex + 1]) {
|
||||
options.chunkSize = Number.parseInt(args[chunkSizeIndex + 1], 10)
|
||||
}
|
||||
|
||||
// Show help if requested
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`
|
||||
📚 Process Documentation Script
|
||||
|
||||
Usage: bun run process-docs.ts [options]
|
||||
|
||||
By default, processes English (en) documentation only.
|
||||
Note: Use --clear flag when changing language scope to remove old embeddings.
|
||||
|
||||
Options:
|
||||
--clear Clear existing embeddings before processing
|
||||
--dry-run Process and display results without saving to DB
|
||||
--verbose Show detailed output including text previews
|
||||
--path <path> Custom path to docs directory (default: docs/en)
|
||||
--url <url> Custom base URL for links
|
||||
--chunk-size <n> Custom chunk size in tokens (default: 1024)
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
# Dry run to test chunking (English docs)
|
||||
bun run process-docs.ts --dry-run
|
||||
|
||||
# Process and save to database (English docs)
|
||||
bun run process-docs.ts
|
||||
|
||||
# Clear existing and reprocess (English docs)
|
||||
bun run process-docs.ts --clear
|
||||
|
||||
# Process a different language
|
||||
bun run process-docs.ts --path ../../apps/docs/content/docs/es
|
||||
|
||||
# Custom path with verbose output
|
||||
bun run process-docs.ts --path ./my-docs --verbose
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const result = await processDocs(options)
|
||||
process.exit(result.success ? 0 : 1)
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
logger.error('Fatal error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
export { processDocs }
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* One-off repair for `user_table_rows.order_key` rows mis-ordered by the
|
||||
* collation bug fixed in migration 0228.
|
||||
*
|
||||
* Fractional `order_key`s are base-62 strings the fractional-indexing library
|
||||
* compares BYTEWISE (ASCII: `0-9 < A-Z < a-z`). Before migration 0228 the column
|
||||
* compared under the database's `en_US.UTF-8` locale, where lowercase interleaves
|
||||
* with/precedes uppercase ("a0" < "Zz", the opposite of bytewise). Keys minted in
|
||||
* that window were anchored to the wrong neighbors, so a table's keys can be
|
||||
* out of order — or duplicated — under bytewise comparison. That makes inserts
|
||||
* throw `generateKeyBetween`'s `a >= b` assertion and rows display out of order.
|
||||
*
|
||||
* This script finds every table whose `order_key`s are mis-assigned: walking rows
|
||||
* in their authoritative `position, id` order, a row's key is `>=` the next row's
|
||||
* under bytewise (`COLLATE "C"`) comparison. That covers swapped keys (a low
|
||||
* position holding a bytewise-larger key than a higher one) and duplicates. Each
|
||||
* flagged table is re-keyed from `position` order — the legacy authoritative order
|
||||
* the original backfill also used — minting a fresh, evenly-spaced, distinct run
|
||||
* with `nKeysBetween`.
|
||||
*
|
||||
* Distinct from the `0001_backfill_table_order_keys` script migration
|
||||
* (`packages/db/script-migrations/`), which keys tables with NULL keys;
|
||||
* this one repairs tables that are fully keyed but bytewise-disordered. Run it
|
||||
* AFTER migration 0228 so the re-key writes and sorts under `COLLATE "C"`.
|
||||
*
|
||||
* Per-table-atomic: each table is re-keyed inside one transaction holding the
|
||||
* same per-table advisory lock the app uses for inserts, so a concurrent insert
|
||||
* can't interleave. Idempotent: a table whose keys are already distinct and
|
||||
* ordered is never selected, so a re-run after a partial failure is safe.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=... bun run apps/sim/scripts/repair-table-order-key-collation.ts
|
||||
* DATABASE_URL=... bun run apps/sim/scripts/repair-table-order-key-collation.ts --dry-run
|
||||
*/
|
||||
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { asc, eq, sql } from 'drizzle-orm'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import { nKeysBetween } from '@/lib/table/order-key'
|
||||
|
||||
/** Keeps each VALUES list well under Postgres's 65535-bound-param and JS call-stack ceilings. */
|
||||
const WRITE_CHUNK_SIZE = 5000
|
||||
|
||||
export async function runRepair(): Promise<void> {
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
const connectionString = process.env.DATABASE_URL ?? process.env.POSTGRES_URL
|
||||
if (!connectionString) {
|
||||
console.error('Missing DATABASE_URL or POSTGRES_URL')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const client = postgres(connectionString, {
|
||||
prepare: false,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 30,
|
||||
max: 5,
|
||||
onnotice: () => {},
|
||||
})
|
||||
const db = drizzle(client)
|
||||
|
||||
const stats = { tables: 0, tablesKeyed: 0, rowsKeyed: 0, failed: 0 }
|
||||
|
||||
try {
|
||||
// Tables with a bytewise (`COLLATE "C"`) inversion or duplicate among their
|
||||
// non-null keys. Walking rows in their authoritative `position, id` order (the
|
||||
// order the re-key writes), a healthy table has strictly INCREASING keys; flag
|
||||
// any table where a row's key is `>=` the next row's. Ordering by `position`
|
||||
// (not by `order_key`) is what makes this detect actual mis-assignment — e.g.
|
||||
// pos 0 holding "a0" while pos 1 holds "Zz" (bytewise "Zz" < "a0") — and not
|
||||
// just adjacent duplicates. The explicit `COLLATE "C"` keeps the comparison
|
||||
// bytewise whether or not migration 0228 has been applied yet.
|
||||
const pending = await db.execute<{ table_id: string }>(sql`
|
||||
SELECT DISTINCT table_id FROM (
|
||||
SELECT
|
||||
table_id,
|
||||
order_key,
|
||||
LEAD(order_key) OVER (
|
||||
PARTITION BY table_id ORDER BY position, id
|
||||
) AS next_key
|
||||
FROM user_table_rows
|
||||
WHERE order_key IS NOT NULL
|
||||
) t
|
||||
WHERE next_key IS NOT NULL AND order_key COLLATE "C" >= next_key COLLATE "C"
|
||||
`)
|
||||
|
||||
console.log(
|
||||
`Repair starting — ${pending.length} table(s) with mis-ordered keys${dryRun ? ' [DRY RUN]' : ''}`
|
||||
)
|
||||
|
||||
for (const { table_id: tableId } of pending) {
|
||||
stats.tables += 1
|
||||
try {
|
||||
if (dryRun) {
|
||||
// Sizing only — count outside any transaction/lock so we never serialize
|
||||
// live inserts on the table (taking the advisory lock just to count would
|
||||
// make the dry run the opposite of safe).
|
||||
const [row] = await db
|
||||
.select({ rowCount: sql<number>`count(*)`.mapWith(Number) })
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, tableId))
|
||||
stats.tablesKeyed += 1
|
||||
stats.rowsKeyed += row.rowCount
|
||||
console.log(` ${tableId}: would re-key ${row.rowCount} rows`)
|
||||
continue
|
||||
}
|
||||
|
||||
const keyed = await db.transaction(async (trx) => {
|
||||
// Serialize with concurrent inserts on this table (same lock the app uses).
|
||||
await trx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))`
|
||||
)
|
||||
const rows = await trx
|
||||
.select({ id: userTableRows.id })
|
||||
.from(userTableRows)
|
||||
.where(eq(userTableRows.tableId, tableId))
|
||||
.orderBy(asc(userTableRows.position), asc(userTableRows.id))
|
||||
|
||||
if (rows.length === 0) return 0
|
||||
const keys = nKeysBetween(null, null, rows.length)
|
||||
|
||||
// Chunked UPDATE … FROM (VALUES …) mapping id → key (see WRITE_CHUNK_SIZE).
|
||||
for (let start = 0; start < rows.length; start += WRITE_CHUNK_SIZE) {
|
||||
const chunk = rows.slice(start, start + WRITE_CHUNK_SIZE)
|
||||
const values = sql.join(
|
||||
chunk.map((r, i) => sql`(${r.id}, ${keys[start + i]})`),
|
||||
sql`, `
|
||||
)
|
||||
await trx.execute(sql`
|
||||
UPDATE user_table_rows AS t
|
||||
SET order_key = v.order_key
|
||||
FROM (VALUES ${values}) AS v(id, order_key)
|
||||
WHERE t.id = v.id AND t.table_id = ${tableId}
|
||||
`)
|
||||
}
|
||||
return rows.length
|
||||
})
|
||||
stats.tablesKeyed += 1
|
||||
stats.rowsKeyed += keyed
|
||||
console.log(` ${tableId}: re-keyed ${keyed} rows`)
|
||||
} catch (error) {
|
||||
stats.failed += 1
|
||||
console.error(` ${tableId}: FAILED — ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const verb = dryRun ? 'to re-key' : 're-keyed'
|
||||
console.log(`Repair complete.${dryRun ? ' [DRY RUN — no rows written]' : ''}`)
|
||||
console.log(` tables scanned: ${stats.tables}`)
|
||||
console.log(` tables ${verb}: ${stats.tablesKeyed}`)
|
||||
console.log(` rows ${verb}: ${stats.rowsKeyed}`)
|
||||
console.log(` failed: ${stats.failed}`)
|
||||
if (stats.failed > 0) process.exitCode = 1
|
||||
} finally {
|
||||
await client.end({ timeout: 5 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if ((import.meta as { main?: boolean }).main) {
|
||||
try {
|
||||
await runRepair()
|
||||
} catch (error) {
|
||||
console.error('Repair aborted:', getErrorMessage(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user