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

101 lines
4.8 KiB
TypeScript

import { createHash } from 'node:crypto'
/**
* Fixed namespace UUID for fork block-identity derivation. Changing this value
* would re-key every forked workflow's block ids, breaking webhook URLs and
* external block references (table workflow groups, chat output configs) across
* promotes - so it must never change.
*/
const FORK_BLOCK_NAMESPACE = '6f1c0e2a-9b3d-5e47-8a1c-2d4f6b8e0c13'
function uuidToBytes(uuid: string): Buffer {
return Buffer.from(uuid.replace(/-/g, ''), 'hex')
}
/**
* Deterministic UUIDv5 (SHA-1) of `name` within `namespace`. The same inputs
* always yield the same UUID, which is how fork block identity stays stable.
*
* SHA-1 is mandated by RFC 4122 for UUIDv5 and is used here only for deterministic id derivation,
* never for secrecy or integrity — not a security use of the algorithm. Swapping it would change
* every derived id, breaking webhook URLs and stored block-id references across existing forks
* (see {@link FORK_BLOCK_NAMESPACE}).
*/
function uuidV5(name: string, namespace: string): string {
const hash = createHash('sha1')
hash.update(uuidToBytes(namespace)) // lgtm[js/weak-cryptographic-algorithm]
hash.update(Buffer.from(name, 'utf8')) // lgtm[js/weak-cryptographic-algorithm]
const bytes = hash.digest().subarray(0, 16)
bytes[6] = (bytes[6] & 0x0f) | 0x50
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = bytes.toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/**
* Derive the target block id for a source block copied into a target workflow.
*
* Identity is deterministic in `(targetWorkflowId, sourceBlockId)`, so a logical
* block keeps the same target id across every promote. This is what keeps trigger
* webhook URLs consistent and keeps external block-id references (table workflow
* groups, chat output configs, sim_trigger_state) valid across promotes. A source
* block that no longer exists simply has no derived target, so the target block
* disappears on the next force-replace.
*/
export function deriveForkBlockId(targetWorkflowId: string, sourceBlockId: string): string {
return uuidV5(`${targetWorkflowId}:${sourceBlockId}`, FORK_BLOCK_NAMESPACE)
}
/** A persisted counterpart: the target block id plus the workflow it belongs to. */
export interface ForkBlockMapEntry {
targetBlockId: string
/** The target-side workflow the pair belongs to (childWorkflowId for parentToChild). */
targetWorkflowId: string
}
/** Persisted block-identity pairs for an edge, indexed for both promote directions. */
export interface ForkBlockMap {
/** parent block id -> { child block, child workflow } (pull/create resolve source=parent). */
parentToChild: ReadonlyMap<string, ForkBlockMapEntry>
/** child block id -> { parent block, parent workflow } (push resolves source=child). */
childToParent: ReadonlyMap<string, ForkBlockMapEntry>
}
/** An empty map - fork creation has no prior pairs, so every block id is derived fresh. */
export const EMPTY_FORK_BLOCK_MAP: ForkBlockMap = {
parentToChild: new Map(),
childToParent: new Map(),
}
/** Resolve a source block to its target block id for a promote (map-or-derive). */
export type ForkBlockIdResolver = (targetWorkflowId: string, sourceBlockId: string) => string
/**
* Build the block-id resolver a promote uses to assign target block ids. It reuses the
* persisted counterpart when one exists AND that pair belongs to the workflow being written,
* else falls back to {@link deriveForkBlockId} (blocks added since the last sync; fork
* creation, which has no map). `sourceIsParent` is true on pull/create (source = parent) and
* false on push (source = child); it selects the lookup direction.
*
* The workflow guard is what makes a re-created target safe: if the original target workflow
* was archived and the promote creates a new one, the recorded pair points at the OLD
* workflow, so it no longer matches and we derive a fresh id - never reusing the archived
* workflow's block id (which would collide on the global `workflow_blocks` primary key).
*
* For a stable workflow this still maps each child block back to the parent's ORIGINAL id on
* push, keeping its trigger webhook URL fixed. The SAME resolver must back
* `copyWorkflowStateIntoTarget` (which writes the blocks) and `collectForkDependentReconfigs`
* (which keys the modal's override by target block id), or the two would disagree.
*/
export function buildForkBlockIdResolver(
sourceIsParent: boolean,
map: ForkBlockMap
): ForkBlockIdResolver {
const existing = sourceIsParent ? map.parentToChild : map.childToParent
return (targetWorkflowId, sourceBlockId) => {
const entry = existing.get(sourceBlockId)
if (entry && entry.targetWorkflowId === targetWorkflowId) return entry.targetBlockId
return deriveForkBlockId(targetWorkflowId, sourceBlockId)
}
}