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
215 lines
9.2 KiB
TypeScript
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(() => {})
|
|
}
|
|
}
|
|
}
|