Files
simstudioai--sim/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts
T
wehub-resource-sync d25d482dc2
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

215 lines
9.2 KiB
TypeScript

import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import { runDetached } from '@/lib/core/utils/background'
import { finishBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
import { clearFailedForkResourceReferences } from '@/ee/workspace-forking/lib/copy/cleanup-failed'
import type { BlobCopyTask } from '@/ee/workspace-forking/lib/copy/copy-files'
import { executeForkFileBlobCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
import type {
ForkContentPlan,
ForkFailedResource,
} from '@/ee/workspace-forking/lib/copy/copy-resources'
import { copyForkResourceContent } from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkContentRefMaps } from '@/ee/workspace-forking/lib/remap/remap-content-refs'
const logger = createLogger('WorkspaceForkContentCopy')
/**
* Whether a fork/sync has any heavy content to copy after the commit: table rows, KB documents,
* copied skill bodies, standalone documents, or file blobs. The single gate for scheduling the
* background content fill - shared by fork-create and promote so the two can't diverge. A
* skill-only (or documents-only) copy must still schedule it, otherwise the in-content `sim:` /
* serve-link rewrite in {@link runForkContentCopy} never runs and copied bodies keep source links.
*/
export function hasForkContentToCopy(
contentPlan: ForkContentPlan,
blobTasks: BlobCopyTask[]
): boolean {
return (
contentPlan.tables.length > 0 ||
contentPlan.knowledgeBases.length > 0 ||
contentPlan.skills.length > 0 ||
contentPlan.documents.length > 0 ||
blobTasks.length > 0
)
}
/**
* JSON-serializable form of {@link ForkContentRefMaps} (Maps become Records) so the in-content
* reference maps survive the Trigger.dev payload boundary. Rehydrated to Maps in the runner.
*/
export interface SerializableForkContentRefMaps {
workspaceId?: { from: string; to: string }
fileKeys?: Record<string, string>
fileIds?: Record<string, string>
workflows?: Record<string, string>
knowledgeBases?: Record<string, string>
tables?: Record<string, string>
skills?: Record<string, string>
folders?: Record<string, string>
}
/**
* Serializable payload for the post-fork heavy-content copy. Runs either as a
* Trigger.dev task (`background/fork-content-copy`) or inline via `runDetached`
* when Trigger.dev is disabled - both call {@link runForkContentCopy}.
*/
export interface ForkContentCopyPayload {
contentPlan: ForkContentPlan
blobTasks: BlobCopyTask[]
/** In-content reference maps for rewriting copied markdown blobs (serialized form). */
contentRefMaps?: SerializableForkContentRefMaps
/**
* `background_work_status` row to finish when the copy ends, so the source workspace's
* Manage Forks -> Activity entry resolves (completed / warning / error). Started right
* after the fork commits so it's visible immediately.
*/
statusId?: string
/**
* Target workflow ids this sync deployed (promote's deploy loop). When a copied resource's
* fill fails, its dropped placeholder must be cleared from these workflows' DEPLOYED version
* states too - a deployed version can reference the placeholder even when the draft no longer
* does (edited in the fill window), so the cleanup unions these with the draft-affected set
* rather than relying on draft divergence. Empty/omitted for fork-create (child is undeployed).
*/
deployedTargetWorkflowIds?: string[]
requestId?: string
}
const toRefMap = (record?: Record<string, string>): Map<string, string> | undefined =>
record ? new Map(Object.entries(record)) : undefined
const fromRefMap = (map?: ReadonlyMap<string, string>): Record<string, string> | undefined =>
map && map.size > 0 ? Object.fromEntries(map) : undefined
/**
* Convert the Map-based {@link ForkContentRefMaps} to its JSON-serializable form for the
* Trigger.dev payload. Empty maps are dropped (omitted). Single source of truth for the
* Map->Record direction, paired with {@link deserializeContentRefMaps}.
*/
export function serializeContentRefMaps(maps: ForkContentRefMaps): SerializableForkContentRefMaps {
return {
workspaceId: maps.workspaceId,
fileKeys: fromRefMap(maps.fileKeys),
fileIds: fromRefMap(maps.fileIds),
workflows: fromRefMap(maps.workflows),
knowledgeBases: fromRefMap(maps.knowledgeBases),
tables: fromRefMap(maps.tables),
skills: fromRefMap(maps.skills),
folders: fromRefMap(maps.folders),
}
}
/** Rehydrate the serialized content-ref maps to the Map-based {@link ForkContentRefMaps}. */
function deserializeContentRefMaps(
serialized?: SerializableForkContentRefMaps
): ForkContentRefMaps | undefined {
if (!serialized) return undefined
return {
workspaceId: serialized.workspaceId,
fileKeys: toRefMap(serialized.fileKeys),
fileIds: toRefMap(serialized.fileIds),
workflows: toRefMap(serialized.workflows),
knowledgeBases: toRefMap(serialized.knowledgeBases),
tables: toRefMap(serialized.tables),
skills: toRefMap(serialized.skills),
folders: toRefMap(serialized.folders),
}
}
/**
* Copy the heavy fork content after the fork transaction has committed: table
* rows, KB documents + embeddings (keyset-paginated), and file blobs. Best-effort
* and idempotency-unsafe (per-row inserts use fresh ids), so it must run at most
* once - never blindly retried. Per-resource failures are counted (not thrown), so
* the run finishes `completed_with_warnings` rather than failing the whole copy.
*/
export async function runForkContentCopy(payload: ForkContentCopyPayload): Promise<void> {
const { contentPlan, blobTasks, statusId, requestId } = payload
try {
const contentRefMaps = deserializeContentRefMaps(payload.contentRefMaps)
const resourceCounts = await copyForkResourceContent({ contentPlan, contentRefMaps, requestId })
const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId, contentRefMaps)
// A resource whose content fill failed leaves a dangling reference: a table/KB/doc placeholder
// its workflows still point at, or a `file-upload` whose copied blob is missing. Clear those
// references (draft + deployed versions) and drop the table/KB/doc placeholder so nothing
// dangles; a failed file leaves its metadata row (re-uploadable) but has its refs cleared.
const fileFailures: ForkFailedResource[] = fileCounts.failedTargetKeys.map((childKey) => ({
kind: 'file',
childKey,
}))
const { cleared, clearingFailed } = await clearFailedForkResourceReferences({
childWorkspaceId: contentPlan.childWorkspaceId,
failures: [...resourceCounts.failures, ...fileFailures],
deployedTargetWorkflowIds: payload.deployedTargetWorkflowIds,
requestId,
})
const copied = resourceCounts.copied + fileCounts.copied
const failed = resourceCounts.failed + fileCounts.failed
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: failed > 0 ? 'completed_with_warnings' : 'completed',
message:
failed > 0
? `Copied ${copied} item${copied === 1 ? '' : 's'}; ${failed} could not be copied`
: `Copied ${copied} item${copied === 1 ? '' : 's'}`,
metadata: {
copied,
failed,
clearedReferences: cleared,
...(clearingFailed ? { clearingFailed: true } : {}),
},
})
}
} catch (error) {
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: 'failed',
error: getErrorMessage(error, 'Background resource copy failed'),
}).catch(() => {})
}
throw error
}
}
/**
* Schedule the post-commit heavy-content copy off the request path. Uses the Trigger.dev task
* when enabled (so it survives an app deploy), else `runDetached` inline best-effort. Shared by
* both fork and sync - only the `detachedLabel` (and so the inline job's name) differs. Never
* throws: a scheduling failure is logged and, when a status row exists, marks it failed, so a
* committed fork/sync is never turned into a 500 by a background-scheduling hiccup.
*/
export async function scheduleForkContentCopy(
payload: ForkContentCopyPayload,
options: { detachedLabel: string; requestId?: string }
): Promise<void> {
const { detachedLabel, requestId = 'unknown' } = options
try {
if (isTriggerDevEnabled) {
const [{ forkContentCopyTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/fork-content-copy'),
import('@trigger.dev/sdk'),
import('@/lib/core/async-jobs/region'),
])
await tasks.trigger<typeof forkContentCopyTask>('fork-content-copy', payload, {
region: await resolveTriggerRegion(),
})
} else {
runDetached(detachedLabel, () => runForkContentCopy(payload))
}
} catch (error) {
logger.error(`[${requestId}] Failed to schedule fork content copy`, {
detachedLabel,
error: getErrorMessage(error),
})
if (payload.statusId) {
await finishBackgroundWork(db, payload.statusId, {
status: 'failed',
error: getErrorMessage(error, 'Could not start the background copy'),
}).catch(() => {})
}
}
}