Files
simstudioai--sim/apps/sim/tools/temporal/utils.ts
T
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

278 lines
9.6 KiB
TypeScript

import { truncate } from '@sim/utils/string'
/**
* Identity reported to the Temporal server on write operations so they are
* attributable in workflow histories.
*/
export const TEMPORAL_CLIENT_IDENTITY = 'sim'
const JSON_PLAIN_ENCODING = Buffer.from('json/plain').toString('base64')
/** A Temporal `common.v1.Payload` as serialized by the HTTP API (base64 fields). */
export interface TemporalPayload {
metadata?: Record<string, string>
data?: string
}
/** A Temporal `common.v1.Payloads` collection. */
export interface TemporalPayloads {
payloads?: TemporalPayload[]
}
/** Raw `common.v1.WorkflowExecution` shape returned by the HTTP API. */
export interface TemporalRawExecution {
workflowId?: string
runId?: string
}
/** Raw `workflow.v1.WorkflowExecutionInfo` shape returned by describe/list responses. */
export interface TemporalRawExecutionInfo {
execution?: TemporalRawExecution
type?: { name?: string }
status?: string
startTime?: string
closeTime?: string
executionTime?: string
historyLength?: string
taskQueue?: string
}
/** Raw `history.v1.HistoryEvent` shape returned by the workflow history endpoint. */
export interface TemporalRawHistoryEvent {
eventId?: string
eventTime?: string
eventType?: string
[key: string]: unknown
}
/**
* Builds the `/api/v1/namespaces/{namespace}` base URL for a Temporal server's HTTP API,
* tolerating surrounding whitespace and trailing slashes on the server URL
* (e.g. `http://localhost:7243/` → `http://localhost:7243/api/v1/namespaces/default`).
*/
export function temporalNamespaceUrl(serverUrl: string, namespace: string): string {
const base = serverUrl.trim().replace(/\/+$/, '')
return `${base}/api/v1/namespaces/${encodeURIComponent(namespace.trim())}`
}
/**
* Builds the `/workflows/{workflowId}` URL for a workflow execution, trimming and
* URL-encoding the workflow ID.
*/
export function temporalWorkflowUrl(
serverUrl: string,
namespace: string,
workflowId: string
): string {
return `${temporalNamespaceUrl(serverUrl, namespace)}/workflows/${encodeURIComponent(workflowId.trim())}`
}
/**
* Builds the `/schedules/{scheduleId}` URL for a schedule, trimming and URL-encoding
* the schedule ID.
*/
export function temporalScheduleUrl(
serverUrl: string,
namespace: string,
scheduleId: string
): string {
return `${temporalNamespaceUrl(serverUrl, namespace)}/schedules/${encodeURIComponent(scheduleId.trim())}`
}
/**
* Builds a `common.v1.WorkflowExecution` reference with trimmed IDs, omitting the run ID
* when not provided so the server targets the latest run.
*/
export function workflowExecutionRef(workflowId: string, runId?: string): Record<string, string> {
const ref: Record<string, string> = { workflowId: workflowId.trim() }
if (runId?.trim()) ref.runId = runId.trim()
return ref
}
/**
* Builds the request headers for a Temporal HTTP API call, attaching the API key as a
* Bearer token when one is provided (omitted for servers without authentication).
*
* The Accept header opts out of the server's payload "shorthand" JSON form so responses
* always carry full `{metadata, data}` payload objects that {@link decodePayload} understands.
*/
export function temporalRequestHeaders(params: { apiKey?: string }): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json+no-payload-shorthand',
}
if (params.apiKey) headers.Authorization = `Bearer ${params.apiKey.trim()}`
return headers
}
/** Encodes a single JSON value as a Temporal `json/plain` payload. */
export function encodePayload(value: unknown): TemporalPayload {
return {
metadata: { encoding: JSON_PLAIN_ENCODING },
data: Buffer.from(JSON.stringify(value)).toString('base64'),
}
}
/**
* Normalizes a JSON field value: strings are parsed as JSON, already-resolved objects
* and arrays are used as-is, and empty input returns undefined so the field is omitted.
*/
function parseJsonValue(value: unknown, fieldName: string): unknown {
if (value == null) return undefined
if (typeof value === 'string') {
if (!value.trim()) return undefined
try {
return JSON.parse(value)
} catch {
throw new Error(`Invalid JSON in ${fieldName}`)
}
}
return value
}
/**
* Parses a JSON value into Temporal `Payloads`. A top-level array is treated as the
* argument list (one payload per element); any other value becomes a single argument.
* Returns undefined for empty input so optional payload fields can be omitted entirely.
*/
export function parseJsonArgs(value: unknown, fieldName: string): TemporalPayloads | undefined {
const parsed = parseJsonValue(value, fieldName)
if (parsed === undefined) return undefined
const args = Array.isArray(parsed) ? parsed : [parsed]
return { payloads: args.map(encodePayload) }
}
/**
* Parses a JSON object value into a `map<string, Payload>` (memo fields or search
* attribute indexed fields). Returns undefined for empty input.
*/
export function parseJsonPayloadMap(
value: unknown,
fieldName: string
): Record<string, TemporalPayload> | undefined {
const parsed = parseJsonValue(value, fieldName)
if (parsed === undefined) return undefined
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${fieldName} must be a JSON object`)
}
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => [
key,
encodePayload(value),
])
)
}
/**
* Decodes a single Temporal payload: `json/plain` and `json/protobuf` payloads are parsed
* to their JSON value, `binary/null` becomes null, and unknown encodings are returned as
* the original base64 data string.
*/
export function decodePayload(payload: TemporalPayload | undefined): unknown {
if (!payload) return null
const encoding = payload.metadata?.encoding
? Buffer.from(payload.metadata.encoding, 'base64').toString('utf8')
: undefined
if (encoding === 'binary/null') return null
if (payload.data == null) return null
if (encoding === 'json/plain' || encoding === 'json/protobuf') {
const raw = Buffer.from(payload.data, 'base64').toString('utf8')
try {
return JSON.parse(raw)
} catch {
return raw
}
}
return payload.data
}
/** Decodes a Temporal `Payloads` collection into an array of JSON values. */
export function decodePayloads(payloads: TemporalPayloads | undefined): unknown[] {
return (payloads?.payloads ?? []).map(decodePayload)
}
/** Decodes a `map<string, Payload>` (memo / search attributes) into a plain object. */
export function decodePayloadMap(
fields: Record<string, TemporalPayload> | undefined
): Record<string, unknown> | null {
if (!fields) return null
return Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, decodePayload(value)])
)
}
/** Strips a protobuf enum prefix (e.g. `WORKFLOW_EXECUTION_STATUS_RUNNING` → `RUNNING`). */
export function stripEnumPrefix(value: string | undefined, prefix: string): string | null {
if (!value) return null
return value.startsWith(prefix) ? value.slice(prefix.length) : value
}
/**
* Formats a seconds count as a protobuf JSON duration string (e.g. 3600 → `"3600s"`).
* Returns undefined for missing, non-numeric, or non-positive values so the field is omitted.
*/
export function toDurationString(seconds: number | string | undefined): string | undefined {
if (seconds == null || seconds === '') return undefined
const parsed = Number(seconds)
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
return `${parsed}s`
}
/**
* Maps a raw `WorkflowExecutionInfo` to the flat execution summary shared by the
* describe and list tools. int64 fields arrive as JSON strings and are coerced to numbers.
*/
export function mapExecutionInfo(info: TemporalRawExecutionInfo | undefined) {
return {
workflowId: info?.execution?.workflowId ?? null,
runId: info?.execution?.runId ?? null,
workflowType: info?.type?.name ?? null,
status: stripEnumPrefix(info?.status, 'WORKFLOW_EXECUTION_STATUS_'),
startTime: info?.startTime ?? null,
closeTime: info?.closeTime ?? null,
executionTime: info?.executionTime ?? null,
historyLength: info?.historyLength != null ? Number(info.historyLength) : null,
taskQueue: info?.taskQueue ?? null,
}
}
/**
* Maps a raw history event to a flat shape, extracting the event's `*EventAttributes`
* object (each event carries exactly one, keyed by its type).
*/
export function mapHistoryEvent(event: TemporalRawHistoryEvent) {
const attributesKey = Object.keys(event).find((key) => key.endsWith('EventAttributes'))
return {
eventId: event.eventId != null ? Number(event.eventId) : null,
eventTime: event.eventTime ?? null,
eventType: stripEnumPrefix(event.eventType, 'EVENT_TYPE_'),
attributes: attributesKey ? ((event[attributesKey] as Record<string, unknown>) ?? null) : null,
}
}
/**
* Parses a Temporal HTTP API response body and throws a descriptive error for non-2xx
* replies. grpc-gateway errors carry a top-level `message` field; empty bodies (returned
* by signal/cancel/terminate) parse to an empty object.
*/
export async function parseTemporalResponse<T extends object>(
response: Response,
operation: string
): Promise<T> {
const text = await response.text()
let data: Record<string, unknown> = {}
if (text) {
try {
data = JSON.parse(text) as Record<string, unknown>
} catch {
data = { message: truncate(text, 300) }
}
}
if (!response.ok) {
const message =
typeof data.message === 'string' && data.message ? data.message : `HTTP ${response.status}`
throw new Error(`Temporal ${operation} failed: ${message}`)
}
return data as T
}