Files
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

365 lines
16 KiB
TypeScript

import { db } from '@sim/db'
import {
document,
knowledgeBase,
userTableDefinitions,
workflow,
workflowBlocks,
workflowDeploymentVersion,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, asc, eq, gt, inArray } from 'drizzle-orm'
import { isRecord, type SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import { invalidateDeployedStateCache } from '@/lib/workflows/persistence/utils'
import type { ForkFailedResource } from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
import {
clearDependentsOnRemap,
type ForkRemapKind,
remapForkSubBlocks,
} from '@/ee/workspace-forking/lib/remap/remap-references'
const logger = createLogger('WorkspaceForkCleanupFailed')
/** Child workflow ids loaded per page so the block sweep never materializes the whole workspace. */
const WORKFLOW_PAGE = 200
/** Deployment versions loaded per page so a workflow with many versions never loads all at once. */
const DEPLOYMENT_VERSION_PAGE = 100
/** Identity-or-clear resolver: a failed id resolves to null (cleared), any other id to itself. */
function buildFailedResolver(failedByKind: Map<ForkRemapKind, Set<string>>): ForkCopyResolver {
return (kind, id) => (failedByKind.get(kind)?.has(id) ? null : id)
}
/**
* Apply the identity-or-clear resolver to one block's subBlocks: a value (top-level selector or
* nested tool param) that resolves to a failed id is cleared, and its `dependsOn` children with it;
* everything else is left untouched. Returns the rewritten record plus whether anything changed.
* Shared by the draft block sweep and the deployment-version state sweep so both clear identically.
*/
function clearFailedSubBlockReferences(
subBlocks: SubBlockRecord,
blockType: string,
resolve: ForkCopyResolver
): { subBlocks: SubBlockRecord; changed: boolean } {
const result = remapForkSubBlocks(subBlocks, resolve, 'create')
// remappedKeys is non-empty only when a failed id was actually cleared, so a block that
// referenced nothing failed is reported unchanged without a write.
if (result.remappedKeys.size === 0) return { subBlocks, changed: false }
return {
subBlocks: clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys),
changed: true,
}
}
/**
* Clean up after a resource whose post-commit content fill failed: clear every subblock
* reference in the child workspace's workflows that points at the failed resource (so no
* subblock keeps a dead id), then drop the orphaned placeholder rows (a KB cascade-drops its
* documents + embeddings; a table cascade-drops its rows). In-content references inside copied
* skill/markdown bodies are intentionally left as graceful broken links rather than mutated.
*
* The deployed-version sweep covers the draft-affected workflows UNION this sync's deployed
* target workflows (`deployedTargetWorkflowIds`): a deployed version can reference the dropped
* placeholder even when the draft no longer does (the user edited the empty-looking block in the
* fill window), so scoping to draft divergence alone would miss it.
*
* Best-effort and isolated: a failure cleaning one resource is logged and the rest continue, so
* a cleanup error never aborts the others. The placeholder drop is SKIPPED when a reference-clear
* phase threw - dropping it then would turn an empty placeholder into a dangling reference to a
* deleted row; leaving it keeps the reference resolvable (to empty content) until a later retry.
*
* Returns `{ cleared, clearingFailed }` for the fork activity metadata. `clearingFailed` is true
* when a reference-clear phase threw - placeholders were then NOT dropped - and `cleared` is 0 in
* that case, so the report never claims references it did not actually clear. On success `cleared`
* is the count of failed resources whose references were cleared.
*
* Storage accounting: this cleanup never decrements storage usage because it never removes
* anything that was counted. Copied file blobs are the only counted copies (incremented in
* `executeForkFileBlobCopies` only after the blob lands), and a failed file's blob never
* landed - its metadata row is intentionally left re-uploadable, and nothing was charged. The
* dropped table/KB/document placeholders are DB rows the upload path never counts, and any KB
* blobs copied before their KB failed are left in storage (rows only are dropped here) but
* uncounted - mirroring the KB upload path, which never counts KB blobs.
*/
export async function clearFailedForkResourceReferences(params: {
childWorkspaceId: string
failures: ForkFailedResource[]
/** Target workflows this sync deployed; their deployed versions are swept regardless of draft. */
deployedTargetWorkflowIds?: string[]
requestId?: string
}): Promise<{ cleared: number; clearingFailed: boolean }> {
const { childWorkspaceId, failures, requestId = 'unknown' } = params
if (failures.length === 0) return { cleared: 0, clearingFailed: false }
const failedByKind = new Map<ForkRemapKind, Set<string>>()
const markFailed = (kind: ForkRemapKind, id: string) => {
const set = failedByKind.get(kind)
if (set) set.add(id)
else failedByKind.set(kind, new Set([id]))
}
const tableIds: string[] = []
const kbIds: string[] = []
// Standalone documents copied into an already-existing target KB (the doc-into-mapped-KB sync
// path) - dropped individually, since their KB is not ours to remove.
const docIds: string[] = []
for (const failure of failures) {
if (failure.kind === 'table') {
markFailed('table', failure.childId)
tableIds.push(failure.childId)
} else if (failure.kind === 'knowledge-document') {
markFailed('knowledge-document', failure.childId)
docIds.push(failure.childId)
} else if (failure.kind === 'file') {
// A failed file blob: clear `file-upload` references to its copied storage key. No row to
// drop - the metadata row is left in place so the user can re-upload the missing blob.
markFailed('file', failure.childKey)
} else {
markFailed('knowledge-base', failure.childId)
for (const docId of failure.documentChildIds) markFailed('knowledge-document', docId)
kbIds.push(failure.childId)
}
}
// Whether BOTH reference-clear phases completed without throwing. The placeholder drop below is
// gated on this: if clearing threw, a workflow (draft or deployed version) may still reference
// the failed id, so dropping its placeholder would create a dangling reference to a deleted row.
let clearingSucceeded = true
let affectedWorkflowIds: Set<string> = new Set()
try {
affectedWorkflowIds = await clearFailedReferencesInWorkflows(
childWorkspaceId,
failedByKind,
requestId
)
} catch (error) {
clearingSucceeded = false
logger.error(`[${requestId}] Failed to clear references for failed fork resources`, {
childWorkspaceId,
error: getErrorMessage(error),
})
}
// The same dead id also lives in DEPLOYED version states (the active one re-cut from the
// placeholder-referencing draft at this sync's redeploy, plus any newer version from a rare
// race), not only the draft just swept above. Sweep those too so "Load deployment" can't
// re-poison the cleaned draft with a dropped id, and the execute path never runs against content
// that no longer exists. Scope is the draft-affected workflows UNION this sync's deployed target
// workflows: a deployed version may reference the placeholder even when the draft no longer does
// (edited in the fill window), so draft divergence alone is not a reliable scope. Isolated: a
// failure here never aborts a sibling resource's cleanup.
const deployedSweepIds = new Set(affectedWorkflowIds)
for (const id of params.deployedTargetWorkflowIds ?? []) deployedSweepIds.add(id)
try {
await clearFailedReferencesInDeploymentVersions(deployedSweepIds, failedByKind, requestId)
} catch (error) {
clearingSucceeded = false
logger.error(`[${requestId}] Failed to clear references in fork deployment versions`, {
childWorkspaceId,
error: getErrorMessage(error),
})
}
// Drop the orphaned placeholders. The KB delete cascades its documents + embeddings; the
// table delete cascades its rows; a standalone document delete cascades its embeddings. Done
// after the refs are cleared so a drop failure can't strand a workflow still pointing at the
// (now content-less) resource - and ONLY when clearing succeeded, so a clear failure never
// turns an empty placeholder into a dangling reference to a deleted row.
if (!clearingSucceeded) {
logger.warn(
`[${requestId}] Skipping fork resource placeholder drop after a reference-clear failure`,
{
childWorkspaceId,
tables: tableIds.length,
knowledgeBases: kbIds.length,
documents: docIds.length,
}
)
return { cleared: 0, clearingFailed: true }
}
try {
if (tableIds.length > 0) {
await db.delete(userTableDefinitions).where(inArray(userTableDefinitions.id, tableIds))
}
if (kbIds.length > 0) {
await db.delete(knowledgeBase).where(inArray(knowledgeBase.id, kbIds))
}
if (docIds.length > 0) {
await db.delete(document).where(inArray(document.id, docIds))
}
} catch (error) {
logger.error(`[${requestId}] Failed to drop orphaned fork resource placeholders`, {
childWorkspaceId,
error: getErrorMessage(error),
})
}
return { cleared: failures.length, clearingFailed: false }
}
/**
* Sweep the child workspace's workflow blocks and clear any subblock value (top-level selector
* or nested tool param) that resolves to a failed child resource id. Reuses the create-mode
* remap with an identity-or-clear resolver: a non-failed id resolves to itself (left unchanged),
* a failed id resolves to null and is cleared, and its `dependsOn` children are cleared too.
* Returns the ids of the workflows whose blocks actually had a reference cleared, so the deployed
* version sweep can scope itself to exactly those workflows.
*/
export async function clearFailedReferencesInWorkflows(
childWorkspaceId: string,
failedByKind: Map<ForkRemapKind, Set<string>>,
requestId: string
): Promise<Set<string>> {
const resolve = buildFailedResolver(failedByKind)
const affectedWorkflowIds = new Set<string>()
let afterWorkflowId: string | null = null
for (;;) {
const workflowRows = await db
.select({ id: workflow.id })
.from(workflow)
.where(
afterWorkflowId === null
? eq(workflow.workspaceId, childWorkspaceId)
: and(eq(workflow.workspaceId, childWorkspaceId), gt(workflow.id, afterWorkflowId))
)
.orderBy(asc(workflow.id))
.limit(WORKFLOW_PAGE)
if (workflowRows.length === 0) break
const workflowIds = workflowRows.map((row) => row.id)
const blocks = await db
.select({
id: workflowBlocks.id,
workflowId: workflowBlocks.workflowId,
type: workflowBlocks.type,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(inArray(workflowBlocks.workflowId, workflowIds))
for (const block of blocks) {
const current = (block.subBlocks ?? {}) as SubBlockRecord
const { subBlocks: cleared, changed } = clearFailedSubBlockReferences(
current,
block.type,
resolve
)
if (!changed) continue
await db
.update(workflowBlocks)
.set({ subBlocks: cleared })
.where(eq(workflowBlocks.id, block.id))
affectedWorkflowIds.add(block.workflowId)
}
if (workflowRows.length < WORKFLOW_PAGE) break
afterWorkflowId = workflowIds[workflowIds.length - 1]
}
return affectedWorkflowIds
}
/**
* Rewrite a deployment version's serialized state in memory, clearing every block subblock that
* resolves to a failed id (and its `dependsOn` children). Returns `changed: false` with the
* original state when no block referenced a failed id - so a version cut before this sync, which
* cannot contain the new placeholder id, is a no-op and never written back. Tolerant of a
* malformed/legacy state shape (anything that is not `{ blocks: {...} }` is left untouched).
*/
export function rewriteDeploymentVersionState(
state: unknown,
resolve: ForkCopyResolver
): { state: unknown; changed: boolean } {
if (!isRecord(state) || !isRecord(state.blocks)) return { state, changed: false }
let nextBlocks: Record<string, unknown> | null = null
for (const [blockId, block] of Object.entries(state.blocks)) {
if (!isRecord(block)) continue
const blockType = typeof block.type === 'string' ? block.type : undefined
if (!blockType || !isRecord(block.subBlocks)) continue
const { subBlocks: cleared, changed } = clearFailedSubBlockReferences(
block.subBlocks as SubBlockRecord,
blockType,
resolve
)
if (!changed) continue
nextBlocks ??= { ...state.blocks }
nextBlocks[blockId] = { ...block, subBlocks: cleared }
}
if (!nextBlocks) return { state, changed: false }
return { state: { ...state, blocks: nextBlocks }, changed: true }
}
/**
* Rewrite the DEPLOYED version states of the workflows whose draft blocks were just swept. The dead
* placeholder id lives in any version re-cut from the (placeholder-referencing) draft at this sync's
* redeploy - in practice the active one - so it must be cleared there too, not only in the draft.
* Every version of each affected workflow is examined, but only versions whose state actually
* changes are written: an older version predates the sync and cannot contain the new id, so it is a
* no-op. After a version is rewritten its cached deployed state is evicted so execute/serve rebuilds
* from the cleaned snapshot. Bounded work (no long transaction): per-version short UPDATEs, versions
* keyset-paginated, and a per-workflow failure is logged without aborting the other workflows.
*/
export async function clearFailedReferencesInDeploymentVersions(
workflowIds: ReadonlySet<string>,
failedByKind: Map<ForkRemapKind, Set<string>>,
requestId: string
): Promise<void> {
if (workflowIds.size === 0) return
const resolve = buildFailedResolver(failedByKind)
for (const workflowId of workflowIds) {
try {
let afterVersion: number | null = null
for (;;) {
const versions = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
afterVersion === null
? eq(workflowDeploymentVersion.workflowId, workflowId)
: and(
eq(workflowDeploymentVersion.workflowId, workflowId),
gt(workflowDeploymentVersion.version, afterVersion)
)
)
.orderBy(asc(workflowDeploymentVersion.version))
.limit(DEPLOYMENT_VERSION_PAGE)
if (versions.length === 0) break
for (const version of versions) {
const { state: nextState, changed } = rewriteDeploymentVersionState(
version.state,
resolve
)
if (!changed) continue
await db
.update(workflowDeploymentVersion)
.set({ state: nextState })
.where(eq(workflowDeploymentVersion.id, version.id))
// Evict the post-migration deployed state cached by this immutable version id so the
// execute/serve path rebuilds from the cleaned snapshot.
invalidateDeployedStateCache(version.id)
}
if (versions.length < DEPLOYMENT_VERSION_PAGE) break
afterVersion = versions[versions.length - 1].version
}
} catch (error) {
logger.error(`[${requestId}] Failed to clear references in deployment versions`, {
workflowId,
error: getErrorMessage(error),
})
}
}
}