chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,774 @@
import { z } from 'zod'
import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workspaceSchema } from '@/lib/api/contracts/workspaces'
const workspaceIdParamsSchema = z.object({ id: nonEmptyIdSchema })
export const forkRemapKindSchema = z.enum([
'credential',
'env-var',
'knowledge-base',
'knowledge-document',
'table',
'file',
'mcp-server',
'custom-tool',
'skill',
])
export const forkResourceTypeSchema = z.enum([
'workflow',
'oauth_credential',
'service_account_credential',
'env_var',
'table',
'knowledge_base',
'knowledge_document',
'file',
'mcp_server',
/**
* Workflow-publishing MCP server identity (parent shell <-> fork copy), seeded at fork so a
* sync can mirror `workflow_mcp_tool` attachments onto the mapped counterpart. System-managed;
* never user-mapped (nothing in a workflow references these servers).
*/
'workflow_mcp_server',
'custom_tool',
'skill',
])
/**
* Resource types a user may map via the mapping editor. Excludes `workflow` (identity is
* system-managed - seeded at fork, maintained by promote, dissolved by rollback - and must never
* be written through the editor, or a crafted entry could repoint a promote at the wrong target
* workflow), `workflow_mcp_server` (identity is likewise system-managed - seeded when a fork
* copies the server shells - and nothing in a workflow references one, so there is never a
* mapping entry to edit), AND `knowledge_document` (a document is never a standalone mapping: it
* follows its parent knowledge base, re-picked in that KB's reconfigure flow and auto-remapped
* when the KB is copied - the mapping view never emits one and `listForkResourceCandidates`
* returns none).
*/
export const forkMappableResourceTypeSchema = forkResourceTypeSchema.exclude([
'workflow',
'workflow_mcp_server',
'knowledge_document',
])
export type ForkMappableResourceType = z.infer<typeof forkMappableResourceTypeSchema>
export const forkDirectionSchema = z.enum(['push', 'pull'])
/**
* The remappable, copyable resource kinds a sync can copy into the target when they are
* unmapped (the fork-style copy at promote time), whether referenced by the synced workflows or
* not. Excludes credentials and env vars (never copied); documents are auto-copied with their
* parent knowledge base, not selected individually. Workspace `file` references are keyed by
* storage key (not `workspace_files.id`) and copied like fork does.
*/
export const forkCopyableKindSchema = z.enum([
'knowledge-base',
'table',
'custom-tool',
'skill',
'file',
/**
* External MCP servers copy as CONFIG rows (transport/url/headers verbatim; OAuth tokens
* never copied - oauth-auth servers land disconnected until re-authorized in the target).
*/
'mcp-server',
])
export type ForkCopyableKind = z.infer<typeof forkCopyableKindSchema>
export const forkLineageNodeSchema = z.object({
id: z.string(),
name: z.string(),
organizationId: z.string().nullable(),
/**
* Whether the viewer has any access (read or higher, explicit or org-derived) to this
* lineage workspace. Drives the Forks page's row-action gating - lineage rows are visible
* to any admin of the CURRENT workspace, who may hold no access to the other side.
*/
viewerAccessible: z.boolean(),
})
/** A live fork of this workspace, listed read-only on the Forks settings page. */
export const forkLineageChildSchema = forkLineageNodeSchema.extend({
/** When the fork was created (ISO timestamp). */
createdAt: z.string(),
})
export const getForkLineageContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/lineage',
params: workspaceIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
workspaceId: z.string(),
parent: forkLineageNodeSchema.nullable(),
/** Live forks created from this workspace, newest first. */
children: z.array(forkLineageChildSchema),
/** The most recent undoable promote into this workspace, for the rollback UI. */
undoableRun: z
.object({
otherWorkspaceId: z.string(),
otherName: z.string(),
direction: forkDirectionSchema,
})
.nullable(),
}),
},
})
export type ForkLineageNodeApi = z.output<typeof forkLineageNodeSchema>
export type ForkLineageChildApi = z.output<typeof forkLineageChildSchema>
export type GetForkLineageResponse = z.output<typeof getForkLineageContract.response.schema>
const forkResourceIdList = z.array(nonEmptyIdSchema).max(2000).optional()
export const forkResourceSelectionSchema = z.object({
files: forkResourceIdList,
tables: forkResourceIdList,
knowledgeBases: forkResourceIdList,
customTools: forkResourceIdList,
skills: forkResourceIdList,
/**
* External MCP servers, copied as config rows (transport/url/headers) so MCP tool selections
* in the forked workflows keep working. OAuth tokens are never copied - an oauth-auth server
* lands disconnected in the child until re-authorized; tools re-discover on first use.
*/
mcpServers: forkResourceIdList,
/** Workflow-publishing MCP servers, copied as config-only shells with no workflows attached. */
workflowMcpServers: forkResourceIdList,
})
export const forkWorkspaceBodySchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name is too long').optional(),
copy: forkResourceSelectionSchema.optional(),
})
export const forkWorkspaceContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/fork',
params: workspaceIdParamsSchema,
body: forkWorkspaceBodySchema,
response: {
mode: 'json',
schema: z.object({
// Full workspace row so the client can merge it into the workspace-list cache
// (parity with create), not just the lineage node.
workspace: workspaceSchema,
workflowsCopied: z.number().int(),
}),
},
})
export type ForkWorkspaceBody = z.input<typeof forkWorkspaceBodySchema>
export type ForkWorkspaceResponse = z.output<typeof forkWorkspaceContract.response.schema>
export const forkCopyableResourceSchema = z.object({ id: z.string(), label: z.string() })
export type ForkCopyableResource = z.output<typeof forkCopyableResourceSchema>
/**
* A copyable workspace file plus its folder grouping. `folderId`/`folderName` are null when
* the file sits at the workspace root (or its folder was deleted). Files are the only copyable
* kind that nests in the picker (folder ▸ file); every other kind stays flat at the top level.
*/
export const forkCopyableFileSchema = forkCopyableResourceSchema.extend({
folderId: z.string().nullable(),
folderName: z.string().nullable(),
})
export type ForkCopyableFile = z.output<typeof forkCopyableFileSchema>
export const getForkResourcesContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/resources',
params: workspaceIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
files: z.array(forkCopyableFileSchema),
tables: z.array(forkCopyableResourceSchema),
knowledgeBases: z.array(forkCopyableResourceSchema),
customTools: z.array(forkCopyableResourceSchema),
skills: z.array(forkCopyableResourceSchema),
/** External MCP servers (config rows; OAuth tokens never copied). */
mcpServers: z.array(forkCopyableResourceSchema),
workflowMcpServers: z.array(forkCopyableResourceSchema),
deployedWorkflowCount: z.number().int(),
}),
},
})
export type GetForkResourcesResponse = z.output<typeof getForkResourcesContract.response.schema>
export const forkMappingCandidateSchema = z.object({
id: z.string(),
label: z.string(),
providerId: z.string().optional(),
})
export const forkMappingEntrySchema = z.object({
kind: forkRemapKindSchema,
resourceType: forkMappableResourceTypeSchema,
sourceId: z.string(),
sourceLabel: z.string(),
targetId: z.string().nullable(),
/** True when `targetId` is an unconfirmed auto-suggestion (no persisted mapping yet). */
suggested: z.boolean(),
required: z.boolean(),
candidates: z.array(forkMappingCandidateSchema),
/**
* True when the target workspace has more candidates of this kind than the picker
* loads, so the list shown is partial. The UI surfaces a "refine to find more"
* notice; the chosen target is validated by exact id on save (never capped).
*/
candidatesTruncated: z.boolean(),
})
export type ForkMappingEntry = z.output<typeof forkMappingEntrySchema>
export const getForkMappingQuerySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema.default('push'),
})
export const getForkMappingContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/mapping',
params: workspaceIdParamsSchema,
query: getForkMappingQuerySchema,
response: {
mode: 'json',
schema: z.object({
childWorkspaceId: z.string(),
parentWorkspaceId: z.string(),
sourceWorkspaceId: z.string(),
targetWorkspaceId: z.string(),
entries: z.array(forkMappingEntrySchema),
}),
},
})
export type GetForkMappingResponse = z.output<typeof getForkMappingContract.response.schema>
/**
* One dependent field's value in the stored mapping. The sync modal and the Forks
* settings page's mapping editor send the full set for every dependent whose parent is
* mapped; the server persists them to `workspace_fork_dependent_value` (promote also
* applies them verbatim to the target blocks), so the user's selection survives every
* future sync without re-picking. `blockId` is the deterministic fork block id, so the
* value lands on the right block.
*/
export const forkDependentValueEntrySchema = z.object({
workflowId: nonEmptyIdSchema,
blockId: nonEmptyIdSchema,
subBlockKey: z.string().min(1, 'subBlockKey is required'),
value: z.string(),
})
export type ForkDependentValueEntry = z.input<typeof forkDependentValueEntrySchema>
export const updateForkMappingBodySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
entries: z
.array(
z.object({
resourceType: forkMappableResourceTypeSchema,
sourceId: z.string().min(1),
targetId: z.string().min(1).nullable(),
})
)
.max(5000),
/**
* The full stored mapping of dependent-field values for the workflows it names; persisted
* to `workspace_fork_dependent_value` alongside the mapping entries (each named workflow's
* stored set is replaced by exactly what was sent - cleared fields drop out). Omitting the
* field leaves the stored mapping untouched. Unlike promote this only stores the values;
* they are applied to the target blocks on the next sync.
*/
dependentValues: z.array(forkDependentValueEntrySchema).max(2000).optional(),
})
export const updateForkMappingContract = defineRouteContract({
method: 'PUT',
path: '/api/workspaces/[id]/fork/mapping',
params: workspaceIdParamsSchema,
body: updateForkMappingBodySchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true), updated: z.number().int() }),
},
})
export type UpdateForkMappingBody = z.input<typeof updateForkMappingBodySchema>
export const forkUnmappedReferenceSchema = z.object({
kind: forkRemapKindSchema,
sourceId: z.string(),
required: z.boolean(),
blockName: z.string().optional(),
})
export const forkWorkflowChangeSchema = z.object({
action: z.enum(['update', 'create', 'archive']),
/** Workflow name in the workspace the modal is open in. */
currentName: z.string(),
/** Workflow name in the sync-partner workspace (differs from `currentName` after a rename). */
otherName: z.string(),
})
/**
* A configured selector field (Gmail label, Slack channel, KB document, ...) that
* `dependsOn` a remappable parent resource - a credential or a knowledge base - so a
* sync clears it whenever that parent's target changes. The modal renders a controlled
* selector against the newly-chosen parent (using `selectorKey` + `context` + the new
* parent value under `parentContextKey`) so the user re-picks the value in place,
* pre-sync, instead of having it cleared and reconfigured after. `targetBlockId` is the
* deterministic fork block id, matching the engine, so the re-pick lands on the right
* block. Only fields the source actually had set are emitted (the active operation's),
* so blocks aren't padded with every operation variant.
*/
export const forkDependentReconfigSchema = z.object({
/** The remappable parent resource kind whose target swap clears this field. */
parentKind: z.enum(['credential', 'knowledge-base', 'table']),
/** Source id of that parent (matches a mapping entry's `sourceId`). */
parentSourceId: z.string(),
/** SelectorContext key the new parent value is supplied under (`oauthCredential` | `knowledgeBaseId` | `tableId`). */
parentContextKey: z.string(),
targetWorkflowId: z.string(),
targetBlockId: z.string(),
blockName: z.string(),
subBlockKey: z.string(),
selectorKey: z.string(),
/** Plain field title (e.g. `Label`), never a `Tool: Field` composite. */
title: z.string(),
/**
* Display name of the nested `tool-input` tool this field belongs to (e.g. `Gmail` /
* `Gmail 1`). Absent for top-level block subblocks.
*/
toolName: z.string().optional(),
/**
* The field's stored value (from the persisted mapping), so the always-on reconfigure listing
* pre-fills the selector with what the user last set. Empty string when unset; for an edge
* that predates the store the TARGET's currently-configured value is the fallback (never the
* source's, which would overwrite the target's own selection on the first sync). After a
* parent target CHANGE the modal ignores this and starts blank, since the old value no longer
* resolves against the new parent.
*/
currentValue: z.string(),
/**
* The field's raw value in the SOURCE workflow state (what the source references today),
* untouched by the stored/target-draft overlay that `currentValue` carries. Seeds the selector
* when the parent is resolved by COPY: the copy brings the source parent's children along, so
* the source reference is exactly what the copied parent will contain.
*/
sourceValue: z.string(),
/** Whether the field is required - a required empty field blocks Sync. */
required: z.boolean(),
/**
* SelectorContext key this field's own value supplies to its in-block descendants
* (e.g. a spreadsheet feeds `spreadsheetId` to the sheet selector). Lets the modal
* chain re-picks: a re-picked parent updates its children's selector context.
*/
providesContextKey: z.string().optional(),
/**
* SelectorContext keys this field needs from in-block siblings (excluding the parent
* the modal already supplies). The modal keeps the field disabled until each sibling
* that provides one of these keys has been re-picked, so a child never queries a stale
* upstream value.
*/
consumesContextKeys: z.array(z.string()),
/** Source-derived selector context (sans the parent key the modal supplies). */
context: z.record(z.string(), z.string()),
})
export type ForkDependentReconfig = z.output<typeof forkDependentReconfigSchema>
/**
* Every workflow a mapped resource (any kind) is used in, for the always-on reconfigure
* listing under each mapping entry. Joined to the entry by `(parentKind, parentSourceId)`;
* the modal cross-references {@link forkDependentReconfigSchema} per workflow to decide which
* rows are expandable (have configurable dependents) vs. greyed (used here, nothing to tune).
*/
export const forkResourceUsageSchema = z.object({
parentKind: forkRemapKindSchema,
parentSourceId: z.string(),
workflows: z.array(
z.object({
/** Deterministic fork (target) workflow id - matches a dependent's `targetWorkflowId`. */
workflowId: z.string(),
workflowName: z.string(),
})
),
})
export type ForkResourceUsage = z.output<typeof forkResourceUsageSchema>
/** Fields shared by every cleared-ref variant: the labels to phrase the "will be cleared" line. */
const forkClearedRefBaseSchema = z.object({
targetWorkflowId: z.string(),
workflowName: z.string(),
blockId: z.string(),
blockLabel: z.string(),
fieldLabel: z.string(),
sourceId: z.string(),
sourceLabel: z.string(),
})
/**
* A reference in a synced source workflow that this sync would blank in the target, with the
* labels to phrase it as "{blockLabel} will lose {fieldLabel} in workflow {workflowName}". A
* discriminated union on `cause` so clients narrow exhaustively (only `dependent` carries the parent
* fields):
* - `reference`: an unmapped remappable resource (`kind`). BLOCKS the sync until the user maps it
* OR selects it for copy (matched to a mapping entry by `${kind}:${sourceId}`); the entry drops
* off the blocker list once resolved.
* - `workflow`: a `workflow-selector`/`workflow_input` ref to a workflow not carried into the
* target. BLOCKS the sync; resolved outside the modal (deploy the referenced workflow in the
* source, or remove/fix the reference).
* - `dependent`: a create-target dependent selector a remapped parent clears. NOT a blocker (the
* reconfigure flow owns dependents). Carries the parent (`parentKind`/`parentSourceId`); when the
* child follows its parent (a document under a knowledge base) the client drops it once that
* parent is mapped/copied, else it stays (credential label / table column).
*/
export const forkClearedRefSchema = z.discriminatedUnion('cause', [
forkClearedRefBaseSchema.extend({
cause: z.literal('reference'),
/** The unmapped remappable resource (never `workflow`). */
kind: forkRemapKindSchema,
/**
* True when the referenced resource no longer exists (deleted/archived) in the SOURCE
* workspace, so it cannot be offered for copy - the resolution is mapping the dead source id
* to a live target resource, or fixing the source workflow. Collected as `false` and
* annotated post-collection by the source-liveness check (`annotateForkClearedRefSourceLiveness`).
*/
sourceDeleted: z.boolean(),
}),
forkClearedRefBaseSchema.extend({
cause: z.literal('workflow'),
kind: z.literal('workflow'),
}),
forkClearedRefBaseSchema.extend({
cause: z.literal('dependent'),
/** Mirrors `parentKind` - the parent resource the cleared dependent hangs off. */
kind: forkRemapKindSchema,
/** The dependsOn parent; the entry drops off once this parent is mapped/copied (KB-document case). */
parentKind: forkRemapKindSchema,
parentSourceId: z.string(),
}),
])
export type ForkClearedRef = z.output<typeof forkClearedRefSchema>
/**
* Why a would-clear reference blocks the sync, so clients can phrase the resolution:
* - `unmapped-copyable`: a live copyable-kind resource (table / KB / file / custom tool /
* skill / external MCP server) with no target mapping - resolve by mapping it or selecting
* it for copy.
* - `source-deleted`: the referenced resource was deleted in the source - resolve by mapping the
* dead id to an existing live target resource, or by fixing/archiving the source workflow.
* - `workflow-missing`: a cross-workflow reference to a workflow not carried into the target -
* resolve by deploying the referenced workflow in the source, or removing the reference.
*/
export const forkSyncBlockerReasonSchema = z.enum([
'unmapped-copyable',
'source-deleted',
'workflow-missing',
])
export type ForkSyncBlockerReason = z.output<typeof forkSyncBlockerReasonSchema>
/**
* One reference that blocked a promote at the server gate (the authoritative in-tx re-check of
* the would-clear set). Mirrors the cleared-ref labels so the client can phrase each blocker;
* `kind` is `workflow` for cross-workflow references. `sourceLabel` may fall back to `sourceId`
* (the gate skips display-label loading); the modal's refreshed diff carries the labeled list.
*/
export const forkSyncBlockerSchema = z.object({
workflowName: z.string(),
blockLabel: z.string(),
fieldLabel: z.string(),
kind: z.union([forkRemapKindSchema, z.literal('workflow')]),
sourceId: z.string(),
sourceLabel: z.string(),
reason: forkSyncBlockerReasonSchema,
})
export type ForkSyncBlocker = z.output<typeof forkSyncBlockerSchema>
export const getForkDiffQuerySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
})
export const getForkDiffContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/diff',
params: workspaceIdParamsSchema,
query: getForkDiffQuerySchema,
response: {
mode: 'json',
schema: z.object({
sourceWorkspaceId: z.string(),
targetWorkspaceId: z.string(),
willUpdate: z.number().int(),
willCreate: z.number().int(),
willArchive: z.number().int(),
/** Per-workflow change list for the sync preview. */
workflows: z.array(forkWorkflowChangeSchema),
unmappedRequired: z.array(forkUnmappedReferenceSchema),
unmappedOptional: z.array(forkUnmappedReferenceSchema),
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
mcpReauthServerIds: z.array(z.string()),
/** Review-only descriptions of inline secrets that cannot be id-mapped. */
inlineSecretSources: z.array(z.string()),
/** Configured selector fields per parent (credential/KB), for the pre-sync reconfigure. */
dependentReconfigs: z.array(forkDependentReconfigSchema),
/** Every workflow each mapped resource is used in, for the always-on reconfigure listing. */
resourceUsages: z.array(forkResourceUsageSchema),
/**
* Copyable resources with no target mapping that the sync can copy into the target
* (fork-style). `referenced: true` entries are referenced by the synced workflows and
* default-selected in the modal (deselecting one clears its references); `referenced: false`
* entries exist in the source but are used by no synced workflow and default-unselected
* (skipping one breaks nothing). Documents under a selected knowledge base are copied
* automatically. `parentId`/`parentLabel` carry the folder grouping for file entries
* (id + name); they are null for non-file kinds and for files at the workspace root.
*/
copyableUnmapped: z.array(
z.object({
kind: forkCopyableKindSchema,
sourceId: z.string(),
label: z.string(),
parentId: z.string().nullable(),
parentLabel: z.string().nullable(),
/** Whether any synced workflow references this resource (drives the copy default). */
referenced: z.boolean(),
})
),
/**
* References this sync will blank in the target, with labels for a pre-sync "what will be
* cleared" list. The client filters this against the current mapping/copy selection so a
* `reference` item disappears once mapped or selected for copy; `workflow`/`dependent` items
* always clear (informational).
*/
clearedRefs: z.array(forkClearedRefSchema),
}),
},
})
export type GetForkDiffResponse = z.output<typeof getForkDiffContract.response.schema>
export type ForkWorkflowChange = z.output<typeof forkWorkflowChangeSchema>
export type ForkCopyableUnmapped = GetForkDiffResponse['copyableUnmapped'][number]
/**
* A workflow whose required dependent fields a sync cleared because their parent
* (e.g. a credential) was changed - the target must re-pick them. The workflow's
* draft holds the synced state but it was NOT redeployed (the prior version keeps
* running), so nothing runs broken until the user reconfigures.
*/
export const forkNeedsConfigurationSchema = z.object({
workflowName: z.string(),
/** Names of the blocks in the workflow that need a re-check (deduplicated). */
blocks: z.array(z.string()).min(1),
})
export type ForkNeedsConfiguration = z.output<typeof forkNeedsConfigurationSchema>
/**
* Source resource ids (by kind) the user chose to copy into the target before the sync gate -
* unmapped resources, whether referenced by the synced workflows or not. Each kind's documents
* under a copied knowledge base are discovered + copied automatically (the user selects only
* the parent resources).
*/
export const promoteCopyResourcesSchema = z.object({
knowledgeBases: forkResourceIdList,
tables: forkResourceIdList,
customTools: forkResourceIdList,
skills: forkResourceIdList,
/** Workspace files to copy, identified by storage key (not `workspace_files.id`). */
files: forkResourceIdList,
/** External MCP servers to copy as config rows (OAuth tokens never copied - re-auth). */
mcpServers: forkResourceIdList,
})
export type PromoteCopyResources = z.input<typeof promoteCopyResourcesSchema>
export const promoteForkBodySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
/**
* The full stored mapping of dependent-field values; persisted to
* `workspace_fork_dependent_value` and applied to the target blocks verbatim. Omitting the
* field leaves the stored mapping untouched (the store stays the source of truth); sending
* an explicit `[]` clears it for the written replace targets.
*/
dependentValues: z.array(forkDependentValueEntrySchema).max(2000).optional(),
/** Referenced-but-unmapped resources to copy into the target before the sync gate (U17). */
copyResources: promoteCopyResourcesSchema.optional(),
})
export const promoteForkContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/fork/promote',
params: workspaceIdParamsSchema,
body: promoteForkBodySchema,
response: {
mode: 'json',
schema: z.object({
promoteRunId: z.string(),
updated: z.number().int(),
created: z.number().int(),
archived: z.number().int(),
redeployed: z.number().int(),
deployFailed: z.number().int(),
unmappedRequired: z.array(forkUnmappedReferenceSchema),
/**
* References the sync would have cleared, so it was blocked without writing (the
* authoritative in-tx gate; non-empty only when `promoteRunId` is empty). Normally the
* client blocks first - this fires only when the state changed between preview and Sync.
*/
blockers: z.array(forkSyncBlockerSchema),
/** Workflows whose required dependent fields the target must re-pick post-sync. */
needsConfiguration: z.array(forkNeedsConfigurationSchema),
/** Workflows whose optional dependent fields a swap cleared (surfaced, not gated). */
clearedOptional: z.array(forkNeedsConfigurationSchema),
}),
},
})
export type PromoteForkBody = z.input<typeof promoteForkBodySchema>
export type PromoteForkResponse = z.output<typeof promoteForkContract.response.schema>
/** Structured detail for a background job, surfaced in the Activity tab's expand row. */
export const backgroundWorkMetadataSchema = z
.object({
/** Display name of the user who performed the action (denormalized at write time). */
actorName: z.string().optional(),
// Fork content copy
childWorkspaceId: z.string().optional(),
childWorkspaceName: z.string().optional(),
workflowsCopied: z.number().int().optional(),
tables: z.number().int().optional(),
knowledgeBases: z.number().int().optional(),
files: z.number().int().optional(),
copied: z.number().int().optional(),
failed: z.number().int().optional(),
/** Count of failed resources whose dangling references were cleared post-fork (U8). */
clearedReferences: z.number().int().optional(),
/** True when a reference-clear phase threw, so cleanup is incomplete (placeholders not dropped). */
clearingFailed: z.boolean().optional(),
/** Names of the resources a fork copied, by kind, for the report breakdown. */
workflowNames: z.array(z.string()).optional(),
tableNames: z.array(z.string()).optional(),
knowledgeBaseNames: z.array(z.string()).optional(),
fileNames: z.array(z.string()).optional(),
customToolNames: z.array(z.string()).optional(),
skillNames: z.array(z.string()).optional(),
mcpServerNames: z.array(z.string()).optional(),
workflowMcpServerNames: z.array(z.string()).optional(),
// Sync / rollback
/**
* The other side of the fork edge (by id) for sync/rollback/sync-copy rows. Written so
* the activity query can surface a row to BOTH edge workspaces, and so the client can
* tell which side a row was recorded on.
*/
otherWorkspaceId: z.string().optional(),
otherWorkspaceName: z.string().optional(),
direction: z.enum(['push', 'pull']).optional(),
updated: z.number().int().optional(),
created: z.number().int().optional(),
archived: z.number().int().optional(),
redeployed: z.number().int().optional(),
deployFailed: z.number().int().optional(),
restored: z.number().int().optional(),
unarchived: z.number().int().optional(),
removed: z.number().int().optional(),
skipped: z.number().int().optional(),
/** Names of the workflows a sync changed, by action, for the report breakdown. */
updatedNames: z.array(z.string()).optional(),
createdNames: z.array(z.string()).optional(),
archivedNames: z.array(z.string()).optional(),
/** Workflows whose required dependent fields a sync left for the target to re-pick. */
needsConfiguration: z.array(forkNeedsConfigurationSchema).optional(),
/** Workflows whose optional dependent fields a sync cleared (FYI, non-blocking). */
clearedOptional: z.array(forkNeedsConfigurationSchema).optional(),
})
.nullable()
export const backgroundWorkItemSchema = z.object({
id: z.string(),
workspaceId: z.string(),
workflowId: z.string().nullable(),
kind: z.enum(['deployment_side_effects', 'fork_content_copy', 'fork_sync', 'fork_rollback']),
status: z.enum(['pending', 'processing', 'completed', 'completed_with_warnings', 'failed']),
message: z.string().nullable(),
error: z.string().nullable(),
metadata: backgroundWorkMetadataSchema,
startedAt: z.string(),
completedAt: z.string().nullable(),
})
export type BackgroundWorkMetadata = z.output<typeof backgroundWorkMetadataSchema>
/** Keyset pagination inputs, mirroring the audit log's (`auditLogsQuerySchema`). */
export const getWorkspaceBackgroundWorkQuerySchema = z.object({
/** Opaque cursor from a prior page's `nextCursor`; omit for the first page. */
cursor: z.string().optional(),
limit: z
.string()
.optional()
.transform((value) => Math.min(Math.max(Number(value) || 50, 1), 100)),
})
export const getWorkspaceBackgroundWorkContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/background-work',
params: workspaceIdParamsSchema,
query: getWorkspaceBackgroundWorkQuerySchema,
response: {
mode: 'json',
schema: z.object({
items: z.array(backgroundWorkItemSchema),
/** Opaque keyset cursor for the next page; null when this page is the last. */
nextCursor: z.string().nullable(),
}),
},
})
export type BackgroundWorkItem = z.output<typeof backgroundWorkItemSchema>
export type GetWorkspaceBackgroundWorkResponse = z.output<
typeof getWorkspaceBackgroundWorkContract.response.schema
>
export const rollbackForkBodySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
})
export const rollbackForkContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/fork/rollback',
params: workspaceIdParamsSchema,
body: rollbackForkBodySchema,
response: {
mode: 'json',
schema: z.object({
restored: z.number().int(),
archived: z.number().int(),
unarchived: z.number().int(),
/** Snapshot workflows that no longer exist and couldn't be reactivated. */
skipped: z.number().int(),
}),
},
})
export type RollbackForkBody = z.input<typeof rollbackForkBodySchema>
export type RollbackForkResponse = z.output<typeof rollbackForkContract.response.schema>
export const getForkAvailabilityContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/availability',
params: workspaceIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
/** Server-evaluated verdict of the fork gate: env/plan + AppConfig rollout flag. */
available: z.boolean(),
}),
},
})
export type GetForkAvailabilityResponse = z.output<
typeof getForkAvailabilityContract.response.schema
>
export const unlinkForkBodySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
})
export const unlinkForkContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/fork/unlink',
params: workspaceIdParamsSchema,
body: unlinkForkBodySchema,
response: {
mode: 'json',
schema: z.object({
/** False when the edge was already dissolved by a concurrent unlink (idempotent no-op). */
unlinked: z.boolean(),
}),
},
})
export type UnlinkForkBody = z.input<typeof unlinkForkBodySchema>
export type UnlinkForkResponse = z.output<typeof unlinkForkContract.response.schema>