Files
simstudioai--sim/apps/sim/lib/copilot/vfs/serializers.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

964 lines
26 KiB
TypeScript

import { truncate } from '@sim/utils/string'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import { isHosted } from '@/lib/core/config/env-flags'
import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models'
import type { ToolConfig } from '@/tools/types'
/**
* Serialize workflow metadata for VFS meta.json.
*
* `locked` is the EFFECTIVE lock — true when the workflow is locked directly or
* sits inside a locked folder. A locked workflow cannot be edited, moved,
* renamed, or deleted (mutations are rejected server-side with a 423). The
* mothership should read this before attempting any workflow mutation.
* `inheritedFolderLock` carries the resolved containing-folder lock (the
* caller computes folder inheritance; see workspace-vfs materializeWorkflows).
*/
export function serializeWorkflowMeta(
wf: {
id: string
name: string
description?: string | null
folderId?: string | null
isDeployed: boolean
deployedAt?: Date | null
runCount: number
lastRunAt?: Date | null
createdAt: Date
updatedAt: Date
locked?: boolean
},
options?: { inheritedFolderLock?: boolean }
): string {
const directLock = wf.locked ?? false
const locked = directLock || (options?.inheritedFolderLock ?? false)
return JSON.stringify(
{
id: wf.id,
name: wf.name,
description: wf.description || undefined,
folderId: wf.folderId || undefined,
locked,
lockedBy: locked ? (directLock ? 'workflow' : 'folder') : undefined,
isDeployed: wf.isDeployed,
deployedAt: wf.deployedAt?.toISOString(),
runCount: wf.runCount,
lastRunAt: wf.lastRunAt?.toISOString(),
createdAt: wf.createdAt.toISOString(),
updatedAt: wf.updatedAt.toISOString(),
},
null,
2
)
}
/**
* Serialize execution logs for VFS executions.json.
* Takes recent execution log rows and produces a summary.
*/
export function serializeRecentExecutions(
executions: Array<{
id: string
executionId: string
status: string
trigger: string
startedAt: Date
endedAt?: Date | null
totalDurationMs?: number | null
}>
): string {
return JSON.stringify(
executions.map((e) => ({
executionId: e.executionId,
status: e.status,
trigger: e.trigger,
startedAt: e.startedAt.toISOString(),
endedAt: e.endedAt?.toISOString(),
durationMs: e.totalDurationMs,
})),
null,
2
)
}
/**
* Serialize knowledge base metadata for VFS meta.json
*/
export function serializeKBMeta(kb: {
id: string
name: string
description?: string | null
embeddingModel: string
embeddingDimension: number
tokenCount: number
createdAt: Date
updatedAt: Date
documentCount: number
connectorTypes?: string[]
}): string {
return JSON.stringify(
{
id: kb.id,
name: kb.name,
description: kb.description || undefined,
embeddingModel: kb.embeddingModel,
embeddingDimension: kb.embeddingDimension,
tokenCount: kb.tokenCount,
documentCount: kb.documentCount,
connectorTypes:
kb.connectorTypes && kb.connectorTypes.length > 0 ? kb.connectorTypes : undefined,
createdAt: kb.createdAt.toISOString(),
updatedAt: kb.updatedAt.toISOString(),
},
null,
2
)
}
/**
* Serialize documents list for VFS documents.json (metadata only, no content)
*/
export function serializeDocuments(
docs: Array<{
id: string
filename: string
fileSize: number
mimeType: string
chunkCount: number
tokenCount: number
processingStatus: string
enabled: boolean
uploadedAt: Date
}>
): string {
return JSON.stringify(
docs.map((d) => ({
id: d.id,
filename: d.filename,
fileSize: d.fileSize,
mimeType: d.mimeType,
chunkCount: d.chunkCount,
tokenCount: d.tokenCount,
processingStatus: d.processingStatus,
enabled: d.enabled,
uploadedAt: d.uploadedAt.toISOString(),
})),
null,
2
)
}
/**
* Serialize KB connectors for VFS knowledgebases/{name}/connectors.json.
* Shows connector type, sync status, and schedule — NOT credentials or source config.
*/
export function serializeConnectors(
connectors: Array<{
id: string
connectorType: string
status: string
syncMode: string
syncIntervalMinutes: number
lastSyncAt: Date | null
lastSyncError: string | null
lastSyncDocCount: number | null
nextSyncAt: Date | null
consecutiveFailures: number
createdAt: Date
}>
): string {
return JSON.stringify(
connectors.map((c) => ({
id: c.id,
connectorType: c.connectorType,
status: c.status,
syncMode: c.syncMode,
syncIntervalMinutes: c.syncIntervalMinutes,
lastSyncAt: c.lastSyncAt?.toISOString(),
lastSyncError: c.lastSyncError || undefined,
lastSyncDocCount: c.lastSyncDocCount ?? undefined,
nextSyncAt: c.nextSyncAt?.toISOString(),
consecutiveFailures: c.consecutiveFailures,
createdAt: c.createdAt.toISOString(),
})),
null,
2
)
}
/**
* Connector config field shape (mirrors ConnectorConfigField from connectors/types.ts
* but avoids importing React-dependent code into serializers).
*/
interface SerializableConfigField {
id: string
title: string
type: string
placeholder?: string
required?: boolean
description?: string
options?: Array<{ label: string; id: string }>
}
interface SerializableTagDef {
id: string
displayName: string
fieldType: string
}
interface SerializableConnectorConfig {
id: string
name: string
description: string
version: string
auth: { mode: string; provider?: string; requiredScopes?: string[] }
configFields: SerializableConfigField[]
tagDefinitions?: SerializableTagDef[]
supportsIncrementalSync?: boolean
}
/**
* Serialize a single connector type's schema for VFS knowledgebases/connectors/{type}.json.
* Contains everything the LLM needs to build a valid sourceConfig.
*/
export function serializeConnectorSchema(connector: SerializableConnectorConfig): string {
return JSON.stringify(
{
id: connector.id,
name: connector.name,
description: connector.description,
version: connector.version,
auth: connector.auth,
configFields: connector.configFields.map((f) => {
const field: Record<string, unknown> = {
id: f.id,
title: f.title,
type: f.type,
}
if (f.required) field.required = true
if (f.placeholder) field.placeholder = f.placeholder
if (f.description) field.description = f.description
if (f.options) field.options = f.options
return field
}),
tagDefinitions: connector.tagDefinitions ?? [],
supportsIncrementalSync: connector.supportsIncrementalSync ?? false,
},
null,
2
)
}
/**
* Generate the knowledgebases/connectors/connectors.md overview file.
* Lists all available connector types with their OAuth providers — enough
* for the LLM to identify the right type and credential, then read the
* per-connector schema file for full config details.
*/
export function serializeConnectorOverview(connectors: SerializableConnectorConfig[]): string {
const rows = connectors.map((c) => {
const provider = c.auth.provider ?? c.auth.mode
const scopes = c.auth.requiredScopes?.length ? c.auth.requiredScopes.join(', ') : '(none)'
return `| ${c.id} | ${c.name} | ${provider} | ${scopes} |`
})
return [
'# Available KB Connectors',
'',
'Use `read("knowledgebases/connectors/{type}.json")` to get the full config schema before calling `add_connector`.',
'',
'| Type | Name | OAuth Provider | Required Scopes |',
'|------|------|---------------|-----------------|',
...rows,
'',
'To add a connector, the user must have an OAuth credential for that provider.',
'Check `environment/credentials.json` for available credential IDs.',
].join('\n')
}
/**
* Serialize workspace file metadata for VFS files/{path}/{name}/meta.json.
*/
export function serializeFileMeta(file: {
id: string
name: string
folderId?: string | null
folderPath?: string | null
vfsPath?: string
contentType: string
size: number
uploadedAt: Date
}): string {
return JSON.stringify(
{
id: file.id,
name: file.name,
folderId: file.folderId || undefined,
folderPath: file.folderPath || undefined,
vfsPath: file.vfsPath,
contentType: file.contentType,
size: file.size,
uploadedAt: file.uploadedAt.toISOString(),
readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined,
note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).',
},
null,
2
)
}
/**
* Serialize table metadata for VFS tables/{name}/meta.json
*/
export function serializeTableMeta(table: {
id: string
name: string
description?: string | null
schema: unknown
rowCount: number
maxRows: number
createdAt: Date | string
updatedAt: Date | string
}): string {
return JSON.stringify(
{
id: table.id,
name: table.name,
description: table.description || undefined,
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() : table.createdAt,
updatedAt: table.updatedAt instanceof Date ? table.updatedAt.toISOString() : table.updatedAt,
},
null,
2
)
}
/**
* Returns the static model list from PROVIDER_DEFINITIONS for VFS serialization.
* Excludes dynamic providers (ollama, vllm, openrouter) whose models are user-configured.
* Includes provider ID and whether the model is hosted by Sim (no API key required).
*/
interface StaticModelOption {
id: string
provider: string
hosted: boolean
recommended?: boolean
speedOptimized?: boolean
deprecated?: boolean
}
const DYNAMIC_PROVIDERS_NOTE = {
note: 'The options array above lists Sim\'s static provider catalog. These providers also accept user-configured models that are NOT enumerated here: the user may have additional ids available at runtime (e.g. local Ollama tags). To reference one, prefix the model id with the provider slash below — for example "ollama/llama3.1:8b" instead of the bare "llama3.1:8b". The server rejects bare ids that are not in the catalog; always use the prefix for user-configured models.',
prefixes: DYNAMIC_MODEL_PROVIDERS.map((p) => `${p}/`),
} as const
function getStaticModelOptionsForVFS(): StaticModelOption[] {
const hostedProviders = new Set(['openai', 'anthropic', 'google'])
const dynamicProviders = new Set<string>(DYNAMIC_MODEL_PROVIDERS)
const models: StaticModelOption[] = []
for (const [providerId, def] of Object.entries(PROVIDER_DEFINITIONS)) {
if (dynamicProviders.has(providerId)) continue
for (const model of def.models) {
const option: StaticModelOption = {
id: model.id,
provider: providerId,
hosted: hostedProviders.has(providerId),
}
if (model.recommended) option.recommended = true
if (model.speedOptimized) option.speedOptimized = true
if (model.deprecated) option.deprecated = true
models.push(option)
}
}
return models
}
/**
* Serialize a SubBlockConfig for the VFS component schema.
* Strips functions and UI-only fields. Includes static options arrays.
*/
function serializeSubBlock(sb: SubBlockConfig): Record<string, unknown> {
const result: Record<string, unknown> = {
id: sb.id,
type: sb.type,
}
if (sb.title) result.title = sb.title
if (sb.required === true) result.required = true
if (sb.defaultValue !== undefined) result.defaultValue = sb.defaultValue
if (sb.mode) result.mode = sb.mode
if (sb.canonicalParamId) result.canonicalParamId = sb.canonicalParamId
// Include static options arrays for dropdowns
if (Array.isArray(sb.options)) {
result.options = sb.options
}
return result
}
/**
* Serialize a block schema for VFS components/blocks/{type}.json
*/
export function serializeBlockSchema(block: BlockConfig): string {
// Custom blocks bake their `workflowId`/`inputMapping` as `hidden` sub-blocks;
// treat `hidden` as hidden for them so those never reach the agent's schema.
const customBlock = isCustomBlockType(block.type)
const hiddenIds = new Set(
block.subBlocks
.filter((sb) => isSubBlockHidden(sb) || (customBlock && sb.hidden))
.map((sb) => sb.id)
)
const subBlocks = block.subBlocks
.filter((sb) => !hiddenIds.has(sb.id))
.map((sb) => {
const serialized = serializeSubBlock(sb)
if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') {
serialized.options = getStaticModelOptionsForVFS()
serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE
}
return serialized
})
const inputs =
block.inputs && hiddenIds.size > 0
? Object.fromEntries(Object.entries(block.inputs).filter(([key]) => !hiddenIds.has(key)))
: block.inputs
return JSON.stringify(
{
type: block.type,
name: block.name,
description: block.description,
category: block.category,
longDescription: block.longDescription || undefined,
bestPractices: block.bestPractices || undefined,
triggerAllowed: block.triggerAllowed || undefined,
singleInstance: block.singleInstance || undefined,
// Custom (deploy-as-block) blocks execute via a baked `workflow_executor`
// internally; that's implementation plumbing, not something the agent
// configures. Hiding it keeps the block self-contained (fields in, outputs
// out) so the agent doesn't treat it like the generic workflow block and
// ask for a workflowId/inputMapping.
tools: isCustomBlockType(block.type) ? [] : block.tools.access,
subBlocks,
inputs,
outputs: Object.fromEntries(
Object.entries(block.outputs)
.filter(([key, val]) => key !== 'visualization' && val != null)
.map(([key, val]) => [
key,
typeof val === 'string'
? { type: val }
: { type: val.type, description: (val as { description?: string }).description },
])
),
},
null,
2
)
}
/**
* Serialize OAuth credentials for VFS environment/credentials.json.
* Shows which integrations are connected — IDs, roles, and scopes, NOT tokens.
*/
export function serializeCredentials(
accounts: Array<{
id?: string
providerId: string
displayName?: string | null
role?: string | null
scope: string | null
createdAt: Date
}>
): string {
return JSON.stringify(
accounts.map((a) => ({
id: a.id || undefined,
provider: a.providerId,
displayName: a.displayName || undefined,
role: a.role || undefined,
scope: a.scope || undefined,
connectedAt: a.createdAt.toISOString(),
})),
null,
2
)
}
/**
* Serialize API keys for VFS environment/api-keys.json.
* Shows key names and types — NOT the actual key values.
*/
export function serializeApiKeys(
keys: Array<{
id: string
name: string
type: string
lastUsed: Date | null
createdAt: Date
expiresAt: Date | null
}>
): string {
return JSON.stringify(
keys.map((k) => ({
id: k.id,
name: k.name,
type: k.type,
lastUsed: k.lastUsed?.toISOString(),
createdAt: k.createdAt.toISOString(),
expiresAt: k.expiresAt?.toISOString(),
})),
null,
2
)
}
/**
* Serialize environment variables for VFS environment/variables.json.
* Shows variable NAMES only — NOT values.
*/
export function serializeEnvironmentVariables(
personalVarNames: string[],
workspaceVarNames: string[]
): string {
return JSON.stringify(
{
personal: personalVarNames,
workspace: workspaceVarNames,
},
null,
2
)
}
/** Input types for deployment serialization. */
export interface DeploymentData {
workflowId: string
isDeployed: boolean
deployedAt?: Date | null
needsRedeployment?: boolean
api?: {
version: number
createdAt: Date
} | null
chat?: {
id: string
identifier: string
title: string
description?: string | null
authType: string
customizations: unknown
isActive: boolean
} | null
mcp: Array<{
serverId: string
serverName: string
toolId: string
toolName: string
toolDescription?: string | null
}>
versions?: Array<{
id: string
version: number
name: string | null
description: string | null
isActive: boolean
createdAt: Date
}>
}
/**
* Serialize all deployment configurations for VFS deployment.json.
* Only includes keys for active deployment types.
*/
export function serializeDeployments(data: DeploymentData): string {
const result: Record<string, unknown> = {}
if (data.needsRedeployment !== undefined) {
result.needsRedeployment = data.needsRedeployment
}
if (data.isDeployed) {
result.api = {
isDeployed: true,
deployedAt: data.deployedAt?.toISOString(),
apiEndpoint: `/api/workflows/${data.workflowId}/execute`,
...(data.api ? { version: data.api.version } : {}),
}
}
if (data.chat) {
result.chat = {
id: data.chat.id,
identifier: data.chat.identifier,
chatUrl: `/chat/${data.chat.identifier}`,
title: data.chat.title,
description: data.chat.description || undefined,
authType: data.chat.authType,
customizations: data.chat.customizations,
isActive: data.chat.isActive,
}
}
if (data.mcp.length > 0) {
result.mcp = data.mcp.map((m) => ({
serverId: m.serverId,
serverName: m.serverName,
toolId: m.toolId,
toolName: m.toolName,
toolDescription: m.toolDescription || undefined,
}))
}
return JSON.stringify(result, null, 2)
}
/**
* Serialize deployment version history for VFS workflows/{name}/versions.json.
* Lists all versions without full state — use the diff_workflows tool to compare a version,
* or load_deployment to restore one into the draft.
*/
export function serializeVersions(
versions: Array<{
id: string
version: number
name: string | null
description: string | null
isActive: boolean
createdAt: Date
}>
): string {
return JSON.stringify(
versions.map((v) => ({
id: v.id,
version: v.version,
name: v.name || undefined,
description: v.description || undefined,
isActive: v.isActive,
createdAt: v.createdAt.toISOString(),
})),
null,
2
)
}
/**
* Serialize a custom tool for VFS custom-tools/{name}.json
*/
export function serializeCustomTool(tool: {
id: string
title: string
schema: unknown
code: string
}): string {
return JSON.stringify(
{
id: tool.id,
title: tool.title,
schema: tool.schema,
codePreview: truncate(tool.code, 500),
},
null,
2
)
}
/**
* Serialize an MCP server for VFS agent/mcp-servers/{name}.json
*/
export function serializeMcpServer(server: {
id: string
name: string
url: string | null
transport: string | null
enabled: boolean
connectionStatus: string | null
}): string {
return JSON.stringify(
{
id: server.id,
name: server.name,
url: server.url,
transport: server.transport,
enabled: server.enabled,
connectionStatus: server.connectionStatus,
},
null,
2
)
}
/**
* Serialize a skill for VFS agent/skills/{name}.json
*/
export function serializeSkill(s: {
id: string
name: string
description: string
content: string
createdAt: Date
}): string {
return JSON.stringify(
{
id: s.id,
name: s.name,
description: s.description,
contentPreview: truncate(s.content, 500),
createdAt: s.createdAt.toISOString(),
},
null,
2
)
}
/**
* Serialize an integration/tool schema for VFS components/integrations/{service}/{operation}.json
*/
export function serializeIntegrationSchema(tool: ToolConfig): string {
const hostedApiKeyParam = isHosted && tool.hosting ? tool.hosting.apiKeyParam : null
return JSON.stringify(
{
// The full registry id is the agent-callable id (deferred tools are sent
// with this exact id; no stripping). Surface it verbatim so "copy the id
// field and load it" matches the callable tool and the block's tools.access.
id: tool.id,
name: tool.name,
description: getCopilotToolDescription(tool, { isHosted }),
version: tool.version,
oauth: tool.oauth
? { required: tool.oauth.required, provider: tool.oauth.provider }
: undefined,
params: tool.params
? {
...Object.fromEntries(
Object.entries(tool.params)
.filter(([key, val]) => val != null && key !== hostedApiKeyParam)
.map(([key, val]) => [
key,
{
type: val.type,
required: val.required,
description: val.description,
default: val.default,
},
])
),
...(tool.oauth?.required && {
credentialId: {
type: 'string',
required: false,
description:
'Credential ID to use for this OAuth tool call. For Copilot/Superagent execution, pass this explicitly. Get valid IDs from environment/credentials.json.',
},
}),
}
: undefined,
outputs: tool.outputs
? Object.fromEntries(
Object.entries(tool.outputs)
.filter(([, val]) => val != null)
.map(([key, val]) => [key, { type: val.type, description: val.description }])
)
: undefined,
},
null,
2
)
}
/**
* Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json
*/
export function serializeTriggerSchema(trigger: {
id: string
name: string
provider: string
description: string
version: string
subBlocks: SubBlockConfig[]
outputs: Record<string, unknown>
webhook?: { method?: string; headers?: Record<string, string> }
}): string {
return JSON.stringify(
{
id: trigger.id,
name: trigger.name,
provider: trigger.provider,
description: trigger.description,
version: trigger.version,
webhook: trigger.webhook || undefined,
subBlocks: trigger.subBlocks.map(serializeSubBlock),
outputs: trigger.outputs,
},
null,
2
)
}
/**
* Serialize a built-in trigger block for VFS components/triggers/sim/{type}.json
*/
export function serializeBuiltinTriggerSchema(block: BlockConfig): string {
return JSON.stringify(
{
type: block.type,
name: block.name,
description: block.description,
longDescription: block.longDescription || undefined,
category: 'builtin',
triggers: block.triggers || undefined,
subBlocks: block.subBlocks.map(serializeSubBlock),
inputs: block.inputs,
outputs: block.outputs,
},
null,
2
)
}
interface TriggerOverviewEntry {
id: string
name: string
provider: string
description: string
}
/**
* Serialize a triggers.md overview for VFS components/triggers/triggers.md
*/
export function serializeTriggerOverview(
builtinTriggers: TriggerOverviewEntry[],
externalTriggers: TriggerOverviewEntry[]
): string {
const lines: string[] = ['# Triggers', '']
lines.push('## Built-in Triggers', '')
lines.push('| ID | Name | Description |')
lines.push('|----|------|-------------|')
for (const t of builtinTriggers) {
lines.push(`| ${t.id} | ${t.name} | ${t.description} |`)
}
lines.push('')
lines.push('## External Triggers', '')
lines.push('| Provider | ID | Name | Description |')
lines.push('|----------|----|------|-------------|')
for (const t of externalTriggers) {
lines.push(`| ${t.provider} | ${t.id} | ${t.name} | ${t.description} |`)
}
lines.push('')
return lines.join('\n')
}
/**
* Serialize job metadata for VFS jobs/{id}/meta.json
*/
export function serializeJobMeta(job: {
id: string
title: string | null
prompt: string
cronExpression: string | null
timezone: string | null
status: string
lifecycle: string
successCondition: string | null
maxRuns: number | null
runCount: number
nextRunAt: Date | null
lastRanAt: Date | null
sourceTaskName: string | null
sourceChatId: string | null
createdAt: Date
}): string {
return JSON.stringify(
{
id: job.id,
title: job.title || undefined,
prompt: job.prompt,
cronExpression: job.cronExpression || undefined,
timezone: job.timezone || 'UTC',
status: job.status,
lifecycle: job.lifecycle,
successCondition: job.successCondition || undefined,
maxRuns: job.maxRuns ?? undefined,
runCount: job.runCount,
nextRunAt: job.nextRunAt?.toISOString(),
lastRanAt: job.lastRanAt?.toISOString(),
sourceTaskName: job.sourceTaskName || undefined,
sourceChatId: job.sourceChatId || undefined,
createdAt: job.createdAt.toISOString(),
},
null,
2
)
}
export function serializeTaskSession(task: {
id: string
title: string
messageCount: number
createdAt: Date
updatedAt: Date
}): string {
return [
`# ${task.title}`,
'',
`- **Chat ID:** ${task.id}`,
`- **Created:** ${task.createdAt.toISOString()}`,
`- **Updated:** ${task.updatedAt.toISOString()}`,
`- **Messages:** ${task.messageCount}`,
'',
].join('\n')
}
export function serializeTaskChat(rawMessages: unknown[]): string {
const filtered: { role: string; content: string }[] = []
for (const msg of rawMessages) {
if (!msg || typeof msg !== 'object') continue
const m = msg as Record<string, unknown>
const role = m.role as string | undefined
if (role !== 'user' && role !== 'assistant') continue
let content = ''
if (role === 'assistant' && Array.isArray(m.contentBlocks)) {
const textParts: string[] = []
for (const block of m.contentBlocks) {
if (
block &&
typeof block === 'object' &&
(block as any).type === 'text' &&
(block as any).content
) {
textParts.push((block as any).content)
}
}
content = textParts.join('')
}
if (!content && typeof m.content === 'string') {
content = m.content
}
if (!content) continue
filtered.push({ role, content })
}
return JSON.stringify(filtered, null, 2)
}