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

320 lines
8.3 KiB
TypeScript

import type { NormalizedBlockOutput } from '@/executor/types'
import type { ConsoleEntry } from './types'
/**
* Terminal console safety limits used to bound persisted debug data.
*/
export const TERMINAL_CONSOLE_LIMITS = {
MAX_ENTRIES_PER_WORKFLOW: 5000,
MAX_STRING_LENGTH: 50_000,
MAX_OBJECT_KEYS: 100,
MAX_ARRAY_ITEMS: 100,
MAX_DEPTH: 12,
MAX_SERIALIZED_BYTES: 256 * 1024,
MAX_SERIALIZED_PREVIEW_LENGTH: 10_000,
} as const
const textEncoder = new TextEncoder()
/**
* Returns the UTF-8 byte length of a string.
*/
function getByteLength(value: string): number {
return textEncoder.encode(value).length
}
/**
* Truncates a string while preserving a short explanation.
*/
function truncateString(
value: string,
maxLength: number = TERMINAL_CONSOLE_LIMITS.MAX_STRING_LENGTH
): string {
if (value.length <= maxLength) {
return value
}
return `${value.slice(0, maxLength)}... [truncated ${value.length - maxLength} chars]`
}
/**
* Safely stringifies terminal data without throwing on circular or non-JSON-safe values.
*/
export function safeConsoleStringify(value: unknown): string {
const seen = new WeakSet<object>()
try {
return (
JSON.stringify(
value,
(_key, currentValue) => {
if (typeof currentValue === 'bigint') {
return `${currentValue.toString()}n`
}
if (currentValue instanceof Error) {
return {
name: currentValue.name,
message: currentValue.message,
stack: currentValue.stack,
}
}
if (typeof currentValue === 'function') {
return `[Function ${currentValue.name || 'anonymous'}]`
}
if (typeof currentValue === 'symbol') {
return currentValue.toString()
}
if (typeof currentValue === 'object' && currentValue !== null) {
if (seen.has(currentValue)) {
return '[Circular]'
}
seen.add(currentValue)
}
return currentValue
},
2
) ?? ''
)
} catch {
try {
return String(value)
} catch {
return '[Unserializable value]'
}
}
}
/**
* Produces a terminal-safe representation of any value.
*
* Recursion is bounded by two independent guards: `seen` tracks the current
* ancestor chain so true circular references resolve to `[Circular]` (a value
* reused across sibling positions is not a cycle and renders fully), and
* `MAX_DEPTH` is a pathological-nesting backstop. Actual payload size is bounded
* downstream by `truncateString` and `capNormalizedValue`, not by depth.
*/
export function normalizeConsoleValue(
value: unknown,
depth = 0,
seen: WeakSet<object> = new WeakSet()
): unknown {
if (value === null || value === undefined) {
return value
}
if (typeof value === 'string') {
return truncateString(value)
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value
}
if (typeof value === 'bigint') {
return `${value.toString()}n`
}
if (typeof value === 'function') {
return `[Function ${value.name || 'anonymous'}]`
}
if (typeof value === 'symbol') {
return value.toString()
}
if (value instanceof Error) {
return {
name: value.name,
message: truncateString(value.message),
stack: value.stack ? truncateString(value.stack) : undefined,
}
}
if (depth >= TERMINAL_CONSOLE_LIMITS.MAX_DEPTH) {
return `[Truncated ${Array.isArray(value) ? 'array' : 'object'}]`
}
const objectValue = value as object
if (seen.has(objectValue)) {
return '[Circular]'
}
seen.add(objectValue)
try {
if (Array.isArray(value)) {
const normalizedItems = value
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
.map((item) => normalizeConsoleValue(item, depth + 1, seen))
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
normalizedItems.push(
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
)
}
return normalizedItems
}
const objectEntries = Object.entries(value as Record<string, unknown>)
const normalizedObject: Record<string, unknown> = {}
for (const [key, entryValue] of objectEntries.slice(
0,
TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
)) {
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1, seen)
}
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
normalizedObject.__simTruncatedKeys =
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
}
return normalizedObject
} finally {
seen.delete(objectValue)
}
}
/**
* Applies a final serialized-size cap after recursive normalization.
*/
function capNormalizedValue(value: unknown): unknown {
if (value === null || value === undefined) {
return value
}
const serialized = safeConsoleStringify(value)
const serializedBytes = getByteLength(serialized)
if (serializedBytes <= TERMINAL_CONSOLE_LIMITS.MAX_SERIALIZED_BYTES) {
return value
}
return {
__simTruncated: true,
__simByteLength: serializedBytes,
__simPreview: truncateString(serialized, TERMINAL_CONSOLE_LIMITS.MAX_SERIALIZED_PREVIEW_LENGTH),
}
}
/**
* Normalizes terminal input data before it is stored.
*/
export function normalizeConsoleInput(input: unknown): unknown {
return capNormalizedValue(normalizeConsoleValue(input))
}
/**
* Normalizes terminal output data before it is stored.
*/
export function normalizeConsoleOutput(output: unknown): NormalizedBlockOutput | undefined {
if (output === undefined) {
return undefined
}
return capNormalizedValue(normalizeConsoleValue(output)) as NormalizedBlockOutput
}
/**
* Normalizes terminal error data before it is stored.
*/
export function normalizeConsoleError(error: unknown): string | null | undefined {
if (error === undefined) {
return undefined
}
if (error === null) {
return null
}
return truncateString(
typeof error === 'string' ? error : safeConsoleStringify(normalizeConsoleValue(error))
)
}
/**
* Returns a workflow's entries trimmed to the configured cap.
*/
export function trimWorkflowConsoleEntries(entries: ConsoleEntry[]): ConsoleEntry[] {
if (entries.length <= TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW) {
return entries
}
const executionGroups = new Map<string, ConsoleEntry[]>()
for (const entry of entries) {
const executionId = entry.executionId ?? entry.id
const group = executionGroups.get(executionId)
if (group) {
group.push(entry)
} else {
executionGroups.set(executionId, [entry])
}
}
const executionIds = [...executionGroups.keys()]
const newestExecutionId = executionIds[0]
if (!newestExecutionId) {
return entries.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW)
}
const keptEntryIds = new Set<string>()
let remainingSlots = TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW
const newestExecutionEntries = executionGroups.get(newestExecutionId) ?? []
const newestExecutionToKeep = newestExecutionEntries.slice(0, remainingSlots)
newestExecutionToKeep.forEach((entry) => keptEntryIds.add(entry.id))
remainingSlots -= newestExecutionToKeep.length
for (const executionId of executionIds.slice(1)) {
const executionEntries = executionGroups.get(executionId) ?? []
if (executionEntries.length > remainingSlots) {
continue
}
executionEntries.forEach((entry) => keptEntryIds.add(entry.id))
remainingSlots -= executionEntries.length
if (remainingSlots === 0) {
break
}
}
return entries.filter((entry) => keptEntryIds.has(entry.id))
}
/**
* Applies workflow-level trimming while preserving newest-first order.
*/
export function trimConsoleEntries(entries: ConsoleEntry[]): ConsoleEntry[] {
const workflowGroups = new Map<string, ConsoleEntry[]>()
for (const entry of entries) {
const workflowEntries = workflowGroups.get(entry.workflowId)
if (workflowEntries) {
workflowEntries.push(entry)
} else {
workflowGroups.set(entry.workflowId, [entry])
}
}
const keptEntryIds = new Set<string>()
for (const workflowEntries of workflowGroups.values()) {
trimWorkflowConsoleEntries(workflowEntries).forEach((entry) => keptEntryIds.add(entry.id))
}
return entries.filter((entry) => keptEntryIds.has(entry.id))
}