From eeb1410c56741ecbe1896c276873bad36f392f67 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 19 May 2026 15:37:14 -0700 Subject: [PATCH 1/9] CONTRACTS --- .../generated/mothership-stream-v1-schema.ts | 1425 +++++++++++++++++ .../lib/copilot/generated/request-trace-v1.ts | 4 +- .../copilot/generated/trace-attributes-v1.ts | 22 + 3 files changed, 1449 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts new file mode 100644 index 00000000000..1c670b37b54 --- /dev/null +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -0,0 +1,1425 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated from copilot/contracts/mothership-stream-v1.schema.json +// + +export type JsonSchema = unknown + +export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { + $defs: { + MothershipStreamV1AdditionalPropertiesMap: { + additionalProperties: true, + type: 'object', + }, + MothershipStreamV1AsyncToolRecordStatus: { + enum: ['pending', 'running', 'completed', 'failed', 'cancelled', 'delivered'], + type: 'string', + }, + MothershipStreamV1CheckpointPauseEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1CheckpointPausePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['run'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1CheckpointPauseFrame: { + additionalProperties: false, + properties: { + parentToolCallId: { + type: 'string', + }, + parentToolName: { + type: 'string', + }, + pendingToolIds: { + items: { + type: 'string', + }, + type: 'array', + }, + }, + required: ['parentToolCallId', 'parentToolName', 'pendingToolIds'], + type: 'object', + }, + MothershipStreamV1CheckpointPausePayload: { + additionalProperties: false, + properties: { + checkpointId: { + type: 'string', + }, + executionId: { + type: 'string', + }, + frames: { + items: { + $ref: '#/$defs/MothershipStreamV1CheckpointPauseFrame', + }, + type: 'array', + }, + kind: { + enum: ['checkpoint_pause'], + type: 'string', + }, + pendingToolCallIds: { + items: { + type: 'string', + }, + type: 'array', + }, + runId: { + type: 'string', + }, + }, + required: ['kind', 'checkpointId', 'runId', 'executionId', 'pendingToolCallIds'], + type: 'object', + }, + MothershipStreamV1CompactionDoneData: { + additionalProperties: false, + properties: { + summary_chars: { + type: 'integer', + }, + }, + required: ['summary_chars'], + type: 'object', + }, + MothershipStreamV1CompactionDoneEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1CompactionDonePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['run'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1CompactionDonePayload: { + additionalProperties: false, + properties: { + data: { + $ref: '#/$defs/MothershipStreamV1CompactionDoneData', + }, + kind: { + enum: ['compaction_done'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1CompactionStartEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1CompactionStartPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['run'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1CompactionStartPayload: { + additionalProperties: false, + properties: { + kind: { + enum: ['compaction_start'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1CompleteEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1CompletePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['complete'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1CompletePayload: { + additionalProperties: false, + properties: { + cost: { + $ref: '#/$defs/MothershipStreamV1CostData', + }, + reason: { + type: 'string', + }, + response: true, + status: { + $ref: '#/$defs/MothershipStreamV1CompletionStatus', + }, + usage: { + $ref: '#/$defs/MothershipStreamV1UsageData', + }, + }, + required: ['status'], + type: 'object', + }, + MothershipStreamV1CompletionStatus: { + enum: ['complete', 'error', 'cancelled'], + type: 'string', + }, + MothershipStreamV1CostData: { + additionalProperties: false, + properties: { + input: { + type: 'number', + }, + output: { + type: 'number', + }, + total: { + type: 'number', + }, + }, + type: 'object', + }, + MothershipStreamV1ErrorEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ErrorPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['error'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ErrorPayload: { + additionalProperties: false, + properties: { + code: { + type: 'string', + }, + data: true, + displayMessage: { + type: 'string', + }, + error: { + type: 'string', + }, + message: { + type: 'string', + }, + provider: { + type: 'string', + }, + }, + required: ['message'], + type: 'object', + }, + MothershipStreamV1EventEnvelopeCommon: { + additionalProperties: false, + properties: { + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream'], + type: 'object', + }, + MothershipStreamV1EventType: { + enum: ['session', 'text', 'tool', 'span', 'resource', 'run', 'error', 'complete'], + type: 'string', + }, + MothershipStreamV1ResourceDescriptor: { + additionalProperties: false, + properties: { + id: { + type: 'string', + }, + title: { + type: 'string', + }, + type: { + type: 'string', + }, + }, + required: ['type', 'id'], + type: 'object', + }, + MothershipStreamV1ResourceOp: { + enum: ['upsert', 'remove'], + type: 'string', + }, + MothershipStreamV1ResourceRemoveEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ResourceRemovePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['resource'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ResourceRemovePayload: { + additionalProperties: false, + properties: { + op: { + enum: ['remove'], + type: 'string', + }, + resource: { + $ref: '#/$defs/MothershipStreamV1ResourceDescriptor', + }, + }, + required: ['op', 'resource'], + type: 'object', + }, + MothershipStreamV1ResourceUpsertEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ResourceUpsertPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['resource'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ResourceUpsertPayload: { + additionalProperties: false, + properties: { + op: { + enum: ['upsert'], + type: 'string', + }, + resource: { + $ref: '#/$defs/MothershipStreamV1ResourceDescriptor', + }, + }, + required: ['op', 'resource'], + type: 'object', + }, + MothershipStreamV1ResumeRequest: { + additionalProperties: false, + properties: { + checkpointId: { + type: 'string', + }, + results: { + items: { + $ref: '#/$defs/MothershipStreamV1ResumeToolResult', + }, + type: 'array', + }, + streamId: { + type: 'string', + }, + }, + required: ['streamId', 'checkpointId', 'results'], + type: 'object', + }, + MothershipStreamV1ResumeToolResult: { + additionalProperties: false, + properties: { + error: { + type: 'string', + }, + output: true, + success: { + type: 'boolean', + }, + toolCallId: { + type: 'string', + }, + }, + required: ['toolCallId', 'success'], + type: 'object', + }, + MothershipStreamV1RunKind: { + enum: ['checkpoint_pause', 'resumed', 'compaction_start', 'compaction_done'], + type: 'string', + }, + MothershipStreamV1RunResumedEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1RunResumedPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['run'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1RunResumedPayload: { + additionalProperties: false, + properties: { + kind: { + enum: ['resumed'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1SessionChatEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SessionChatPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['session'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SessionChatPayload: { + additionalProperties: false, + properties: { + chatId: { + type: 'string', + }, + kind: { + enum: ['chat'], + type: 'string', + }, + }, + required: ['kind', 'chatId'], + type: 'object', + }, + MothershipStreamV1SessionKind: { + enum: ['trace', 'chat', 'title', 'start'], + type: 'string', + }, + MothershipStreamV1SessionStartData: { + additionalProperties: false, + properties: { + responseId: { + type: 'string', + }, + }, + type: 'object', + }, + MothershipStreamV1SessionStartEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SessionStartPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['session'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SessionStartPayload: { + additionalProperties: false, + properties: { + data: { + $ref: '#/$defs/MothershipStreamV1SessionStartData', + }, + kind: { + enum: ['start'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1SessionTitleEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SessionTitlePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['session'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SessionTitlePayload: { + additionalProperties: false, + properties: { + kind: { + enum: ['title'], + type: 'string', + }, + title: { + type: 'string', + }, + }, + required: ['kind', 'title'], + type: 'object', + }, + MothershipStreamV1SessionTraceEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SessionTracePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['session'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SessionTracePayload: { + additionalProperties: false, + properties: { + kind: { + enum: ['trace'], + type: 'string', + }, + requestId: { + type: 'string', + }, + spanId: { + type: 'string', + }, + }, + required: ['kind', 'requestId'], + type: 'object', + }, + MothershipStreamV1SpanKind: { + enum: ['subagent'], + type: 'string', + }, + MothershipStreamV1SpanLifecycleEvent: { + enum: ['start', 'end'], + type: 'string', + }, + MothershipStreamV1SpanPayloadKind: { + enum: ['subagent', 'structured_result', 'subagent_result'], + type: 'string', + }, + MothershipStreamV1StreamCursor: { + additionalProperties: false, + properties: { + cursor: { + type: 'string', + }, + seq: { + type: 'integer', + }, + streamId: { + type: 'string', + }, + }, + required: ['streamId', 'cursor', 'seq'], + type: 'object', + }, + MothershipStreamV1StreamRef: { + additionalProperties: false, + properties: { + chatId: { + type: 'string', + }, + cursor: { + type: 'string', + }, + streamId: { + type: 'string', + }, + }, + required: ['streamId'], + type: 'object', + }, + MothershipStreamV1StreamScope: { + additionalProperties: false, + properties: { + agentId: { + type: 'string', + }, + lane: { + enum: ['subagent'], + type: 'string', + }, + parentToolCallId: { + type: 'string', + }, + }, + required: ['lane'], + type: 'object', + }, + MothershipStreamV1StructuredResultSpanEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1StructuredResultSpanPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['span'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1StructuredResultSpanPayload: { + additionalProperties: false, + properties: { + agent: { + type: 'string', + }, + data: true, + kind: { + enum: ['structured_result'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1SubagentResultSpanEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SubagentResultSpanPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['span'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SubagentResultSpanPayload: { + additionalProperties: false, + properties: { + agent: { + type: 'string', + }, + data: true, + kind: { + enum: ['subagent_result'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1SubagentSpanEndEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SubagentSpanEndPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['span'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SubagentSpanEndPayload: { + additionalProperties: false, + properties: { + agent: { + type: 'string', + }, + data: true, + event: { + enum: ['end'], + type: 'string', + }, + kind: { + enum: ['subagent'], + type: 'string', + }, + }, + required: ['kind', 'event'], + type: 'object', + }, + MothershipStreamV1SubagentSpanStartEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SubagentSpanStartPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['span'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SubagentSpanStartPayload: { + additionalProperties: false, + properties: { + agent: { + type: 'string', + }, + data: true, + event: { + enum: ['start'], + type: 'string', + }, + kind: { + enum: ['subagent'], + type: 'string', + }, + }, + required: ['kind', 'event'], + type: 'object', + }, + MothershipStreamV1TextChannel: { + enum: ['assistant', 'thinking'], + type: 'string', + }, + MothershipStreamV1TextEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1TextPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['text'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1TextPayload: { + additionalProperties: false, + properties: { + channel: { + $ref: '#/$defs/MothershipStreamV1TextChannel', + }, + text: { + type: 'string', + }, + }, + required: ['channel', 'text'], + type: 'object', + }, + MothershipStreamV1ToolArgsDeltaEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ToolArgsDeltaPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['tool'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ToolArgsDeltaPayload: { + additionalProperties: false, + properties: { + argumentsDelta: { + type: 'string', + }, + executor: { + $ref: '#/$defs/MothershipStreamV1ToolExecutor', + }, + mode: { + $ref: '#/$defs/MothershipStreamV1ToolMode', + }, + phase: { + enum: ['args_delta'], + type: 'string', + }, + toolCallId: { + type: 'string', + }, + toolName: { + type: 'string', + }, + }, + required: ['toolCallId', 'toolName', 'argumentsDelta', 'executor', 'mode', 'phase'], + type: 'object', + }, + MothershipStreamV1ToolCallDescriptor: { + additionalProperties: false, + properties: { + arguments: { + $ref: '#/$defs/MothershipStreamV1AdditionalPropertiesMap', + }, + executor: { + $ref: '#/$defs/MothershipStreamV1ToolExecutor', + }, + mode: { + $ref: '#/$defs/MothershipStreamV1ToolMode', + }, + partial: { + type: 'boolean', + }, + phase: { + enum: ['call'], + type: 'string', + }, + requiresConfirmation: { + type: 'boolean', + }, + status: { + $ref: '#/$defs/MothershipStreamV1ToolStatus', + }, + toolCallId: { + type: 'string', + }, + toolName: { + type: 'string', + }, + ui: { + $ref: '#/$defs/MothershipStreamV1ToolUI', + }, + }, + required: ['toolCallId', 'toolName', 'executor', 'mode', 'phase'], + type: 'object', + }, + MothershipStreamV1ToolCallEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ToolCallDescriptor', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['tool'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ToolExecutor: { + enum: ['go', 'sim', 'client'], + type: 'string', + }, + MothershipStreamV1ToolMode: { + enum: ['sync', 'async'], + type: 'string', + }, + MothershipStreamV1ToolOutcome: { + enum: ['success', 'error', 'cancelled', 'skipped', 'rejected'], + type: 'string', + }, + MothershipStreamV1ToolPhase: { + enum: ['call', 'args_delta', 'result'], + type: 'string', + }, + MothershipStreamV1ToolResultEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ToolResultPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['tool'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ToolResultPayload: { + additionalProperties: false, + properties: { + error: { + type: 'string', + }, + executor: { + $ref: '#/$defs/MothershipStreamV1ToolExecutor', + }, + mode: { + $ref: '#/$defs/MothershipStreamV1ToolMode', + }, + output: true, + phase: { + enum: ['result'], + type: 'string', + }, + status: { + $ref: '#/$defs/MothershipStreamV1ToolStatus', + }, + success: { + type: 'boolean', + }, + toolCallId: { + type: 'string', + }, + toolName: { + type: 'string', + }, + }, + required: ['toolCallId', 'toolName', 'executor', 'mode', 'phase', 'success'], + type: 'object', + }, + MothershipStreamV1ToolStatus: { + enum: ['generating', 'executing', 'success', 'error', 'cancelled', 'skipped', 'rejected'], + type: 'string', + }, + MothershipStreamV1ToolUI: { + additionalProperties: false, + properties: { + clientExecutable: { + type: 'boolean', + }, + hidden: { + type: 'boolean', + }, + icon: { + type: 'string', + }, + internal: { + type: 'boolean', + }, + phaseLabel: { + type: 'string', + }, + requiresConfirmation: { + type: 'boolean', + }, + title: { + type: 'string', + }, + }, + type: 'object', + }, + MothershipStreamV1Trace: { + additionalProperties: false, + properties: { + goTraceId: { + description: + 'OTel trace ID from the first Go ingress. May differ from requestId when Sim assigns the canonical request identity.', + type: 'string', + }, + requestId: { + type: 'string', + }, + spanId: { + type: 'string', + }, + }, + required: ['requestId'], + type: 'object', + }, + MothershipStreamV1UsageData: { + additionalProperties: false, + properties: { + cache_creation_input_tokens: { + type: 'integer', + }, + cache_read_input_tokens: { + type: 'integer', + }, + input_tokens: { + type: 'integer', + }, + model: { + type: 'string', + }, + output_tokens: { + type: 'integer', + }, + total_tokens: { + type: 'integer', + }, + }, + type: 'object', + }, + }, + $id: 'mothership-stream-v1.schema.json', + $schema: 'https://json-schema.org/draft/2020-12/schema', + description: 'Shared execution-oriented mothership stream contract from Go to Sim.', + oneOf: [ + { + $ref: '#/$defs/MothershipStreamV1SessionStartEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SessionChatEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SessionTitleEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SessionTraceEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1TextEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ToolCallEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ToolArgsDeltaEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ToolResultEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SubagentSpanStartEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SubagentSpanEndEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1StructuredResultSpanEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SubagentResultSpanEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ResourceUpsertEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ResourceRemoveEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1CheckpointPauseEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1RunResumedEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1CompactionStartEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1CompactionDoneEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ErrorEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1CompleteEventEnvelope', + }, + ], + title: 'MothershipStreamV1EventEnvelope', +} diff --git a/apps/sim/lib/copilot/generated/request-trace-v1.ts b/apps/sim/lib/copilot/generated/request-trace-v1.ts index 8b9e3870f11..31a60bb5159 100644 --- a/apps/sim/lib/copilot/generated/request-trace-v1.ts +++ b/apps/sim/lib/copilot/generated/request-trace-v1.ts @@ -80,7 +80,7 @@ export interface RequestTraceV1UsageSummary { * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema * via the `definition` "RequestTraceV1MergedTrace". */ -interface RequestTraceV1MergedTrace { +export interface RequestTraceV1MergedTrace { chatId?: string cost?: RequestTraceV1CostSummary durationMs: number @@ -99,7 +99,7 @@ interface RequestTraceV1MergedTrace { * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema * via the `definition` "RequestTraceV1SimReport". */ -interface RequestTraceV1SimReport1 { +export interface RequestTraceV1SimReport1 { chatId?: string cost?: RequestTraceV1CostSummary durationMs: number diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 84bf88d82c4..33906379c6e 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -310,6 +310,7 @@ export const TraceAttr = { GenAiOperationName: 'gen_ai.operation.name', GenAiOutputMessages: 'gen_ai.output.messages', GenAiRequestAssistantMessages: 'gen_ai.request.assistant_messages', + GenAiRequestCacheableSystemBlocks: 'gen_ai.request.cacheable_system_blocks', GenAiRequestContentBlocks: 'gen_ai.request.content_blocks', GenAiRequestHasCacheControl: 'gen_ai.request.has_cache_control', GenAiRequestImageBlocks: 'gen_ai.request.image_blocks', @@ -317,6 +318,7 @@ export const TraceAttr = { GenAiRequestMaxMessageBlocks: 'gen_ai.request.max_message_blocks', GenAiRequestMessagesCount: 'gen_ai.request.messages.count', GenAiRequestModel: 'gen_ai.request.model', + GenAiRequestSystemBlocks: 'gen_ai.request.system_blocks', GenAiRequestSystemChars: 'gen_ai.request.system_chars', GenAiRequestTextBlocks: 'gen_ai.request.text_blocks', GenAiRequestToolResultBlocks: 'gen_ai.request.tool_result_blocks', @@ -404,6 +406,10 @@ export const TraceAttr = { PrefsToolCount: 'prefs.tool_count', ProcessingChunkSize: 'processing.chunk_size', ProcessingRecipe: 'processing.recipe', + PromptCacheableBlocks: 'prompt.cacheable_blocks', + PromptSet: 'prompt.set', + PromptSystemBlocks: 'prompt.system_blocks', + PromptSystemChars: 'prompt.system_chars', ProviderId: 'provider.id', RateLimitAttempt: 'rate_limit.attempt', RateLimitCount: 'rate_limit.count', @@ -476,14 +482,17 @@ export const TraceAttr = { ToolAsyncWaiterPubsubDeliveries: 'tool.async_waiter.pubsub_deliveries', ToolAsyncWaiterResolution: 'tool.async_waiter.resolution', ToolCallId: 'tool.call_id', + ToolClientCount: 'tool.client.count', ToolClientExecutable: 'tool.client_executable', ToolCompletionReceived: 'tool.completion.received', ToolConfirmationStatus: 'tool.confirmation.status', + ToolDeferredCount: 'tool.deferred.count', ToolDurationMs: 'tool.duration_ms', ToolErrorKind: 'tool.error_kind', ToolExecutor: 'tool.executor', ToolExternalService: 'tool.external.service', ToolId: 'tool.id', + ToolLoadedCount: 'tool.loaded.count', ToolName: 'tool.name', ToolOutcome: 'tool.outcome', ToolOutcomeMessage: 'tool.outcome.message', @@ -498,6 +507,8 @@ export const TraceAttr = { ToolStoreStatus: 'tool.store_status', ToolSync: 'tool.sync', ToolTimeoutMs: 'tool.timeout_ms', + ToolVisibleCount: 'tool.visible.count', + ToolVisibleNames: 'tool.visible.names', TraceAborted: 'trace.aborted', TraceBilledTotalCost: 'trace.billed_total_cost', TraceCacheReadTokens: 'trace.cache_read_tokens', @@ -837,6 +848,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.operation.name', 'gen_ai.output.messages', 'gen_ai.request.assistant_messages', + 'gen_ai.request.cacheable_system_blocks', 'gen_ai.request.content_blocks', 'gen_ai.request.has_cache_control', 'gen_ai.request.image_blocks', @@ -844,6 +856,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.request.max_message_blocks', 'gen_ai.request.messages.count', 'gen_ai.request.model', + 'gen_ai.request.system_blocks', 'gen_ai.request.system_chars', 'gen_ai.request.text_blocks', 'gen_ai.request.tool_result_blocks', @@ -931,6 +944,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'prefs.tool_count', 'processing.chunk_size', 'processing.recipe', + 'prompt.cacheable_blocks', + 'prompt.set', + 'prompt.system_blocks', + 'prompt.system_chars', 'provider.id', 'rate_limit.attempt', 'rate_limit.count', @@ -1003,14 +1020,17 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'tool.async_waiter.pubsub_deliveries', 'tool.async_waiter.resolution', 'tool.call_id', + 'tool.client.count', 'tool.client_executable', 'tool.completion.received', 'tool.confirmation.status', + 'tool.deferred.count', 'tool.duration_ms', 'tool.error_kind', 'tool.executor', 'tool.external.service', 'tool.id', + 'tool.loaded.count', 'tool.name', 'tool.outcome', 'tool.outcome.message', @@ -1025,6 +1045,8 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'tool.store_status', 'tool.sync', 'tool.timeout_ms', + 'tool.visible.count', + 'tool.visible.names', 'trace.aborted', 'trace.billed_total_cost', 'trace.cache_read_tokens', From 6f83e4ce21e10750d004ff607366136910519923 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 19 May 2026 17:17:54 -0700 Subject: [PATCH 2/9] updates --- .../generated/mothership-stream-v1-schema.ts | 5 +- .../lib/copilot/generated/tool-schemas-v1.ts | 186 +++++++++--------- apps/sim/lib/copilot/request/lifecycle/run.ts | 1 + 3 files changed, 98 insertions(+), 94 deletions(-) diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index 1c670b37b54..57488105199 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -486,8 +486,11 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { streamId: { type: 'string', }, + userId: { + type: 'string', + }, }, - required: ['streamId', 'checkpointId', 'results'], + required: ['streamId', 'checkpointId', 'userId', 'results'], type: 'object', }, MothershipStreamV1ResumeToolResult: { diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a447a116ddf..f381c961dee 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + ['agent']: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - auth: { + ['auth']: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { + ['check_deployment_status']: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_job: { + ['complete_job']: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - context_write: { + ['context_write']: { parameters: { type: 'object', properties: { @@ -78,7 +78,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { + ['crawl_website']: { parameters: { type: 'object', properties: { @@ -113,7 +113,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_file: { + ['create_file']: { parameters: { type: 'object', properties: { @@ -149,7 +149,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { + ['create_file_folder']: { parameters: { type: 'object', properties: { @@ -170,7 +170,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_folder: { + ['create_folder']: { parameters: { type: 'object', properties: { @@ -191,7 +191,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_job: { + ['create_job']: { parameters: { type: 'object', properties: { @@ -241,7 +241,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workflow: { + ['create_workflow']: { parameters: { type: 'object', properties: { @@ -266,7 +266,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workspace_mcp_server: { + ['create_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -299,7 +299,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - debug: { + ['debug']: { parameters: { properties: { context: { @@ -318,7 +318,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { + ['delete_file']: { parameters: { type: 'object', properties: { @@ -347,7 +347,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - delete_file_folder: { + ['delete_file_folder']: { parameters: { type: 'object', properties: { @@ -363,7 +363,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_folder: { + ['delete_folder']: { parameters: { type: 'object', properties: { @@ -379,7 +379,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workflow: { + ['delete_workflow']: { parameters: { type: 'object', properties: { @@ -395,7 +395,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workspace_mcp_server: { + ['delete_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -408,7 +408,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy: { + ['deploy']: { parameters: { properties: { request: { @@ -422,7 +422,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + ['deploy_api']: { parameters: { type: 'object', properties: { @@ -496,7 +496,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + ['deploy_chat']: { parameters: { type: 'object', properties: { @@ -644,7 +644,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_mcp: { + ['deploy_mcp']: { parameters: { type: 'object', properties: { @@ -760,7 +760,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - download_to_workspace_file: { + ['download_to_workspace_file']: { parameters: { type: 'object', properties: { @@ -779,7 +779,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { + ['edit_content']: { parameters: { type: 'object', properties: { @@ -811,7 +811,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - edit_workflow: { + ['edit_workflow']: { parameters: { type: 'object', properties: { @@ -850,13 +850,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { + ['file']: { parameters: { type: 'object', }, resultSchema: undefined, }, - function_execute: { + ['function_execute']: { parameters: { type: 'object', properties: { @@ -917,7 +917,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_api_key: { + ['generate_api_key']: { parameters: { type: 'object', properties: { @@ -935,7 +935,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + ['generate_image']: { parameters: { type: 'object', properties: { @@ -972,7 +972,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_visualization: { + ['generate_visualization']: { parameters: { type: 'object', properties: { @@ -1012,7 +1012,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_outputs: { + ['get_block_outputs']: { parameters: { type: 'object', properties: { @@ -1033,7 +1033,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_upstream_references: { + ['get_block_upstream_references']: { parameters: { type: 'object', properties: { @@ -1055,7 +1055,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployed_workflow_state: { + ['get_deployed_workflow_state']: { parameters: { type: 'object', properties: { @@ -1068,7 +1068,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_version: { + ['get_deployment_version']: { parameters: { type: 'object', properties: { @@ -1085,7 +1085,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_execution_summary: { + ['get_execution_summary']: { parameters: { type: 'object', properties: { @@ -1112,7 +1112,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_job_logs: { + ['get_job_logs']: { parameters: { type: 'object', properties: { @@ -1137,7 +1137,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_page_contents: { + ['get_page_contents']: { parameters: { type: 'object', properties: { @@ -1165,14 +1165,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { + ['get_platform_actions']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - get_workflow_data: { + ['get_workflow_data']: { parameters: { type: 'object', properties: { @@ -1191,7 +1191,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_logs: { + ['get_workflow_logs']: { parameters: { type: 'object', properties: { @@ -1217,7 +1217,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - glob: { + ['glob']: { parameters: { type: 'object', properties: { @@ -1236,7 +1236,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - grep: { + ['grep']: { parameters: { type: 'object', properties: { @@ -1283,7 +1283,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - job: { + ['job']: { parameters: { properties: { request: { @@ -1296,7 +1296,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge: { + ['knowledge']: { parameters: { properties: { request: { @@ -1309,7 +1309,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + ['knowledge_base']: { parameters: { type: 'object', properties: { @@ -1501,7 +1501,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { + ['list_file_folders']: { parameters: { type: 'object', properties: { @@ -1513,7 +1513,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_folders: { + ['list_folders']: { parameters: { type: 'object', properties: { @@ -1525,14 +1525,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { + ['list_user_workspaces']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - list_workspace_mcp_servers: { + ['list_workspace_mcp_servers']: { parameters: { type: 'object', properties: { @@ -1545,7 +1545,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_credential: { + ['manage_credential']: { parameters: { type: 'object', properties: { @@ -1574,7 +1574,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_custom_tool: { + ['manage_custom_tool']: { parameters: { type: 'object', properties: { @@ -1653,7 +1653,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_job: { + ['manage_job']: { parameters: { type: 'object', properties: { @@ -1723,7 +1723,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_mcp_tool: { + ['manage_mcp_tool']: { parameters: { type: 'object', properties: { @@ -1774,7 +1774,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_skill: { + ['manage_skill']: { parameters: { type: 'object', properties: { @@ -1806,7 +1806,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { + ['materialize_file']: { parameters: { type: 'object', properties: { @@ -1840,7 +1840,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + ['move_file']: { parameters: { type: 'object', properties: { @@ -1861,7 +1861,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file_folder: { + ['move_file_folder']: { parameters: { type: 'object', properties: { @@ -1879,7 +1879,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_folder: { + ['move_folder']: { parameters: { type: 'object', properties: { @@ -1897,7 +1897,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_workflow: { + ['move_workflow']: { parameters: { type: 'object', properties: { @@ -1917,7 +1917,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_get_auth_link: { + ['oauth_get_auth_link']: { parameters: { type: 'object', properties: { @@ -1931,7 +1931,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_request_access: { + ['oauth_request_access']: { parameters: { type: 'object', properties: { @@ -1945,7 +1945,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - open_resource: { + ['open_resource']: { parameters: { type: 'object', properties: { @@ -1974,7 +1974,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - read: { + ['read']: { parameters: { type: 'object', properties: { @@ -2001,7 +2001,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - redeploy: { + ['redeploy']: { parameters: { type: 'object', properties: { @@ -2069,7 +2069,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { + ['rename_file']: { parameters: { type: 'object', properties: { @@ -2104,7 +2104,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - rename_file_folder: { + ['rename_file_folder']: { parameters: { type: 'object', properties: { @@ -2121,7 +2121,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - rename_workflow: { + ['rename_workflow']: { parameters: { type: 'object', properties: { @@ -2138,7 +2138,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - research: { + ['research']: { parameters: { properties: { topic: { @@ -2151,7 +2151,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - respond: { + ['respond']: { parameters: { additionalProperties: true, properties: { @@ -2174,7 +2174,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - restore_resource: { + ['restore_resource']: { parameters: { type: 'object', properties: { @@ -2192,7 +2192,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - revert_to_version: { + ['revert_to_version']: { parameters: { type: 'object', properties: { @@ -2209,7 +2209,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run: { + ['run']: { parameters: { properties: { context: { @@ -2226,7 +2226,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_block: { + ['run_block']: { parameters: { type: 'object', properties: { @@ -2258,7 +2258,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_from_block: { + ['run_from_block']: { parameters: { type: 'object', properties: { @@ -2290,7 +2290,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow: { + ['run_workflow']: { parameters: { type: 'object', properties: { @@ -2318,7 +2318,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow_until_block: { + ['run_workflow_until_block']: { parameters: { type: 'object', properties: { @@ -2350,7 +2350,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + ['scrape_page']: { parameters: { type: 'object', properties: { @@ -2371,7 +2371,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + ['search_documentation']: { parameters: { type: 'object', properties: { @@ -2388,7 +2388,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_library_docs: { + ['search_library_docs']: { parameters: { type: 'object', properties: { @@ -2409,7 +2409,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + ['search_online']: { parameters: { type: 'object', properties: { @@ -2450,7 +2450,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_patterns: { + ['search_patterns']: { parameters: { type: 'object', properties: { @@ -2472,7 +2472,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_block_enabled: { + ['set_block_enabled']: { parameters: { type: 'object', properties: { @@ -2494,7 +2494,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_environment_variables: { + ['set_environment_variables']: { parameters: { type: 'object', properties: { @@ -2528,7 +2528,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_global_workflow_variables: { + ['set_global_workflow_variables']: { parameters: { type: 'object', properties: { @@ -2566,7 +2566,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { + ['superagent']: { parameters: { properties: { task: { @@ -2580,7 +2580,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - table: { + ['table']: { parameters: { properties: { request: { @@ -2593,7 +2593,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - tool_search_tool_regex: { + ['tool_search_tool_regex']: { parameters: { properties: { case_insensitive: { @@ -2614,7 +2614,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_job_history: { + ['update_job_history']: { parameters: { type: 'object', properties: { @@ -2632,7 +2632,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_workspace_mcp_server: { + ['update_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -2657,7 +2657,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { + ['user_memory']: { parameters: { type: 'object', properties: { @@ -2705,7 +2705,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_table: { + ['user_table']: { parameters: { type: 'object', properties: { @@ -3034,13 +3034,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - workflow: { + ['workflow']: { parameters: { type: 'object', }, resultSchema: undefined, }, - workspace_file: { + ['workspace_file']: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index e06341f43ef..d4502511458 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -434,6 +434,7 @@ async function runCheckpointLoop( payload = { streamId: context.messageId, checkpointId: continuation.checkpointId, + userId: options.userId, results, } From 57a91e9277c1cbe0fcc08cf274e32deaeb83e1c0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 20 May 2026 14:23:41 -0700 Subject: [PATCH 3/9] Prompt caching --- apps/sim/app/api/mothership/execute/route.ts | 9 +-- apps/sim/lib/copilot/chat/payload.ts | 9 +-- .../sim/lib/copilot/chat/workspace-context.ts | 14 +++- .../lib/copilot/generated/request-trace-v1.ts | 4 + .../copilot/generated/trace-attributes-v1.ts | 76 +++++++++++++++++++ .../lib/copilot/generated/trace-events-v1.ts | 2 + apps/sim/lib/copilot/request/trace.ts | 17 ++++- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 53 ++++++++----- apps/sim/lib/mothership/inbox/executor.ts | 8 +- 9 files changed, 148 insertions(+), 44 deletions(-) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index ab53f413baf..9d3d0366e6a 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -122,13 +122,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { buildMothershipToolsForRequest({ workspaceId, userId }), getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), ]) - const workspaceContextWithMothershipTools = [ - workspaceContext, - mothershipToolRuntime.catalogContext, - ] - .filter(Boolean) - .join('\n\n') - const requestPayload: Record = { messages, responseFormat, @@ -137,7 +130,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { mode: 'agent', messageId, isHosted: true, - workspaceContext: workspaceContextWithMothershipTools, + workspaceContext, ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipToolRuntime.tools.length > 0 diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 4e3fdf34da7..6a2cd0cf763 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -267,8 +267,6 @@ export async function buildCopilotRequestPayload( let integrationTools: ToolSchema[] = [] let mothershipTools: ToolSchema[] = [] - let workspaceContext = params.workspaceContext - const payloadLogger = logger.withMetadata({ messageId: userMessageId }) if (effectiveMode === 'build') { @@ -286,11 +284,6 @@ export async function buildCopilotRequestPayload( userId, }) mothershipTools = runtimeTools.tools - if (runtimeTools.catalogContext) { - workspaceContext = [workspaceContext, runtimeTools.catalogContext] - .filter(Boolean) - .join('\n\n') - } } catch (error) { logger.warn( userMessageId @@ -321,7 +314,7 @@ export async function buildCopilotRequestPayload( ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipTools.length > 0 ? { mothershipTools } : {}), ...(commands && commands.length > 0 ? { commands } : {}), - ...(workspaceContext ? { workspaceContext } : {}), + ...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}), ...(params.userPermission ? { userPermission: params.userPermission } : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), isHosted, diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 36b63fbd5f4..df3b0b23bb1 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -267,10 +267,20 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { return sections.join('\n\n') } +export function buildWorkspaceContextMd( + data: WorkspaceMdData, + mothershipToolCatalog?: string +): string { + return ['# Workspace Context', '', buildWorkspaceMd(data), mothershipToolCatalog] + .filter(Boolean) + .join('\n\n') +} + /** * Generate WORKSPACE.md content from actual database state. - * Auto-injected into the system prompt and served as a top-level VFS file. - * The LLM never writes it directly. + * Served as a top-level VFS file. The Go system prompt keeps only stable + * discovery rules; the LLM reads dynamic workspace state from VFS files. + * The LLM never writes this file directly. */ export async function generateWorkspaceContext( workspaceId: string, diff --git a/apps/sim/lib/copilot/generated/request-trace-v1.ts b/apps/sim/lib/copilot/generated/request-trace-v1.ts index 31a60bb5159..80ebb12dadc 100644 --- a/apps/sim/lib/copilot/generated/request-trace-v1.ts +++ b/apps/sim/lib/copilot/generated/request-trace-v1.ts @@ -71,7 +71,11 @@ export interface MothershipStreamV1AdditionalPropertiesMap { * via the `definition` "RequestTraceV1UsageSummary". */ export interface RequestTraceV1UsageSummary { + cacheAttemptedRequests?: number + cacheHitRequests?: number cacheReadTokens?: number + cacheSavingsRate?: number + cacheWriteRequests?: number cacheWriteTokens?: number inputTokens?: number outputTokens?: number diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 33906379c6e..e14368d9a6b 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -21,7 +21,10 @@ export const TraceAttr = { AbortRedisResult: 'abort.redis_result', AnalyticsAborted: 'analytics.aborted', AnalyticsBilledTotalCost: 'analytics.billed_total_cost', + AnalyticsCacheAttemptedRequests: 'analytics.cache_attempted_requests', + AnalyticsCacheHitRequests: 'analytics.cache_hit_requests', AnalyticsCacheReadTokens: 'analytics.cache_read_tokens', + AnalyticsCacheWriteRequests: 'analytics.cache_write_requests', AnalyticsCacheWriteTokens: 'analytics.cache_write_tokens', AnalyticsCustomerType: 'analytics.customer_type', AnalyticsDurationMs: 'analytics.duration_ms', @@ -303,12 +306,15 @@ export const TraceAttr = { FunctionName: 'function.name', GenAiAgentId: 'gen_ai.agent.id', GenAiAgentName: 'gen_ai.agent.name', + GenAiCacheOutcome: 'gen_ai.cache.outcome', + GenAiCacheTokenType: 'gen_ai.cache.token.type', GenAiCostInput: 'gen_ai.cost.input', GenAiCostOutput: 'gen_ai.cost.output', GenAiCostTotal: 'gen_ai.cost.total', GenAiInputMessages: 'gen_ai.input.messages', GenAiOperationName: 'gen_ai.operation.name', GenAiOutputMessages: 'gen_ai.output.messages', + GenAiProviderName: 'gen_ai.provider.name', GenAiRequestAssistantMessages: 'gen_ai.request.assistant_messages', GenAiRequestCacheableSystemBlocks: 'gen_ai.request.cacheable_system_blocks', GenAiRequestContentBlocks: 'gen_ai.request.content_blocks', @@ -318,6 +324,33 @@ export const TraceAttr = { GenAiRequestMaxMessageBlocks: 'gen_ai.request.max_message_blocks', GenAiRequestMessagesCount: 'gen_ai.request.messages.count', GenAiRequestModel: 'gen_ai.request.model', + GenAiRequestPromptCacheBreakpointCreated: 'gen_ai.request.prompt_cache.breakpoint.created', + GenAiRequestPromptCacheBreakpointEligible: 'gen_ai.request.prompt_cache.breakpoint.eligible', + GenAiRequestPromptCacheBreakpointKind: 'gen_ai.request.prompt_cache.breakpoint.kind', + GenAiRequestPromptCacheBreakpointPrefixTokensEstimated: + 'gen_ai.request.prompt_cache.breakpoint.prefix_tokens_estimated', + GenAiRequestPromptCacheBreakpointSection: 'gen_ai.request.prompt_cache.breakpoint.section', + GenAiRequestPromptCacheBreakpointSkipReason: 'gen_ai.request.prompt_cache.breakpoint.skip_reason', + GenAiRequestPromptCacheBreakpointTargetIndex: + 'gen_ai.request.prompt_cache.breakpoint.target_index', + GenAiRequestPromptCacheBreakpointTargetRole: 'gen_ai.request.prompt_cache.breakpoint.target_role', + GenAiRequestPromptCacheBreakpointTargetType: 'gen_ai.request.prompt_cache.breakpoint.target_type', + GenAiRequestPromptCacheBreakpointTtl: 'gen_ai.request.prompt_cache.breakpoint.ttl', + GenAiRequestPromptCacheBreakpointsCount: 'gen_ai.request.prompt_cache.breakpoints.count', + GenAiRequestPromptCacheBreakpointsCreated: 'gen_ai.request.prompt_cache.breakpoints.created', + GenAiRequestPromptCacheDynamicPrefixTokensEstimated: + 'gen_ai.request.prompt_cache.dynamic.prefix_tokens_estimated', + GenAiRequestPromptCacheDynamicSkipReason: 'gen_ai.request.prompt_cache.dynamic.skip_reason', + GenAiRequestPromptCacheDynamicTargetType: 'gen_ai.request.prompt_cache.dynamic.target_type', + GenAiRequestPromptCacheDynamicTtl: 'gen_ai.request.prompt_cache.dynamic.ttl', + GenAiRequestPromptCacheDynamicUserEligible: 'gen_ai.request.prompt_cache.dynamic_user_eligible', + GenAiRequestPromptCacheDynamicUserSkipReason: + 'gen_ai.request.prompt_cache.dynamic_user_skip_reason', + GenAiRequestPromptCacheStaticTtl: 'gen_ai.request.prompt_cache.static.ttl', + GenAiRequestPromptCacheStaticSystem: 'gen_ai.request.prompt_cache.static_system', + GenAiRequestPromptCacheStaticTools: 'gen_ai.request.prompt_cache.static_tools', + GenAiRequestRuntimeContextChars: 'gen_ai.request.runtime_context_chars', + GenAiRequestRuntimeContextMessages: 'gen_ai.request.runtime_context_messages', GenAiRequestSystemBlocks: 'gen_ai.request.system_blocks', GenAiRequestSystemChars: 'gen_ai.request.system_chars', GenAiRequestTextBlocks: 'gen_ai.request.text_blocks', @@ -325,6 +358,7 @@ export const TraceAttr = { GenAiRequestToolUseBlocks: 'gen_ai.request.tool_use_blocks', GenAiRequestToolsCount: 'gen_ai.request.tools.count', GenAiRequestUserMessages: 'gen_ai.request.user_messages', + GenAiResponseModel: 'gen_ai.response.model', GenAiStreamPhaseTextBytes: 'gen_ai.stream.phase.text.bytes', GenAiStreamPhaseTextChunks: 'gen_ai.stream.phase.text.chunks', GenAiStreamPhaseTextFirstMs: 'gen_ai.stream.phase.text.first_ms', @@ -338,8 +372,11 @@ export const TraceAttr = { GenAiStreamPhaseToolArgsFirstMs: 'gen_ai.stream.phase.tool_args.first_ms', GenAiStreamPhaseToolArgsMs: 'gen_ai.stream.phase.tool_args.ms', GenAiSystem: 'gen_ai.system', + GenAiTokenType: 'gen_ai.token.type', GenAiToolName: 'gen_ai.tool.name', + GenAiUsageCacheCreationInputTokens: 'gen_ai.usage.cache_creation.input_tokens', GenAiUsageCacheCreationTokens: 'gen_ai.usage.cache_creation_tokens', + GenAiUsageCacheReadInputTokens: 'gen_ai.usage.cache_read.input_tokens', GenAiUsageCacheReadTokens: 'gen_ai.usage.cache_read_tokens', GenAiUsageInputTokens: 'gen_ai.usage.input_tokens', GenAiUsageOutputTokens: 'gen_ai.usage.output_tokens', @@ -511,7 +548,10 @@ export const TraceAttr = { ToolVisibleNames: 'tool.visible.names', TraceAborted: 'trace.aborted', TraceBilledTotalCost: 'trace.billed_total_cost', + TraceCacheAttemptedRequests: 'trace.cache_attempted_requests', + TraceCacheHitRequests: 'trace.cache_hit_requests', TraceCacheReadTokens: 'trace.cache_read_tokens', + TraceCacheWriteRequests: 'trace.cache_write_requests', TraceCacheWriteTokens: 'trace.cache_write_tokens', TraceDurationMs: 'trace.duration_ms', TraceError: 'trace.error', @@ -559,7 +599,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'abort.redis_result', 'analytics.aborted', 'analytics.billed_total_cost', + 'analytics.cache_attempted_requests', + 'analytics.cache_hit_requests', 'analytics.cache_read_tokens', + 'analytics.cache_write_requests', 'analytics.cache_write_tokens', 'analytics.customer_type', 'analytics.duration_ms', @@ -841,12 +884,15 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'function.name', 'gen_ai.agent.id', 'gen_ai.agent.name', + 'gen_ai.cache.outcome', + 'gen_ai.cache.token.type', 'gen_ai.cost.input', 'gen_ai.cost.output', 'gen_ai.cost.total', 'gen_ai.input.messages', 'gen_ai.operation.name', 'gen_ai.output.messages', + 'gen_ai.provider.name', 'gen_ai.request.assistant_messages', 'gen_ai.request.cacheable_system_blocks', 'gen_ai.request.content_blocks', @@ -856,6 +902,29 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.request.max_message_blocks', 'gen_ai.request.messages.count', 'gen_ai.request.model', + 'gen_ai.request.prompt_cache.breakpoint.created', + 'gen_ai.request.prompt_cache.breakpoint.eligible', + 'gen_ai.request.prompt_cache.breakpoint.kind', + 'gen_ai.request.prompt_cache.breakpoint.prefix_tokens_estimated', + 'gen_ai.request.prompt_cache.breakpoint.section', + 'gen_ai.request.prompt_cache.breakpoint.skip_reason', + 'gen_ai.request.prompt_cache.breakpoint.target_index', + 'gen_ai.request.prompt_cache.breakpoint.target_role', + 'gen_ai.request.prompt_cache.breakpoint.target_type', + 'gen_ai.request.prompt_cache.breakpoint.ttl', + 'gen_ai.request.prompt_cache.breakpoints.count', + 'gen_ai.request.prompt_cache.breakpoints.created', + 'gen_ai.request.prompt_cache.dynamic.prefix_tokens_estimated', + 'gen_ai.request.prompt_cache.dynamic.skip_reason', + 'gen_ai.request.prompt_cache.dynamic.target_type', + 'gen_ai.request.prompt_cache.dynamic.ttl', + 'gen_ai.request.prompt_cache.dynamic_user_eligible', + 'gen_ai.request.prompt_cache.dynamic_user_skip_reason', + 'gen_ai.request.prompt_cache.static.ttl', + 'gen_ai.request.prompt_cache.static_system', + 'gen_ai.request.prompt_cache.static_tools', + 'gen_ai.request.runtime_context_chars', + 'gen_ai.request.runtime_context_messages', 'gen_ai.request.system_blocks', 'gen_ai.request.system_chars', 'gen_ai.request.text_blocks', @@ -863,6 +932,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.request.tool_use_blocks', 'gen_ai.request.tools.count', 'gen_ai.request.user_messages', + 'gen_ai.response.model', 'gen_ai.stream.phase.text.bytes', 'gen_ai.stream.phase.text.chunks', 'gen_ai.stream.phase.text.first_ms', @@ -876,8 +946,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.stream.phase.tool_args.first_ms', 'gen_ai.stream.phase.tool_args.ms', 'gen_ai.system', + 'gen_ai.token.type', 'gen_ai.tool.name', + 'gen_ai.usage.cache_creation.input_tokens', 'gen_ai.usage.cache_creation_tokens', + 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.cache_read_tokens', 'gen_ai.usage.input_tokens', 'gen_ai.usage.output_tokens', @@ -1049,7 +1122,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'tool.visible.names', 'trace.aborted', 'trace.billed_total_cost', + 'trace.cache_attempted_requests', + 'trace.cache_hit_requests', 'trace.cache_read_tokens', + 'trace.cache_write_requests', 'trace.cache_write_tokens', 'trace.duration_ms', 'trace.error', diff --git a/apps/sim/lib/copilot/generated/trace-events-v1.ts b/apps/sim/lib/copilot/generated/trace-events-v1.ts index b5aa8f71b2c..345606eff40 100644 --- a/apps/sim/lib/copilot/generated/trace-events-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-events-v1.ts @@ -19,6 +19,7 @@ export const TraceEvent = { CopilotVfsParseFailed: 'copilot.vfs.parse_failed', CopilotVfsResizeAttempt: 'copilot.vfs.resize_attempt', CopilotVfsResizeAttemptFailed: 'copilot.vfs.resize_attempt_failed', + GenAiPromptCacheBreakpoint: 'gen_ai.prompt_cache.breakpoint', LlmInvokeSent: 'llm.invoke.sent', LlmStreamFirstChunk: 'llm.stream.first_chunk', LlmStreamOpened: 'llm.stream.opened', @@ -41,6 +42,7 @@ export const TraceEventValues: readonly TraceEventValue[] = [ 'copilot.vfs.parse_failed', 'copilot.vfs.resize_attempt', 'copilot.vfs.resize_attempt_failed', + 'gen_ai.prompt_cache.breakpoint', 'llm.invoke.sent', 'llm.stream.first_chunk', 'llm.stream.opened', diff --git a/apps/sim/lib/copilot/request/trace.ts b/apps/sim/lib/copilot/request/trace.ts index cb399959d7d..1e67e9f5085 100644 --- a/apps/sim/lib/copilot/request/trace.ts +++ b/apps/sim/lib/copilot/request/trace.ts @@ -80,7 +80,16 @@ export class TraceCollector { // the moment it's first written instead of waiting on the late // analytics UPDATE. userMessage?: string - usage?: { prompt: number; completion: number } + usage?: { + prompt: number + completion: number + cacheAttemptedRequests?: number + cacheHitRequests?: number + cacheWriteRequests?: number + cacheReadTokens?: number + cacheWriteTokens?: number + cacheSavingsRate?: number + } cost?: { input: number; output: number; total: number } }): RequestTraceV1SimReport { const endMs = Date.now() @@ -88,6 +97,12 @@ export class TraceCollector { ? { inputTokens: params.usage.prompt, outputTokens: params.usage.completion, + cacheAttemptedRequests: params.usage.cacheAttemptedRequests ?? 0, + cacheHitRequests: params.usage.cacheHitRequests ?? 0, + cacheWriteRequests: params.usage.cacheWriteRequests ?? 0, + cacheReadTokens: params.usage.cacheReadTokens ?? 0, + cacheWriteTokens: params.usage.cacheWriteTokens ?? 0, + cacheSavingsRate: params.usage.cacheSavingsRate ?? 0, } : undefined diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 74fb0735a59..678eaa7f8b0 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -19,7 +19,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, desc, eq, isNotNull, isNull, ne, sql } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' -import { buildWorkspaceMd, type WorkspaceMdData } from '@/lib/copilot/chat/workspace-context' +import { + buildWorkspaceContextMd, + buildWorkspaceMd, + type WorkspaceMdData, +} from '@/lib/copilot/chat/workspace-context' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' @@ -62,6 +66,7 @@ import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/executi import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { getKnowledgeBases } from '@/lib/knowledge/service' import { validateMermaidSource } from '@/lib/mermaid/validate' +import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime' import { listTables } from '@/lib/table/service' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { @@ -305,7 +310,8 @@ function getStaticComponentFiles(): Map { * Virtual Filesystem that materializes workspace data into an in-memory Map. * * Structure: - * WORKSPACE.md — workspace identity, members, inventory (auto-generated) + * WORKSPACE_CONTEXT.md — full dynamic workspace/user context (auto-generated) + * WORKSPACE.md — workspace inventory summary (auto-generated) * workflows/{name}/meta.json (root-level workflows) * workflows/{name}/state.json (sanitized blocks with embedded connections) * workflows/{name}/executions.json @@ -367,6 +373,7 @@ export class WorkspaceVFS { jobsSummary, wsRow, members, + mothershipToolRuntime, ] = await Promise.all([ this.materializeWorkflows(workspaceId, userId), this.materializeKnowledgeBases(workspaceId, userId), @@ -380,25 +387,35 @@ export class WorkspaceVFS { this.materializeJobs(workspaceId), getWorkspaceWithOwner(workspaceId), getUsersWithPermissions(workspaceId), + buildMothershipToolsForRequest({ workspaceId, userId }).catch((error) => { + logger.warn('Failed to materialize Mothership tool catalog in VFS', { + workspaceId, + error: toError(error).message, + }) + return { tools: [] } + }), ]) + const workspaceMdData = { + workspace: wsRow, + members, + workflows: wfSummary, + knowledgeBases: kbSummary, + tables: tblSummary, + files: fileSummary, + oauthIntegrations: envSummary.oauthIntegrations, + envVariables: envSummary.envVariables, + tasks: taskSummary, + customTools: toolsSummary, + mcpServers: mcpServersSummary, + skills: skillsSummary, + jobs: jobsSummary, + } + + this.files.set('WORKSPACE.md', buildWorkspaceMd(workspaceMdData)) this.files.set( - 'WORKSPACE.md', - buildWorkspaceMd({ - workspace: wsRow, - members, - workflows: wfSummary, - knowledgeBases: kbSummary, - tables: tblSummary, - files: fileSummary, - oauthIntegrations: envSummary.oauthIntegrations, - envVariables: envSummary.envVariables, - tasks: taskSummary, - customTools: toolsSummary, - mcpServers: mcpServersSummary, - skills: skillsSummary, - jobs: jobsSummary, - }) + 'WORKSPACE_CONTEXT.md', + buildWorkspaceContextMd(workspaceMdData, mothershipToolRuntime.catalogContext) ) await this.materializeRecentlyDeleted(workspaceId, userId) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 9f22e9df0d5..f08211c0a38 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -182,12 +182,6 @@ export async function executeInboxTask(taskId: string): Promise { getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult - const workspaceContextWithMothershipTools = [ - workspaceContext, - mothershipToolRuntime.catalogContext, - ] - .filter(Boolean) - .join('\n\n') const truncatedTask = { ...inboxTask, @@ -203,7 +197,7 @@ export async function executeInboxTask(taskId: string): Promise { mode: 'agent', messageId: userMessageId, isHosted, - workspaceContext: workspaceContextWithMothershipTools, + workspaceContext, ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipToolRuntime.tools.length > 0 ? { mothershipTools: mothershipToolRuntime.tools } From 5308deb453b9d292fee1c34f46c88438cfc10455 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 20 May 2026 14:42:11 -0700 Subject: [PATCH 4/9] Fix regression --- apps/sim/app/api/workflows/[id]/route.test.ts | 50 ++++++++++++++ apps/sim/app/api/workflows/[id]/route.ts | 16 ++--- .../copilot/request/handlers/handlers.test.ts | 67 +++++++++++++++++-- apps/sim/lib/copilot/request/handlers/tool.ts | 12 +++- 4 files changed, 131 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index d752a3e6dc5..462c26e94aa 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -20,6 +20,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getWorkflowResponseDataSchema } from '@/lib/api/contracts/workflows' const mockLoadWorkflowFromNormalizedTables = workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables @@ -183,6 +184,55 @@ describe('Workflow By ID API Route', () => { expect(data.data.id).toBe('workflow-123') }) + it('omits null workflow description from state metadata so response validates', async () => { + const mockWorkflow = { + id: 'workflow-null-description', + userId: 'user-123', + name: 'No Description Workflow', + description: null, + workspaceId: 'workspace-456', + folderId: null, + sortOrder: 0, + color: '#3972F6', + lastSynced: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + isDeployed: false, + deployedAt: null, + isPublicApi: false, + locked: false, + runCount: 0, + lastRunAt: null, + archivedAt: null, + variables: {}, + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'admin', + }) + mockLoadWorkflowFromNormalizedTables.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-null-description') + const params = Promise.resolve({ id: 'workflow-null-description' }) + + const response = await GET(req, { params }) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data.state.metadata).toEqual({ name: 'No Description Workflow' }) + expect(getWorkflowResponseDataSchema.safeParse(data.data).success).toBe(true) + }) + it.concurrent('should allow access when user has workspace permissions', async () => { const mockWorkflow = { id: 'workflow-123', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 2f3f0ebe3ce..f6fda2d120e 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -108,6 +108,12 @@ export const GET = withRouteHandler( stampedVariables[variableId] = { ...variable, workflowId } } } + const workflowStateMetadata = { + name: responseWorkflowData.name, + ...(typeof responseWorkflowData.description === 'string' + ? { description: responseWorkflowData.description } + : {}), + } if (snapshot.normalizedData) { const finalWorkflowData = { @@ -120,10 +126,7 @@ export const GET = withRouteHandler( lastSaved: Date.now(), isDeployed: responseWorkflowData.isDeployed || false, deployedAt: responseWorkflowData.deployedAt, - metadata: { - name: responseWorkflowData.name, - description: responseWorkflowData.description, - }, + metadata: workflowStateMetadata, }, variables: stampedVariables, } @@ -145,10 +148,7 @@ export const GET = withRouteHandler( lastSaved: Date.now(), isDeployed: responseWorkflowData.isDeployed || false, deployedAt: responseWorkflowData.deployedAt, - metadata: { - name: responseWorkflowData.name, - description: responseWorkflowData.description, - }, + metadata: workflowStateMetadata, }, variables: stampedVariables, } diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 40af3110c62..a173ef968c0 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -12,10 +12,16 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered } = vi.hoisted(() = ensureHandlersRegistered: vi.fn(), })) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), +const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } = + vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), + markAsyncToolDelivered: vi.fn(), + })) + +const { waitForToolCompletion } = vi.hoisted(() => ({ + waitForToolCompletion: vi.fn(), })) vi.mock('@/lib/copilot/tool-executor', () => ({ @@ -34,16 +40,20 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ createRunCheckpoint: vi.fn(), getAsyncToolCall: vi.fn(), markAsyncToolStatus: vi.fn(), - markAsyncToolDelivered: vi.fn(), listAsyncToolCallsForRun: vi.fn(), getAsyncToolCalls: vi.fn(), claimCompletedAsyncToolCall: vi.fn(), releaseCompletedAsyncToolClaim: vi.fn(), upsertAsyncToolCall, markAsyncToolRunning, + markAsyncToolDelivered, completeAsyncToolCall, })) +vi.mock('@/lib/copilot/request/tools/client', () => ({ + waitForToolCompletion, +})) + import { MothershipStreamV1AsyncToolRecordStatus, MothershipStreamV1EventType, @@ -68,6 +78,8 @@ describe('sse-handlers tool lifecycle', () => { upsertAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) + markAsyncToolDelivered.mockResolvedValue(null) + waitForToolCompletion.mockResolvedValue(null) context = { chatId: undefined, messageId: 'msg-1', @@ -236,6 +248,51 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.result?.output).toBe('done') }) + it('marks background client workflow tools delivered after synthetic result emission', async () => { + waitForToolCompletion.mockResolvedValueOnce({ + status: 'background', + data: { detached: true }, + }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-background', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: true, timeout: 1000 } + ) + + await sleep(0) + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-background') + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + toolCallId: 'tool-background', + phase: MothershipStreamV1ToolPhase.result, + status: MothershipStreamV1ToolOutcome.skipped, + success: true, + output: { detached: true }, + }), + }) + ) + expect(context.toolCalls.get('tool-background')?.status).toBe( + MothershipStreamV1ToolOutcome.skipped + ) + }) + it('does not add hidden tool calls to content blocks', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { skill: 'ok' } }) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 1056ad0bee5..dad427fa458 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, @@ -457,6 +458,15 @@ async function dispatchToolExecution( span.setAttribute(TraceAttr.ToolOutcome, completion.status) } handleClientCompletion(toolCall, toolCallId, completion) + if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + await markAsyncToolDelivered(toolCallId).catch((err) => { + logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { + toolCallId, + toolName, + error: toError(err).message, + }) + }) + } await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return ( completion ?? { From 8e675582b0278d0dd8317c4e6ae4deef91c58d4b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 20 May 2026 16:43:31 -0700 Subject: [PATCH 5/9] updates to prompt caching --- apps/sim/lib/copilot/chat/post.test.ts | 8 +++++++ apps/sim/lib/copilot/chat/post.ts | 8 +++++-- .../copilot/generated/trace-attributes-v1.ts | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 8b937704ac6..fe9f9444fc5 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -167,8 +167,15 @@ describe('handleUnifiedChatPost', () => { ) expect(response.status).toBe(200) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-opus-4-7', + }), + { selectedModel: 'claude-opus-4-7' } + ) expect(createSSEStream).toHaveBeenCalledWith( expect.objectContaining({ + titleModel: 'claude-opus-4-7', workspaceId: 'ws-1', orchestrateOptions: expect.objectContaining({ workflowId: 'wf-1', @@ -206,6 +213,7 @@ describe('handleUnifiedChatPost', () => { ) expect(createSSEStream).toHaveBeenCalledWith( expect.objectContaining({ + titleModel: 'claude-opus-4-7', workspaceId: 'ws-1', orchestrateOptions: expect.objectContaining({ workspaceId: 'ws-1', diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index a7ba4573879..ac216efa0cc 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -50,7 +50,7 @@ import type { ChatContext } from '@/stores/panel' export const maxDuration = 3600 const logger = createLogger('UnifiedChatAPI') -const DEFAULT_MODEL = 'claude-opus-4-6' +const DEFAULT_MODEL = 'claude-opus-4-7' const FileAttachmentSchema = z.object({ id: z.string(), @@ -147,6 +147,7 @@ type UnifiedChatBranch = workflowId: string workflowName?: string workspaceId?: string + effectiveModel: string selectedModel: string mode: UnifiedChatRequest['mode'] provider?: string @@ -182,6 +183,7 @@ type UnifiedChatBranch = | { kind: 'workspace' workspaceId: string + effectiveModel: string goRoute: '/api/mothership' titleModel: string titleProvider?: undefined @@ -554,6 +556,7 @@ async function resolveBranch(params: { workflowId: resolvedWorkflowId, workflowName: resolved.workflowName, workspaceId: resolvedWorkspaceId, + effectiveModel: selectedModel, selectedModel, mode: mode ?? 'agent', provider, @@ -620,6 +623,7 @@ async function resolveBranch(params: { return { kind: 'workspace', workspaceId: requestedWorkspaceId, + effectiveModel: DEFAULT_MODEL, goRoute: '/api/mothership', titleModel: DEFAULT_MODEL, notifyWorkspaceStatus: true, @@ -821,7 +825,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { activeOtelRoot.setRequestShape({ branchKind: branch.kind, mode: body.mode, - model: body.model, + model: branch.effectiveModel, provider: body.provider, createNewChat: body.createNewChat, prefetch: body.prefetch, diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index e14368d9a6b..4842e0fac42 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -325,23 +325,38 @@ export const TraceAttr = { GenAiRequestMessagesCount: 'gen_ai.request.messages.count', GenAiRequestModel: 'gen_ai.request.model', GenAiRequestPromptCacheBreakpointCreated: 'gen_ai.request.prompt_cache.breakpoint.created', + GenAiRequestPromptCacheBreakpointDynamicWriteSuppressReason: + 'gen_ai.request.prompt_cache.breakpoint.dynamic_write_suppress_reason', + GenAiRequestPromptCacheBreakpointDynamicWriteSuppressed: + 'gen_ai.request.prompt_cache.breakpoint.dynamic_write_suppressed', GenAiRequestPromptCacheBreakpointEligible: 'gen_ai.request.prompt_cache.breakpoint.eligible', GenAiRequestPromptCacheBreakpointKind: 'gen_ai.request.prompt_cache.breakpoint.kind', + GenAiRequestPromptCacheBreakpointMinimumTokens: + 'gen_ai.request.prompt_cache.breakpoint.minimum_tokens', GenAiRequestPromptCacheBreakpointPrefixTokensEstimated: 'gen_ai.request.prompt_cache.breakpoint.prefix_tokens_estimated', + GenAiRequestPromptCacheBreakpointPreviousBoundaryTokens: + 'gen_ai.request.prompt_cache.breakpoint.previous_boundary_tokens', GenAiRequestPromptCacheBreakpointSection: 'gen_ai.request.prompt_cache.breakpoint.section', GenAiRequestPromptCacheBreakpointSkipReason: 'gen_ai.request.prompt_cache.breakpoint.skip_reason', GenAiRequestPromptCacheBreakpointTargetIndex: 'gen_ai.request.prompt_cache.breakpoint.target_index', GenAiRequestPromptCacheBreakpointTargetRole: 'gen_ai.request.prompt_cache.breakpoint.target_role', GenAiRequestPromptCacheBreakpointTargetType: 'gen_ai.request.prompt_cache.breakpoint.target_type', + GenAiRequestPromptCacheBreakpointThresholdTokens: + 'gen_ai.request.prompt_cache.breakpoint.threshold_tokens', GenAiRequestPromptCacheBreakpointTtl: 'gen_ai.request.prompt_cache.breakpoint.ttl', GenAiRequestPromptCacheBreakpointsCount: 'gen_ai.request.prompt_cache.breakpoints.count', GenAiRequestPromptCacheBreakpointsCreated: 'gen_ai.request.prompt_cache.breakpoints.created', + GenAiRequestPromptCacheDynamicMinimumTokens: 'gen_ai.request.prompt_cache.dynamic.minimum_tokens', GenAiRequestPromptCacheDynamicPrefixTokensEstimated: 'gen_ai.request.prompt_cache.dynamic.prefix_tokens_estimated', + GenAiRequestPromptCacheDynamicPreviousBoundaryTokens: + 'gen_ai.request.prompt_cache.dynamic.previous_boundary_tokens', GenAiRequestPromptCacheDynamicSkipReason: 'gen_ai.request.prompt_cache.dynamic.skip_reason', GenAiRequestPromptCacheDynamicTargetType: 'gen_ai.request.prompt_cache.dynamic.target_type', + GenAiRequestPromptCacheDynamicThresholdTokens: + 'gen_ai.request.prompt_cache.dynamic.threshold_tokens', GenAiRequestPromptCacheDynamicTtl: 'gen_ai.request.prompt_cache.dynamic.ttl', GenAiRequestPromptCacheDynamicUserEligible: 'gen_ai.request.prompt_cache.dynamic_user_eligible', GenAiRequestPromptCacheDynamicUserSkipReason: @@ -903,20 +918,28 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.request.messages.count', 'gen_ai.request.model', 'gen_ai.request.prompt_cache.breakpoint.created', + 'gen_ai.request.prompt_cache.breakpoint.dynamic_write_suppress_reason', + 'gen_ai.request.prompt_cache.breakpoint.dynamic_write_suppressed', 'gen_ai.request.prompt_cache.breakpoint.eligible', 'gen_ai.request.prompt_cache.breakpoint.kind', + 'gen_ai.request.prompt_cache.breakpoint.minimum_tokens', 'gen_ai.request.prompt_cache.breakpoint.prefix_tokens_estimated', + 'gen_ai.request.prompt_cache.breakpoint.previous_boundary_tokens', 'gen_ai.request.prompt_cache.breakpoint.section', 'gen_ai.request.prompt_cache.breakpoint.skip_reason', 'gen_ai.request.prompt_cache.breakpoint.target_index', 'gen_ai.request.prompt_cache.breakpoint.target_role', 'gen_ai.request.prompt_cache.breakpoint.target_type', + 'gen_ai.request.prompt_cache.breakpoint.threshold_tokens', 'gen_ai.request.prompt_cache.breakpoint.ttl', 'gen_ai.request.prompt_cache.breakpoints.count', 'gen_ai.request.prompt_cache.breakpoints.created', + 'gen_ai.request.prompt_cache.dynamic.minimum_tokens', 'gen_ai.request.prompt_cache.dynamic.prefix_tokens_estimated', + 'gen_ai.request.prompt_cache.dynamic.previous_boundary_tokens', 'gen_ai.request.prompt_cache.dynamic.skip_reason', 'gen_ai.request.prompt_cache.dynamic.target_type', + 'gen_ai.request.prompt_cache.dynamic.threshold_tokens', 'gen_ai.request.prompt_cache.dynamic.ttl', 'gen_ai.request.prompt_cache.dynamic_user_eligible', 'gen_ai.request.prompt_cache.dynamic_user_skip_reason', From 160b2b8b4d9a7400660fc04141ee815d7eae4955 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 20 May 2026 17:12:08 -0700 Subject: [PATCH 6/9] Prompt caching trace --- .../copilot/generated/trace-attributes-v1.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 4842e0fac42..e22d76ee902 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -20,6 +20,10 @@ export const TraceAttr = { AbortFound: 'abort.found', AbortRedisResult: 'abort.redis_result', AnalyticsAborted: 'analytics.aborted', + AnalyticsBilledCacheReadCost: 'analytics.billed_cache_read_cost', + AnalyticsBilledCacheWriteCost: 'analytics.billed_cache_write_cost', + AnalyticsBilledInputCost: 'analytics.billed_input_cost', + AnalyticsBilledOutputCost: 'analytics.billed_output_cost', AnalyticsBilledTotalCost: 'analytics.billed_total_cost', AnalyticsCacheAttemptedRequests: 'analytics.cache_attempted_requests', AnalyticsCacheHitRequests: 'analytics.cache_hit_requests', @@ -33,6 +37,11 @@ export const TraceAttr = { AnalyticsModel: 'analytics.model', AnalyticsOutputTokens: 'analytics.output_tokens', AnalyticsProvider: 'analytics.provider', + AnalyticsRawCacheReadCost: 'analytics.raw_cache_read_cost', + AnalyticsRawCacheWriteCost: 'analytics.raw_cache_write_cost', + AnalyticsRawInputCost: 'analytics.raw_input_cost', + AnalyticsRawOutputCost: 'analytics.raw_output_cost', + AnalyticsRawTotalCost: 'analytics.raw_total_cost', AnalyticsSource: 'analytics.source', AnalyticsToolCallCount: 'analytics.tool_call_count', ApiKeyId: 'api_key.id', @@ -613,6 +622,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'abort.found', 'abort.redis_result', 'analytics.aborted', + 'analytics.billed_cache_read_cost', + 'analytics.billed_cache_write_cost', + 'analytics.billed_input_cost', + 'analytics.billed_output_cost', 'analytics.billed_total_cost', 'analytics.cache_attempted_requests', 'analytics.cache_hit_requests', @@ -626,6 +639,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'analytics.model', 'analytics.output_tokens', 'analytics.provider', + 'analytics.raw_cache_read_cost', + 'analytics.raw_cache_write_cost', + 'analytics.raw_input_cost', + 'analytics.raw_output_cost', + 'analytics.raw_total_cost', 'analytics.source', 'analytics.tool_call_count', 'api_key.id', From 2f4e80c25f4032cb8430cc33b584231d6ca6271d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 21 May 2026 11:09:28 -0700 Subject: [PATCH 7/9] VFS updates --- .../app/api/function/execute/route.test.ts | 264 +++++++++- apps/sim/app/api/function/execute/route.ts | 351 +++++++++++-- apps/sim/lib/api/contracts/hotspots.ts | 48 ++ .../lib/copilot/generated/tool-catalog-v1.ts | 462 ++++++++++++----- .../lib/copilot/generated/tool-schemas-v1.ts | 479 ++++++++++++------ .../copilot/generated/trace-attributes-v1.ts | 8 + apps/sim/lib/copilot/request/tools/files.ts | 204 ++++++-- apps/sim/lib/copilot/resources/extraction.ts | 3 - apps/sim/lib/copilot/resources/types.ts | 1 + .../tools/handlers/function-execute.ts | 209 +++++++- .../lib/copilot/tools/handlers/param-types.ts | 5 +- .../copilot/tools/handlers/resources.test.ts | 34 +- .../lib/copilot/tools/handlers/resources.ts | 29 +- .../lib/copilot/tools/handlers/vfs.test.ts | 83 ++- apps/sim/lib/copilot/tools/handlers/vfs.ts | 37 +- .../copilot/tools/server/files/create-file.ts | 57 +-- .../copilot/tools/server/files/delete-file.ts | 28 +- .../files/download-to-workspace-file.ts | 48 +- .../tools/server/files/file-folders.ts | 163 +++++- .../copilot/tools/server/files/rename-file.ts | 21 +- .../tools/server/files/workspace-file.ts | 96 ++-- .../tools/server/image/generate-image.ts | 128 ++--- .../tools/server/knowledge/knowledge-base.ts | 12 +- apps/sim/lib/copilot/tools/server/router.ts | 4 - .../copilot/tools/server/table/user-table.ts | 6 +- .../visualization/generate-visualization.ts | 287 ----------- apps/sim/lib/copilot/vfs/file-reader.ts | 2 + apps/sim/lib/copilot/vfs/normalize-segment.ts | 17 +- apps/sim/lib/copilot/vfs/operations.ts | 6 + apps/sim/lib/copilot/vfs/path-utils.test.ts | 29 ++ apps/sim/lib/copilot/vfs/path-utils.ts | 60 +++ apps/sim/lib/copilot/vfs/resource-writer.ts | 192 +++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 164 ++++-- apps/sim/lib/execution/e2b.ts | 161 +++--- .../workspace/workspace-file-manager.ts | 13 +- apps/sim/next.config.ts | 2 + apps/sim/tools/function/execute.test.ts | 6 + apps/sim/tools/function/execute.ts | 17 + apps/sim/tools/function/types.ts | 18 +- 39 files changed, 2691 insertions(+), 1063 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts create mode 100644 apps/sim/lib/copilot/vfs/path-utils.test.ts create mode 100644 apps/sim/lib/copilot/vfs/path-utils.ts create mode 100644 apps/sim/lib/copilot/vfs/resource-writer.ts diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 3b191146c48..958361bfb7b 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -12,10 +12,22 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExecuteInE2B, mockExecuteInIsolatedVM, mockUploadFile } = vi.hoisted(() => ({ +const { + mockExecuteInE2B, + mockExecuteInIsolatedVM, + mockGetWorkspaceFile, + mockUpdateWorkspaceFileContent, + mockUploadFile, + mockValidateWorkspaceFileWriteTarget, + mockWriteWorkspaceFileByPath, +} = vi.hoisted(() => ({ mockExecuteInE2B: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockUpdateWorkspaceFileContent: vi.fn(), mockUploadFile: vi.fn(), + mockValidateWorkspaceFileWriteTarget: vi.fn(), + mockWriteWorkspaceFileByPath: vi.fn(), })) vi.mock('@/lib/execution/isolated-vm', () => ({ @@ -37,9 +49,40 @@ vi.mock('@/lib/copilot/request/tools/files', () => ({ }, normalizeOutputWorkspaceFileName: vi.fn((p: string) => p.replace(/^files\//, '')), resolveOutputFormat: vi.fn(() => 'json'), + getOutputFileDeclarations: vi.fn((params: Record) => { + if (Array.isArray(params.outputs?.files)) { + return params.outputs.files.map((file: Record) => ({ + path: file.path, + mode: file.mode === 'overwrite' ? 'overwrite' : 'create', + sandboxPath: file.sandboxPath, + mimeType: file.mimeType, + format: file.format, + })) + } + return params.outputPath + ? [ + { + path: params.overwriteFileId || params.outputPath, + mode: params.overwriteFileId ? 'overwrite' : 'create', + sandboxPath: params.outputSandboxPath, + mimeType: params.outputMimeType, + format: params.outputFormat, + formatPath: params.outputPath, + overwriteFileId: params.overwriteFileId, + }, + ] + : [] + }), +})) + +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ + validateWorkspaceFileWriteTarget: mockValidateWorkspaceFileWriteTarget, + writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + updateWorkspaceFileContent: mockUpdateWorkspaceFileContent, uploadWorkspaceFile: vi.fn(), })) @@ -79,6 +122,35 @@ describe('Function Execute API Route', () => { stdout: 'e2b output', sandboxId: 'test-sandbox-id', }) + mockGetWorkspaceFile.mockResolvedValue({ + id: 'wf_existing', + name: 'existing.png', + size: 10, + type: 'image/png', + url: '/api/files/view/existing', + key: 'workspace/existing.png', + }) + mockUpdateWorkspaceFileContent.mockResolvedValue({ + id: 'wf_existing', + name: 'existing.png', + size: 20, + type: 'image/png', + url: '/api/files/view/existing', + key: 'workspace/existing.png', + }) + mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({ + mode: target.mode, + vfsPath: target.path, + })) + mockWriteWorkspaceFileByPath.mockImplementation(async ({ target, buffer }) => ({ + id: `wf_${String(target.path).split('/').pop()?.replace(/\W+/g, '_') || 'file'}`, + name: String(target.path).split('/').pop() || 'file', + vfsPath: target.path, + downloadUrl: `/api/files/view/${encodeURIComponent(target.path)}`, + mode: target.mode, + size: buffer.length, + contentType: target.mimeType || 'application/octet-stream', + })) }) describe('Security Tests', () => { @@ -268,6 +340,196 @@ describe('Function Execute API Route', () => { expect(isLargeValueRef(data.output.result.text)).toBe(true) }) + it('exports multiple declared sandbox output files', async () => { + featureFlagsMock.isE2bEnabled = true + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { + '/home/user/chart.png': 'iVBORw0KGgo=', + '/home/user/summary.json': '{"ok":true}', + }, + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/reports/chart.png', + mode: 'create', + sandboxPath: '/home/user/chart.png', + mimeType: 'image/png', + }, + { + path: 'files/reports/summary.json', + mode: 'overwrite', + sandboxPath: '/home/user/summary.json', + mimeType: 'application/json', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockExecuteInE2B).toHaveBeenCalledWith( + expect.objectContaining({ + outputSandboxPaths: ['/home/user/chart.png', '/home/user/summary.json'], + }) + ) + expect(mockValidateWorkspaceFileWriteTarget).toHaveBeenCalledTimes(2) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2) + expect(mockWriteWorkspaceFileByPath).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + target: expect.objectContaining({ path: 'files/reports/chart.png', mode: 'create' }), + }) + ) + expect(mockWriteWorkspaceFileByPath).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + target: expect.objectContaining({ + path: 'files/reports/summary.json', + mode: 'overwrite', + }), + }) + ) + expect(data.output.result.files).toHaveLength(2) + expect(data.resources).toEqual([ + expect.objectContaining({ path: 'files/reports/chart.png' }), + expect.objectContaining({ path: 'files/reports/summary.json' }), + ]) + }) + + it('prevalidates all sandbox output destinations before writing any files', async () => { + featureFlagsMock.isE2bEnabled = true + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { + '/home/user/first.json': '{"first":true}', + '/home/user/second.json': '{"second":true}', + }, + }) + mockValidateWorkspaceFileWriteTarget + .mockResolvedValueOnce({ mode: 'create', vfsPath: 'files/first.json' }) + .mockRejectedValueOnce(new Error('Directory not yet created: files/missing')) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/first.json', + mode: 'create', + sandboxPath: '/home/user/first.json', + }, + { + path: 'files/missing/second.json', + mode: 'create', + sandboxPath: '/home/user/second.json', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toContain('Directory not yet created') + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('rejects duplicate sandbox output destinations before writing files', async () => { + featureFlagsMock.isE2bEnabled = true + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { + '/home/user/first.json': '{"first":true}', + '/home/user/second.json': '{"second":true}', + }, + }) + mockValidateWorkspaceFileWriteTarget.mockResolvedValue({ + mode: 'create', + vfsPath: 'files/dupe.json', + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/dupe.json', + mode: 'create', + sandboxPath: '/home/user/first.json', + }, + { + path: 'files/dupe.json', + mode: 'create', + sandboxPath: '/home/user/second.json', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toContain('Duplicate sandbox output destination') + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('returns a targeted error when a declared sandbox output is missing', async () => { + featureFlagsMock.isE2bEnabled = true + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: {}, + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/missing.json', + mode: 'create', + sandboxPath: '/home/user/missing.json', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toContain('Sandbox file "/home/user/missing.json" was not found') + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + it('should return computed result for multi-line code', async () => { mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' }) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 5fa058736d2..3bbeea60a8d 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -5,9 +5,15 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { FORMAT_TO_CONTENT_TYPE, + getOutputFileDeclarations, normalizeOutputWorkspaceFileName, + type OutputFileDeclaration, resolveOutputFormat, } from '@/lib/copilot/request/tools/files' +import { + validateWorkspaceFileWriteTarget, + writeWorkspaceFileByPath, +} from '@/lib/copilot/vfs/resource-writer' import { isE2bEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -29,7 +35,6 @@ import { import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { materializeLargeValueRef } from '@/lib/execution/payloads/store' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' @@ -48,6 +53,8 @@ const TAG_PATTERN = createReferencePattern() const E2B_JS_WRAPPER_LINES = 3 const E2B_PYTHON_WRAPPER_LINES = 1 +const MAX_SANDBOX_OUTPUT_FILES = 20 +const MAX_SANDBOX_OUTPUT_BYTES = 50 * 1024 * 1024 /** Matches valid JS identifier names (letters, digits, underscore; no leading digit). */ const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/ @@ -865,6 +872,8 @@ async function maybeExportSandboxFileToWorkspace(args: { outputFormat?: string outputMimeType?: string outputSandboxPath?: string + overwriteFileId?: string + outputMode?: 'create' | 'overwrite' exportedFileContent?: string stdout: string executionTime: number @@ -877,6 +886,8 @@ async function maybeExportSandboxFileToWorkspace(args: { outputFormat, outputMimeType, outputSandboxPath, + overwriteFileId, + outputMode, exportedFileContent, stdout, executionTime, @@ -933,28 +944,280 @@ async function maybeExportSandboxFileToWorkspace(args: { ? Buffer.from(exportedFileContent, 'base64') : Buffer.from(exportedFileContent, 'utf-8') - const uploaded = await uploadWorkspaceFile( - resolvedWorkspaceId, - authUserId, - fileBuffer, - fileName, - resolvedMimeType + const targetPath = overwriteFileId || outputPath + const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') + + try { + const written = await writeWorkspaceFileByPath({ + workspaceId: resolvedWorkspaceId, + userId: authUserId, + target: { + path: targetPath, + mode, + mimeType: outputMimeType, + }, + buffer: fileBuffer, + inferredMimeType: resolvedMimeType, + }) + logger.info('Sandbox file exported to workspace', { + fileId: written.id, + vfsPath: written.vfsPath, + sandboxPath: outputSandboxPath, + mode, + mimeType: resolvedMimeType, + size: fileBuffer.length, + }) + return NextResponse.json({ + success: true, + output: { + result: { + message: `Sandbox file exported to ${written.vfsPath}`, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, + downloadUrl: written.downloadUrl, + sandboxPath: outputSandboxPath, + }, + stdout: cleanStdout(stdout), + executionTime, + }, + resources: [{ type: 'file', id: written.id, title: written.name, path: written.vfsPath }], + }) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to export sandbox file', + output: { result: null, stdout: cleanStdout(stdout), executionTime }, + }, + { status: 400 } + ) + } +} + +async function maybeExportSandboxFilesToWorkspace(args: { + authUserId: string + workflowId?: string + workspaceId?: string + outputFiles: OutputFileDeclaration[] + exportedFiles?: Record + exportedFileContent?: string + stdout: string + executionTime: number +}) { + const sandboxFiles = args.outputFiles.filter((file) => file.sandboxPath) + if (sandboxFiles.length === 0) return null + if (sandboxFiles.length > MAX_SANDBOX_OUTPUT_FILES) { + return NextResponse.json( + { + success: false, + error: `Too many sandbox output files requested (${sandboxFiles.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`, + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + + if (sandboxFiles.length === 1) { + const file = sandboxFiles[0] + return maybeExportSandboxFileToWorkspace({ + authUserId: args.authUserId, + workflowId: args.workflowId, + workspaceId: args.workspaceId, + outputPath: file.formatPath ?? file.path, + outputFormat: file.format, + outputMimeType: file.mimeType, + outputSandboxPath: file.sandboxPath, + outputMode: file.mode, + exportedFileContent: + (file.sandboxPath ? args.exportedFiles?.[file.sandboxPath] : undefined) ?? + args.exportedFileContent, + stdout: args.stdout, + executionTime: args.executionTime, + }) + } + + const resolvedWorkspaceId = + args.workspaceId || + (args.workflowId ? (await getWorkflowById(args.workflowId))?.workspaceId : undefined) + if (!resolvedWorkspaceId) { + return NextResponse.json( + { + success: false, + error: 'Workspace context required to save sandbox files to workspace', + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + + const preparedFiles = [] + let totalOutputBytes = 0 + for (const file of sandboxFiles) { + const sandboxPath = file.sandboxPath! + const content = args.exportedFiles?.[sandboxPath] + if (content === undefined) { + return NextResponse.json( + { + success: false, + error: `Sandbox file "${sandboxPath}" was not found or could not be read`, + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 500 } + ) + } + const fileName = normalizeOutputWorkspaceFileName(file.formatPath ?? file.path) + const resolvedMimeType = + file.mimeType || + FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, file.format)] || + 'application/octet-stream' + const isBinary = !new Set(Object.values(FORMAT_TO_CONTENT_TYPE)).has(resolvedMimeType) + const size = Buffer.byteLength(content, isBinary ? 'base64' : 'utf-8') + totalOutputBytes += size + if (totalOutputBytes > MAX_SANDBOX_OUTPUT_BYTES) { + return NextResponse.json( + { + success: false, + error: `Sandbox output files exceed ${MAX_SANDBOX_OUTPUT_BYTES} bytes total`, + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + preparedFiles.push({ + file, + sandboxPath, + content, + resolvedMimeType, + isBinary, + size, + target: { + path: file.path, + mode: file.mode ?? 'create', + mimeType: file.mimeType, + }, + }) + } + + let validationPaths: string[] + try { + const validations = await Promise.all( + preparedFiles.map((prepared) => + validateWorkspaceFileWriteTarget({ + workspaceId: resolvedWorkspaceId, + target: prepared.target, + }) + ) + ) + validationPaths = validations.map((validation) => validation.vfsPath) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Invalid sandbox output destination', + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + const duplicateDestination = validationPaths.find( + (vfsPath, index) => validationPaths.indexOf(vfsPath) !== index ) + if (duplicateDestination) { + return NextResponse.json( + { + success: false, + error: `Duplicate sandbox output destination: ${duplicateDestination}`, + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + + const writtenFiles = [] + try { + for (const prepared of preparedFiles) { + const buffer = prepared.isBinary + ? Buffer.from(prepared.content, 'base64') + : Buffer.from(prepared.content, 'utf-8') + const written = await writeWorkspaceFileByPath({ + workspaceId: resolvedWorkspaceId, + userId: args.authUserId, + target: prepared.target, + buffer, + inferredMimeType: prepared.resolvedMimeType, + }) + logger.info('Sandbox file exported to workspace', { + fileId: written.id, + vfsPath: written.vfsPath, + sandboxPath: prepared.sandboxPath, + mode: prepared.file.mode ?? 'create', + mimeType: prepared.resolvedMimeType, + size: prepared.size, + }) + writtenFiles.push({ ...written, sandboxPath: prepared.sandboxPath }) + } + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to export sandbox files', + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } return NextResponse.json({ success: true, output: { result: { - message: `Sandbox file exported to files/${fileName}`, - fileId: uploaded.id, - fileName, - downloadUrl: uploaded.url, - sandboxPath: outputSandboxPath, + message: `Exported ${writtenFiles.length} sandbox files`, + files: writtenFiles.map((file) => ({ + fileId: file.id, + fileName: file.name, + vfsPath: file.vfsPath, + downloadUrl: file.downloadUrl, + sandboxPath: file.sandboxPath, + })), }, - stdout: cleanStdout(stdout), - executionTime, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, }, - resources: [{ type: 'file', id: uploaded.id, title: fileName }], + resources: writtenFiles.map((file) => ({ + type: 'file', + id: file.id, + title: file.name, + path: file.vfsPath, + })), }) } @@ -990,6 +1253,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { outputFormat, outputMimeType, outputSandboxPath, + overwriteFileId, + outputs, envVars = {}, blockData = {}, blockNameMapping = {}, @@ -1007,6 +1272,26 @@ export const POST = withRouteHandler(async (req: NextRequest) => { _sandboxFiles, } = body sourceCodeForErrors = sourceCode + const outputFiles = getOutputFileDeclarations({ + outputs, + outputPath, + outputFormat, + outputMimeType, + outputSandboxPath, + overwriteFileId, + }) + const outputSandboxPaths = outputFiles + .map((file) => file.sandboxPath) + .filter((path): path is string => Boolean(path)) + if (outputSandboxPaths.length > MAX_SANDBOX_OUTPUT_FILES) { + return NextResponse.json( + { + success: false, + error: `Too many sandbox output files requested (${outputSandboxPaths.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`, + }, + { status: 400 } + ) + } const executionParams = { ...params } executionParams._context = undefined @@ -1108,12 +1393,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxId, error: shellError, exportedFileContent, + exportedFiles, } = await executeShellInE2B({ code: resolvedCode, envs: shellEnvs, timeoutMs: timeout, sandboxFiles: _sandboxFiles, outputSandboxPath, + outputSandboxPaths, }) const executionTime = Date.now() - execStart @@ -1136,15 +1423,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - if (outputSandboxPath) { - const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + if (outputSandboxPaths.length > 0 || outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ authUserId: auth.userId, workflowId, workspaceId, - outputPath, - outputFormat, - outputMimeType, - outputSandboxPath, + outputFiles, + exportedFiles, exportedFileContent, stdout: shellStdout, executionTime, @@ -1237,12 +1522,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxId, error: e2bError, exportedFileContent, + exportedFiles, } = await executeInE2B({ code: codeForE2B, language: CodeLanguage.JavaScript, timeoutMs: timeout, sandboxFiles: _sandboxFiles, outputSandboxPath, + outputSandboxPaths, }) const executionTime = Date.now() - execStart stdout += e2bStdout @@ -1273,15 +1560,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - if (outputSandboxPath) { - const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + if (outputSandboxPaths.length > 0 || outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ authUserId: auth.userId, workflowId, workspaceId, - outputPath, - outputFormat, - outputMimeType, - outputSandboxPath, + outputFiles, + exportedFiles, exportedFileContent, stdout, executionTime, @@ -1324,12 +1609,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxId, error: e2bError, exportedFileContent, + exportedFiles, } = await executeInE2B({ code: codeForE2B, language: CodeLanguage.Python, timeoutMs: timeout, sandboxFiles: _sandboxFiles, outputSandboxPath, + outputSandboxPaths, }) const executionTime = Date.now() - execStart stdout += e2bStdout @@ -1360,15 +1647,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - if (outputSandboxPath) { - const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + if (outputSandboxPaths.length > 0 || outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ authUserId: auth.userId, workflowId, workspaceId, - outputPath, - outputFormat, - outputMimeType, - outputSandboxPath, + outputFiles, + exportedFiles, exportedFileContent, stdout, executionTime, diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index 7ea571b23d7..bc933fb88c0 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -81,6 +81,38 @@ export const wandGenerateStreamContract = defineRouteContract({ }, }) +const functionFileInputSchema = z + .object({ + path: z.string().min(1, 'Input file path is required'), + sandboxPath: z.string().optional(), + }) + .strict() + +const functionDirectoryInputSchema = z + .object({ + path: z.string().min(1, 'Input directory path is required'), + sandboxPath: z.string().optional(), + }) + .strict() + +const functionTableInputSchema = z + .object({ + path: z.string().optional(), + tableId: z.string().optional(), + sandboxPath: z.string().optional(), + }) + .strict() + +const functionOutputFileSchema = z + .object({ + path: z.string().min(1, 'Output file path is required'), + mode: z.enum(['create', 'overwrite']).default('create'), + sandboxPath: z.string().optional(), + format: z.enum(['json', 'csv', 'txt', 'md', 'html']).optional(), + mimeType: z.string().optional(), + }) + .strict() + export const functionExecuteContract = defineRouteContract({ method: 'POST', path: '/api/function/execute', @@ -90,11 +122,27 @@ export const functionExecuteContract = defineRouteContract({ params: unknownRecordSchema.optional().default({}), timeout: z.coerce.number().int().positive().optional(), language: z.string().optional().default(DEFAULT_CODE_LANGUAGE), + title: z.string().optional(), outputPath: z.string().optional(), outputFormat: z.string().optional(), outputTable: z.string().optional(), outputMimeType: z.string().optional(), outputSandboxPath: z.string().optional(), + overwriteFileId: z.string().optional(), + inputs: z + .object({ + files: z.array(functionFileInputSchema).optional(), + directories: z.array(functionDirectoryInputSchema).optional(), + tables: z.array(functionTableInputSchema).optional(), + }) + .strict() + .optional(), + outputs: z + .object({ + files: z.array(functionOutputFileSchema).optional(), + }) + .strict() + .optional(), envVars: z.record(z.string(), z.string()).optional().default({}), blockData: unknownRecordSchema.optional().default({}), blockNameMapping: z.record(z.string(), z.string()).optional().default({}), diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 44adc148635..ea73e890a2a 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3,6 +3,7 @@ // export interface ToolCatalogEntry { + capabilities?: unknown clientExecutable?: boolean hidden?: boolean id: @@ -35,7 +36,6 @@ export interface ToolCatalogEntry { | 'function_execute' | 'generate_api_key' | 'generate_image' - | 'generate_visualization' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' @@ -131,7 +131,6 @@ export interface ToolCatalogEntry { | 'function_execute' | 'generate_api_key' | 'generate_image' - | 'generate_visualization' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' @@ -338,21 +337,55 @@ export const CreateFile: ToolCatalogEntry = { fileName: { type: 'string', description: - 'Workspace filename or slash-separated file path including extension, e.g. "main.py", "report.md", or "Reports/2026/report.md".', + 'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls.', + }, + outputs: { + type: 'object', + description: 'Workspace file output declarations using canonical VFS paths.', + properties: { + files: { + type: 'array', + description: + 'Files to create or overwrite. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, }, - required: ['fileName'], }, resultSchema: { type: 'object', properties: { - data: { type: 'object', description: 'Contains id (the fileId) and name.' }, + data: { + type: 'object', + description: + 'Contains id (internal file ID), name, and vfsPath. Use vfsPath for follow-up file tools.', + }, message: { type: 'string', description: 'Human-readable outcome.' }, success: { type: 'boolean', description: 'Whether the file was created.' }, }, required: ['success', 'message'], }, requiredPermission: 'write', + capabilities: ['file_output'], } export const CreateFileFolder: ToolCatalogEntry = { @@ -363,14 +396,17 @@ export const CreateFileFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - name: { type: 'string', description: 'Folder name.' }, - parentId: { type: 'string', description: 'Optional parent file-folder ID.' }, + path: { + type: 'string', + description: + 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', + }, workspaceId: { type: 'string', description: 'Optional workspace ID. Defaults to the current workspace.', }, }, - required: ['name'], + required: ['path'], }, requiredPermission: 'write', } @@ -528,13 +564,14 @@ export const DeleteFile: ToolCatalogEntry = { parameters: { type: 'object', properties: { - fileIds: { + paths: { type: 'array', - description: 'Canonical workspace file IDs of the files to delete.', + description: + 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', items: { type: 'string' }, }, }, - required: ['fileIds'], + required: ['paths'], }, resultSchema: { type: 'object', @@ -555,13 +592,13 @@ export const DeleteFileFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - folderIds: { + paths: { type: 'array', - description: 'The workspace file-folder IDs to delete.', + description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', items: { type: 'string' }, }, }, - required: ['folderIds'], + required: ['paths'], }, requiresConfirmation: true, requiredPermission: 'write', @@ -950,7 +987,37 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { fileName: { type: 'string', description: - 'Optional workspace file name to save as. If omitted, the name is inferred from the response or URL.', + 'Backward-compatible workspace file name. Prefer outputs.files[0].path for new calls.', + }, + outputs: { + type: 'object', + description: 'Workspace file output declarations using canonical VFS paths.', + properties: { + files: { + type: 'array', + description: + 'Files to create or overwrite. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, url: { type: 'string', @@ -961,6 +1028,7 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { required: ['url'], }, requiredPermission: 'write', + capabilities: ['file_output'], } export const EditContent: ToolCatalogEntry = { @@ -1062,53 +1130,129 @@ export const FunctionExecute: ToolCatalogEntry = { description: 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', }, - inputFiles: { - type: 'array', - description: - 'Canonical workspace file IDs to mount in the sandbox. Discover IDs via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json"). Mounted path: /home/user/files/{fileId}/{originalName}. Example: ["wf_123"]', - items: { type: 'string' }, - }, - inputTables: { - type: 'array', + inputs: { + type: 'object', description: - 'Table IDs to mount as CSV files in the sandbox. Each table appears at /home/user/tables/{tableId}.csv with a header row. Example: ["tbl_abc123"]', - items: { type: 'string' }, + 'Workspace resources to mount into the sandbox. Copy canonical VFS paths exactly from glob/read/grep output.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/files/Reports".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/files/Reports/sales.csv".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, }, language: { type: 'string', description: 'Execution language.', enum: ['javascript', 'python', 'shell'], }, - outputFormat: { - type: 'string', - description: - 'Format for outputPath. Determines how the code result is serialized. If omitted, inferred from outputPath file extension.', - enum: ['json', 'csv', 'txt', 'md', 'html'], - }, - outputMimeType: { - type: 'string', - description: - 'MIME type for outputSandboxPath export. Required for binary files: image/png, image/jpeg, application/pdf, etc. Omit for text files.', - }, - outputPath: { + outputTable: { type: 'string', description: - 'Pipe output directly to a NEW workspace file instead of returning in context. ALWAYS use this instead of a separate workspace_file write call. Use a root path like "files/result.json" — nested output paths are not supported.', + 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', }, - outputSandboxPath: { - type: 'string', + outputs: { + type: 'object', description: - 'Path to a file created inside the sandbox that should be exported to the workspace. Use together with outputPath.', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, - outputTable: { + title: { type: 'string', description: - 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', + 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', }, }, required: ['code'], }, requiredPermission: 'write', + capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], } export const GenerateApiKey: ToolCatalogEntry = { @@ -1147,72 +1291,119 @@ export const GenerateImage: ToolCatalogEntry = { description: 'Aspect ratio for the generated image.', enum: ['1:1', '16:9', '9:16', '4:3', '3:4'], }, - fileName: { - type: 'string', + inputs: { + type: 'object', description: - 'Output file name. Defaults to "generated-image.png". New generated images currently create root workspace files, so pass a plain file name, not a nested path.', + 'Workspace resources to mount into the sandbox. Copy canonical VFS paths exactly from glob/read/grep output.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/files/Reports".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/files/Reports/sales.csv".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, }, - overwriteFileId: { - type: 'string', + outputs: { + type: 'object', description: - 'If provided, overwrites the existing workspace file with this ID instead of creating a new file. Use this when the user asks to update, refine, or redo a previously generated image so the existing chat resource stays current instead of creating a duplicate like "image (1).png". The file ID is returned by previous generate_image or generate_visualization calls (fileId field), or can be found via read("files/by-id/{fileId}/meta.json").', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, prompt: { type: 'string', description: 'Detailed text description of the image to generate, or editing instructions when used with editFileId.', }, - referenceFileIds: { - type: 'array', - description: - 'File IDs of workspace images to include as context for the generation. All images are sent alongside the prompt. Use for: editing a single image (1 file), compositing multiple images together (2+ files), style transfer, face swapping, etc. Order matters — list the primary/base image first. When revising an existing image in place, pair the primary file ID here with overwriteFileId set to that same ID.', - items: { type: 'string' }, - }, }, required: ['prompt'], }, requiredPermission: 'write', -} - -export const GenerateVisualization: ToolCatalogEntry = { - id: 'generate_visualization', - name: 'generate_visualization', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - code: { - type: 'string', - description: - "Python code that generates a visualization using matplotlib. MUST call plt.savefig('/home/user/output.png', dpi=150, bbox_inches='tight') to produce output.", - }, - fileName: { - type: 'string', - description: - 'Output file name. Defaults to "chart.png". New visualization outputs currently create root workspace files, so pass a plain file name, not a nested path.', - }, - inputFiles: { - type: 'array', - description: - 'Canonical workspace file IDs to mount in the sandbox. Discover IDs via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json"). Mounted path: /home/user/files/{fileId}/{originalName}.', - items: { type: 'string' }, - }, - inputTables: { - type: 'array', - description: - "Table IDs to mount as CSV files in the sandbox. Each table appears at /home/user/tables/{tableId}.csv with a header row. Read with pandas: pd.read_csv('/home/user/tables/tbl_xxx.csv')", - items: { type: 'string' }, - }, - overwriteFileId: { - type: 'string', - description: - 'If provided, overwrites the existing workspace file with this ID instead of creating a new file. Use this when the user asks to update, refine, or redo a previously generated chart so the existing chat resource stays current instead of creating a duplicate like "chart (1).png". The file ID is returned by previous generate_visualization or generate_image calls (fileId field), or can be found via read("files/by-id/{fileId}/meta.json").', - }, - }, - required: ['code'], - }, - requiredPermission: 'write', + capabilities: ['file_input', 'file_output', 'generated_media'], } export const GetBlockOutputs: ToolCatalogEntry = { @@ -1594,10 +1785,10 @@ export const KnowledgeBase: ToolCatalogEntry = { type: 'boolean', description: 'Enable/disable a document (optional for update_document)', }, - fileIds: { + filePaths: { type: 'array', description: - 'Canonical workspace file IDs to add as documents (for add_file). Discover via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json").', + 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', items: { type: 'string' }, }, filename: { @@ -2038,17 +2229,18 @@ export const MoveFile: ToolCatalogEntry = { parameters: { type: 'object', properties: { - fileIds: { + destinationPath: { + type: 'string', + description: + 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', + }, + paths: { type: 'array', - description: 'Canonical workspace file IDs to move.', + description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', items: { type: 'string' }, }, - folderId: { - type: 'string', - description: 'Target file-folder ID. Omit or pass empty string to move to workspace root.', - }, }, - required: ['fileIds'], + required: ['paths'], }, requiredPermission: 'write', } @@ -2061,14 +2253,17 @@ export const MoveFileFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - folderId: { type: 'string', description: 'The workspace file-folder ID to move.' }, - parentId: { + destinationPath: { type: 'string', description: - 'Target parent file-folder ID. Omit or pass empty string to move to workspace root.', + 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', + }, + path: { + type: 'string', + description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', }, }, - required: ['folderId'], + required: ['path'], }, requiredPermission: 'write', } @@ -2163,14 +2358,15 @@ export const OpenResource: ToolCatalogEntry = { properties: { resources: { type: 'array', - description: 'Array of resources to open. Each item must have type and id.', + description: + 'Array of resources to open. Each item must have type and either id or, for files, path.', items: { type: 'object', properties: { - id: { + id: { type: 'string', description: 'Canonical resource ID for non-file resources.' }, + path: { type: 'string', - description: - 'Canonical resource ID. For type "file" this must be a UUID from the workspace file meta.json "id" field—never a VFS path or display name.', + description: 'Canonical VFS path for type "file", e.g. "files/Reports/report.pdf".', }, type: { type: 'string', @@ -2178,7 +2374,7 @@ export const OpenResource: ToolCatalogEntry = { enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, }, - required: ['type', 'id'], + required: ['type'], }, }, }, @@ -2284,14 +2480,17 @@ export const RenameFile: ToolCatalogEntry = { parameters: { type: 'object', properties: { - fileId: { type: 'string', description: 'Canonical workspace file ID of the file to rename.' }, newName: { type: 'string', description: 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', }, + path: { + type: 'string', + description: 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', + }, }, - required: ['fileId', 'newName'], + required: ['path', 'newName'], }, resultSchema: { type: 'object', @@ -2313,10 +2512,13 @@ export const RenameFileFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - folderId: { type: 'string', description: 'The workspace file-folder ID to rename.' }, name: { type: 'string', description: 'New folder name.' }, + path: { + type: 'string', + description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', + }, }, - required: ['folderId', 'name'], + required: ['path', 'name'], }, requiredPermission: 'write', } @@ -2982,15 +3184,10 @@ export const UserTable: ToolCatalogEntry = { }, }, description: { type: 'string', description: "Table description (optional for 'create')" }, - fileId: { - type: 'string', - description: - 'Canonical workspace file ID for create_from_file/import_file. Discover via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json").', - }, filePath: { type: 'string', description: - 'Legacy workspace file reference for create_from_file/import_file. Prefer fileId.', + 'Canonical workspace file VFS path for create_from_file/import_file, e.g. files/{path}/{name}.', }, filter: { type: 'object', @@ -3248,21 +3445,17 @@ export const WorkspaceFile: ToolCatalogEntry = { }, target: { type: 'object', - description: 'Explicit file target. Use kind=file_id + fileId for existing files.', + description: 'Explicit file target. Use kind=path + path for existing files.', properties: { - fileId: { + kind: { type: 'string', - description: 'Canonical existing workspace file ID. Required when target.kind=file_id.', + description: 'How the file target is identified.', + enum: ['path'], }, - fileName: { + path: { type: 'string', description: - 'Plain workspace filename including extension, e.g. "main.py" or "report.docx". Required when target.kind=new_file.', - }, - kind: { - type: 'string', - description: 'How the file target is identified.', - enum: ['new_file', 'file_id'], + 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', }, }, required: ['kind'], @@ -3632,7 +3825,6 @@ export const TOOL_CATALOG: Record = { [FunctionExecute.id]: FunctionExecute, [GenerateApiKey.id]: GenerateApiKey, [GenerateImage.id]: GenerateImage, - [GenerateVisualization.id]: GenerateVisualization, [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index f381c961dee..4231d345fb7 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -125,17 +125,47 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { fileName: { type: 'string', description: - 'Workspace filename or slash-separated file path including extension, e.g. "main.py", "report.md", or "Reports/2026/report.md".', + 'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls.', + }, + outputs: { + type: 'object', + description: 'Workspace file output declarations using canonical VFS paths.', + properties: { + files: { + type: 'array', + description: + 'Files to create or overwrite. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, }, - required: ['fileName'], }, resultSchema: { type: 'object', properties: { data: { type: 'object', - description: 'Contains id (the fileId) and name.', + description: + 'Contains id (internal file ID), name, and vfsPath. Use vfsPath for follow-up file tools.', }, message: { type: 'string', @@ -153,20 +183,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - name: { - type: 'string', - description: 'Folder name.', - }, - parentId: { + path: { type: 'string', - description: 'Optional parent file-folder ID.', + description: + 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', }, workspaceId: { type: 'string', description: 'Optional workspace ID. Defaults to the current workspace.', }, }, - required: ['name'], + required: ['path'], }, resultSchema: undefined, }, @@ -322,15 +349,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - fileIds: { + paths: { type: 'array', - description: 'Canonical workspace file IDs of the files to delete.', + description: + 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', items: { type: 'string', }, }, }, - required: ['fileIds'], + required: ['paths'], }, resultSchema: { type: 'object', @@ -351,15 +379,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - folderIds: { + paths: { type: 'array', - description: 'The workspace file-folder IDs to delete.', + description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', items: { type: 'string', }, }, }, - required: ['folderIds'], + required: ['paths'], }, resultSchema: undefined, }, @@ -767,7 +795,37 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { fileName: { type: 'string', description: - 'Optional workspace file name to save as. If omitted, the name is inferred from the response or URL.', + 'Backward-compatible workspace file name. Prefer outputs.files[0].path for new calls.', + }, + outputs: { + type: 'object', + description: 'Workspace file output declarations using canonical VFS paths.', + properties: { + files: { + type: 'array', + description: + 'Files to create or overwrite. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, url: { type: 'string', @@ -865,20 +923,73 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', }, - inputFiles: { - type: 'array', - description: - 'Canonical workspace file IDs to mount in the sandbox. Discover IDs via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json"). Mounted path: /home/user/files/{fileId}/{originalName}. Example: ["wf_123"]', - items: { - type: 'string', - }, - }, - inputTables: { - type: 'array', + inputs: { + type: 'object', description: - 'Table IDs to mount as CSV files in the sandbox. Each table appears at /home/user/tables/{tableId}.csv with a header row. Example: ["tbl_abc123"]', - items: { - type: 'string', + 'Workspace resources to mount into the sandbox. Copy canonical VFS paths exactly from glob/read/grep output.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/files/Reports".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/files/Reports/sales.csv".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, }, }, language: { @@ -886,31 +997,55 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Execution language.', enum: ['javascript', 'python', 'shell'], }, - outputFormat: { - type: 'string', - description: - 'Format for outputPath. Determines how the code result is serialized. If omitted, inferred from outputPath file extension.', - enum: ['json', 'csv', 'txt', 'md', 'html'], - }, - outputMimeType: { - type: 'string', - description: - 'MIME type for outputSandboxPath export. Required for binary files: image/png, image/jpeg, application/pdf, etc. Omit for text files.', - }, - outputPath: { + outputTable: { type: 'string', description: - 'Pipe output directly to a NEW workspace file instead of returning in context. ALWAYS use this instead of a separate workspace_file write call. Use a root path like "files/result.json" — nested output paths are not supported.', + 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', }, - outputSandboxPath: { - type: 'string', + outputs: { + type: 'object', description: - 'Path to a file created inside the sandbox that should be exported to the workspace. Use together with outputPath.', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, - outputTable: { + title: { type: 'string', description: - 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', + 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', }, }, required: ['code'], @@ -944,74 +1079,125 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Aspect ratio for the generated image.', enum: ['1:1', '16:9', '9:16', '4:3', '3:4'], }, - fileName: { - type: 'string', + inputs: { + type: 'object', description: - 'Output file name. Defaults to "generated-image.png". New generated images currently create root workspace files, so pass a plain file name, not a nested path.', + 'Workspace resources to mount into the sandbox. Copy canonical VFS paths exactly from glob/read/grep output.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/files/Reports".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/files/Reports/sales.csv".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, }, - overwriteFileId: { - type: 'string', + outputs: { + type: 'object', description: - 'If provided, overwrites the existing workspace file with this ID instead of creating a new file. Use this when the user asks to update, refine, or redo a previously generated image so the existing chat resource stays current instead of creating a duplicate like "image (1).png". The file ID is returned by previous generate_image or generate_visualization calls (fileId field), or can be found via read("files/by-id/{fileId}/meta.json").', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, prompt: { type: 'string', description: 'Detailed text description of the image to generate, or editing instructions when used with editFileId.', }, - referenceFileIds: { - type: 'array', - description: - 'File IDs of workspace images to include as context for the generation. All images are sent alongside the prompt. Use for: editing a single image (1 file), compositing multiple images together (2+ files), style transfer, face swapping, etc. Order matters — list the primary/base image first. When revising an existing image in place, pair the primary file ID here with overwriteFileId set to that same ID.', - items: { - type: 'string', - }, - }, }, required: ['prompt'], }, resultSchema: undefined, }, - ['generate_visualization']: { - parameters: { - type: 'object', - properties: { - code: { - type: 'string', - description: - "Python code that generates a visualization using matplotlib. MUST call plt.savefig('/home/user/output.png', dpi=150, bbox_inches='tight') to produce output.", - }, - fileName: { - type: 'string', - description: - 'Output file name. Defaults to "chart.png". New visualization outputs currently create root workspace files, so pass a plain file name, not a nested path.', - }, - inputFiles: { - type: 'array', - description: - 'Canonical workspace file IDs to mount in the sandbox. Discover IDs via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json"). Mounted path: /home/user/files/{fileId}/{originalName}.', - items: { - type: 'string', - }, - }, - inputTables: { - type: 'array', - description: - "Table IDs to mount as CSV files in the sandbox. Each table appears at /home/user/tables/{tableId}.csv with a header row. Read with pandas: pd.read_csv('/home/user/tables/tbl_xxx.csv')", - items: { - type: 'string', - }, - }, - overwriteFileId: { - type: 'string', - description: - 'If provided, overwrites the existing workspace file with this ID instead of creating a new file. Use this when the user asks to update, refine, or redo a previously generated chart so the existing chat resource stays current instead of creating a duplicate like "chart (1).png". The file ID is returned by previous generate_visualization or generate_image calls (fileId field), or can be found via read("files/by-id/{fileId}/meta.json").', - }, - }, - required: ['code'], - }, - resultSchema: undefined, - }, ['get_block_outputs']: { parameters: { type: 'object', @@ -1387,10 +1573,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'boolean', description: 'Enable/disable a document (optional for update_document)', }, - fileIds: { + filePaths: { type: 'array', description: - 'Canonical workspace file IDs to add as documents (for add_file). Discover via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json").', + 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', items: { type: 'string', }, @@ -1844,20 +2030,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - fileIds: { + destinationPath: { + type: 'string', + description: + 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', + }, + paths: { type: 'array', - description: 'Canonical workspace file IDs to move.', + description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', items: { type: 'string', }, }, - folderId: { - type: 'string', - description: - 'Target file-folder ID. Omit or pass empty string to move to workspace root.', - }, }, - required: ['fileIds'], + required: ['paths'], }, resultSchema: undefined, }, @@ -1865,17 +2051,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - folderId: { + destinationPath: { type: 'string', - description: 'The workspace file-folder ID to move.', + description: + 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', }, - parentId: { + path: { type: 'string', - description: - 'Target parent file-folder ID. Omit or pass empty string to move to workspace root.', + description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', }, }, - required: ['folderId'], + required: ['path'], }, resultSchema: undefined, }, @@ -1951,14 +2137,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { resources: { type: 'array', - description: 'Array of resources to open. Each item must have type and id.', + description: + 'Array of resources to open. Each item must have type and either id or, for files, path.', items: { type: 'object', properties: { id: { type: 'string', - description: - 'Canonical resource ID. For type "file" this must be a UUID from the workspace file meta.json "id" field—never a VFS path or display name.', + description: 'Canonical resource ID for non-file resources.', + }, + path: { + type: 'string', + description: 'Canonical VFS path for type "file", e.g. "files/Reports/report.pdf".', }, type: { type: 'string', @@ -1966,7 +2156,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, }, - required: ['type', 'id'], + required: ['type'], }, }, }, @@ -2073,17 +2263,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - fileId: { - type: 'string', - description: 'Canonical workspace file ID of the file to rename.', - }, newName: { type: 'string', description: 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', }, + path: { + type: 'string', + description: + 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', + }, }, - required: ['fileId', 'newName'], + required: ['path', 'newName'], }, resultSchema: { type: 'object', @@ -2108,16 +2299,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - folderId: { - type: 'string', - description: 'The workspace file-folder ID to rename.', - }, name: { type: 'string', description: 'New folder name.', }, + path: { + type: 'string', + description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', + }, }, - required: ['folderId', 'name'], + required: ['path', 'name'], }, resultSchema: undefined, }, @@ -2760,15 +2951,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: "Table description (optional for 'create')", }, - fileId: { - type: 'string', - description: - 'Canonical workspace file ID for create_from_file/import_file. Discover via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json").', - }, filePath: { type: 'string', description: - 'Legacy workspace file reference for create_from_file/import_file. Prefer fileId.', + 'Canonical workspace file VFS path for create_from_file/import_file, e.g. files/{path}/{name}.', }, filter: { type: 'object', @@ -3051,22 +3237,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, target: { type: 'object', - description: 'Explicit file target. Use kind=file_id + fileId for existing files.', + description: 'Explicit file target. Use kind=path + path for existing files.', properties: { - fileId: { + kind: { type: 'string', - description: - 'Canonical existing workspace file ID. Required when target.kind=file_id.', + description: 'How the file target is identified.', + enum: ['path'], }, - fileName: { + path: { type: 'string', description: - 'Plain workspace filename including extension, e.g. "main.py" or "report.docx". Required when target.kind=new_file.', - }, - kind: { - type: 'string', - description: 'How the file target is identified.', - enum: ['new_file', 'file_id'], + 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', }, }, required: ['kind'], diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index e22d76ee902..3d3ca5ab59e 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -186,8 +186,12 @@ export const TraceAttr = { CopilotOutputFileBytes: 'copilot.output_file.bytes', CopilotOutputFileFormat: 'copilot.output_file.format', CopilotOutputFileId: 'copilot.output_file.id', + CopilotOutputFileMimeType: 'copilot.output_file.mime_type', + CopilotOutputFileMode: 'copilot.output_file.mode', CopilotOutputFileName: 'copilot.output_file.name', CopilotOutputFileOutcome: 'copilot.output_file.outcome', + CopilotOutputFilePath: 'copilot.output_file.path', + CopilotOutputFileSandboxPath: 'copilot.output_file.sandbox_path', CopilotPendingStreamWaitMs: 'copilot.pending_stream.wait_ms', CopilotPrefetch: 'copilot.prefetch', CopilotPublisherClientDisconnected: 'copilot.publisher.client_disconnected', @@ -788,8 +792,12 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.output_file.bytes', 'copilot.output_file.format', 'copilot.output_file.id', + 'copilot.output_file.mime_type', + 'copilot.output_file.mode', 'copilot.output_file.name', 'copilot.output_file.outcome', + 'copilot.output_file.path', + 'copilot.output_file.sandbox_path', 'copilot.pending_stream.wait_ms', 'copilot.prefetch', 'copilot.publisher.client_disconnected', diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 4ca8e11b408..82fa7a00a77 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -7,13 +7,16 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + parseWorkspaceFileCreatePath, + writeWorkspaceFileByPath, +} from '@/lib/copilot/vfs/resource-writer' const logger = createLogger('CopilotToolResultFiles') export const OUTPUT_PATH_TOOLS: Set = new Set([FunctionExecute.id, UserTable.id]) -type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html' +export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html' export const EXT_TO_FORMAT: Record = { '.json': 'json', @@ -111,17 +114,7 @@ export function convertRowsToCsv(rows: Record[]): string { } export function normalizeOutputWorkspaceFileName(outputPath: string): string { - const trimmed = outputPath.trim().replace(/^\/+/, '') - const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed - if (!withoutPrefix) { - throw new Error('outputPath must include a file name, e.g. "files/result.json"') - } - if (withoutPrefix.includes('/')) { - throw new Error( - 'outputPath must target a flat workspace file, e.g. "files/result.json". Nested paths like "files/reports/result.json" are not supported.' - ) - } - return withoutPrefix + return parseWorkspaceFileCreatePath(outputPath).fileName } export function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat { @@ -145,6 +138,62 @@ export function serializeOutputForFile(output: unknown, format: OutputFormat): s return JSON.stringify(unwrapped, null, 2) } +export interface OutputFileDeclaration { + path: string + mode?: 'create' | 'overwrite' + format?: OutputFormat + mimeType?: string + sandboxPath?: string + formatPath?: string +} + +export function getOutputFileDeclarations( + params: Record | undefined +): OutputFileDeclaration[] { + const args = params?.args as Record | undefined + const outputs = + (params?.outputs as { files?: unknown[] } | undefined) ?? + (args?.outputs as { files?: unknown[] } | undefined) + + if (Array.isArray(outputs?.files)) { + return outputs.files.flatMap((item): OutputFileDeclaration[] => { + if (!item || typeof item !== 'object') return [] + const file = item as Record + if (typeof file.path !== 'string') return [] + return [ + { + path: file.path, + mode: file.mode === 'overwrite' ? 'overwrite' : 'create', + format: typeof file.format === 'string' ? (file.format as OutputFormat) : undefined, + mimeType: typeof file.mimeType === 'string' ? file.mimeType : undefined, + sandboxPath: typeof file.sandboxPath === 'string' ? file.sandboxPath : undefined, + }, + ] + }) + } + + const outputPath = + (params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined) + if (!outputPath) return [] + const overwriteFileId = + (params?.overwriteFileId as string | undefined) ?? (args?.overwriteFileId as string | undefined) + return [ + { + path: overwriteFileId || outputPath, + mode: overwriteFileId ? 'overwrite' : 'create', + formatPath: outputPath, + format: ((params?.outputFormat as string | undefined) ?? + (args?.outputFormat as string | undefined)) as OutputFormat | undefined, + mimeType: + (params?.outputMimeType as string | undefined) ?? + (args?.outputMimeType as string | undefined), + sandboxPath: + (params?.outputSandboxPath as string | undefined) ?? + (args?.outputSandboxPath as string | undefined), + }, + ] +} + export async function maybeWriteOutputToFile( toolName: string, params: Record | undefined, @@ -155,17 +204,26 @@ export async function maybeWriteOutputToFile( if (!OUTPUT_PATH_TOOLS.has(toolName)) return result if (!context.workspaceId || !context.userId) return result - const args = params?.args as Record | undefined - const outputPath = - (params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined) - if (!outputPath) return result - const outputSandboxPath = - (params?.outputSandboxPath as string | undefined) ?? - (args?.outputSandboxPath as string | undefined) - if (toolName === FunctionExecute.id && outputSandboxPath) return result + const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath) + if (outputFiles.length === 0) return result - const explicitFormat = - (params?.outputFormat as string | undefined) ?? (args?.outputFormat as string | undefined) + const outputObject = + result.output && typeof result.output === 'object' && !Array.isArray(result.output) + ? (result.output as Record) + : undefined + const resultObject = + outputObject?.result && + typeof outputObject.result === 'object' && + !Array.isArray(outputObject.result) + ? (outputObject.result as Record) + : undefined + if (Array.isArray(resultObject?.files)) { + logger.warn('Skipping returned-value output write because sandbox export response is active', { + toolName, + outputCount: outputFiles.length, + }) + return result + } // Only span the actual write path (where we upload to storage). Fast // no-op returns above don't need a span — they'd just pad the trace @@ -178,58 +236,92 @@ export async function maybeWriteOutputToFile( }, async (span) => { try { - const fileName = normalizeOutputWorkspaceFileName(outputPath) - const format = resolveOutputFormat(fileName, explicitFormat) - span.setAttributes({ - [TraceAttr.CopilotOutputFileName]: fileName, - [TraceAttr.CopilotOutputFileFormat]: format, - }) - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - const content = serializeOutputForFile(result.output, format) - const contentType = FORMAT_TO_CONTENT_TYPE[format] + const writtenFiles = [] + for (const outputFile of outputFiles) { + const fileName = normalizeOutputWorkspaceFileName( + outputFile.formatPath ?? outputFile.path + ) + const format = resolveOutputFormat(fileName, outputFile.format) + const content = serializeOutputForFile(result.output, format) + const contentType = outputFile.mimeType || FORMAT_TO_CONTENT_TYPE[format] + const buffer = Buffer.from(content, 'utf-8') - const buffer = Buffer.from(content, 'utf-8') - span.setAttribute(TraceAttr.CopilotOutputFileBytes, buffer.length) - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + + const written = await writeWorkspaceFileByPath({ + workspaceId: context.workspaceId!, + userId: context.userId!, + target: { + path: outputFile.path, + mode: outputFile.mode ?? 'create', + mimeType: outputFile.mimeType, + }, + buffer, + inferredMimeType: contentType, + }) + writtenFiles.push({ + ...written, + bytes: buffer.length, + format, + requestedPath: outputFile.path, + }) } - const uploaded = await uploadWorkspaceFile( - context.workspaceId!, - context.userId!, - buffer, - fileName, - contentType - ) + + const firstWritten = writtenFiles[0] span.setAttributes({ - [TraceAttr.CopilotOutputFileId]: uploaded.id, + [TraceAttr.CopilotOutputFileId]: firstWritten.id, + [TraceAttr.CopilotOutputFileName]: firstWritten.name, + [TraceAttr.CopilotOutputFileFormat]: firstWritten.format, + [TraceAttr.CopilotOutputFilePath]: firstWritten.vfsPath, + [TraceAttr.CopilotOutputFileMode]: firstWritten.mode, + [TraceAttr.CopilotOutputFileBytes]: firstWritten.bytes, [TraceAttr.CopilotOutputFileOutcome]: CopilotOutputFileOutcome.Uploaded, }) logger.info('Tool output written to file', { toolName, - fileName, - size: buffer.length, - fileId: uploaded.id, + outputCount: writtenFiles.length, + files: writtenFiles.map((file) => ({ + fileId: file.id, + vfsPath: file.vfsPath, + size: file.bytes, + })), }) return { success: true, output: { - message: `Output written to files/${fileName} (${buffer.length} bytes)`, - fileId: uploaded.id, - fileName, - size: buffer.length, - downloadUrl: uploaded.url, + message: + writtenFiles.length === 1 + ? `Output ${firstWritten.mode === 'overwrite' ? 'updated' : 'written'} at ${firstWritten.vfsPath} (${firstWritten.bytes} bytes)` + : `Output written to ${writtenFiles.length} files`, + files: writtenFiles.map((file) => ({ + fileId: file.id, + fileName: file.name, + vfsPath: file.vfsPath, + size: file.bytes, + downloadUrl: file.downloadUrl, + })), + fileId: firstWritten.id, + fileName: firstWritten.name, + vfsPath: firstWritten.vfsPath, + size: firstWritten.bytes, + downloadUrl: firstWritten.downloadUrl, }, - resources: [{ type: 'file', id: uploaded.id, title: fileName }], + resources: writtenFiles.map((file) => ({ + type: 'file', + id: file.id, + title: file.name, + path: file.vfsPath, + })), } } catch (err) { const message = toError(err).message logger.warn('Failed to write tool output to file', { toolName, - outputPath, + outputPaths: outputFiles.map((file) => file.path), error: message, }) span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed) diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 6cba5a3bee0..598cea6ddc7 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -6,7 +6,6 @@ import { EditWorkflow, FunctionExecute, GenerateImage, - GenerateVisualization, Knowledge, KnowledgeBase, UserTable, @@ -27,7 +26,6 @@ const RESOURCE_TOOL_NAMES: Set = new Set([ FunctionExecute.id, KnowledgeBase.id, Knowledge.id, - GenerateVisualization.id, GenerateImage.id, ]) @@ -143,7 +141,6 @@ export function extractResourcesFromToolResult( } case DownloadToWorkspaceFile.id: - case GenerateVisualization.id: case GenerateImage.id: { if (result.fileId) { return [ diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index bf8a23374c0..09edda0af8c 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -16,6 +16,7 @@ export interface MothershipResource { type: MothershipResourceType id: string title: string + path?: string } export function isEphemeralResource(resource: MothershipResource): boolean { diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 0968b3ffa86..941017b7e85 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { getTableById, queryRows } from '@/lib/table/service' +import { encodeVfsPathSegments, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { getTableById, listTables, queryRows } from '@/lib/table/service' +import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchWorkspaceFileBuffer, findWorkspaceFileRecord, @@ -13,16 +15,55 @@ const logger = createLogger('CopilotFunctionExecute') const MAX_FILE_SIZE = 10 * 1024 * 1024 const MAX_TOTAL_SIZE = 50 * 1024 * 1024 +const MAX_MOUNTED_FILES = 500 interface SandboxFile { path: string content: string + encoding?: 'base64' +} + +interface CanonicalFileInput { + path: string + sandboxPath?: string +} + +interface CanonicalDirectoryInput { + path: string + sandboxPath?: string +} + +interface CanonicalTableInput { + tableId?: string + path?: string + sandboxPath?: string +} + +function tableNameFromVfsPath(tableRef: string): string | null { + if (!tableRef.startsWith('tables/')) return null + const segments = decodeVfsPathSegments(tableRef) + const metaIndex = segments.lastIndexOf('meta.json') + return segments[metaIndex > 0 ? metaIndex - 1 : segments.length - 1] ?? null +} + +async function resolveTableRef( + tableRef: string, + tablePathLookup?: Map>[number]> +) { + if (!tableRef.startsWith('tables/')) { + return getTableById(tableRef) + } + + const tableName = tableNameFromVfsPath(tableRef) + if (!tableName) return null + return tablePathLookup?.get(tableName) ?? null } async function resolveInputFiles( workspaceId: string, inputFiles?: unknown[], - inputTables?: unknown[] + inputTables?: unknown[], + inputDirectories?: unknown[] ): Promise { const sandboxFiles: SandboxFile[] = [] let totalSize = 0 @@ -30,8 +71,14 @@ async function resolveInputFiles( if (inputFiles?.length && workspaceId) { const allFiles = await listWorkspaceFiles(workspaceId) for (const fileRef of inputFiles) { - if (typeof fileRef !== 'string') continue - const record = findWorkspaceFileRecord(allFiles, fileRef) + const filePath = + typeof fileRef === 'string' + ? fileRef + : fileRef && typeof fileRef === 'object' + ? (fileRef as CanonicalFileInput).path + : undefined + if (!filePath) continue + const record = findWorkspaceFileRecord(allFiles, filePath) if (!record) { logger.warn('Input file not found', { fileRef }) continue @@ -50,27 +97,130 @@ async function resolveInputFiles( record.type || '' ) const content = isText ? buffer.toString('utf-8') : buffer.toString('base64') + const explicitSandboxPath = + typeof fileRef === 'object' && fileRef !== null + ? (fileRef as CanonicalFileInput).sandboxPath + : undefined sandboxFiles.push({ - path: getSandboxWorkspaceFilePath(record), + path: explicitSandboxPath || getSandboxWorkspaceFilePath(record), content, encoding: isText ? undefined : 'base64', - } as SandboxFile) + }) + } + } + + if (inputDirectories?.length && workspaceId) { + const folders = await listWorkspaceFileFolders(workspaceId) + const allFiles = await listWorkspaceFiles(workspaceId, { folders }) + for (const dirRef of inputDirectories) { + const dirPath = + typeof dirRef === 'string' + ? dirRef + : dirRef && typeof dirRef === 'object' + ? (dirRef as CanonicalDirectoryInput).path + : undefined + if (!dirPath) continue + const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, '')) + const folderDisplayPath = folderSegments.join('/') + const folder = folders.find((candidate) => candidate.path === folderDisplayPath) + if (!folder) { + throw new Error(`Input directory not found: ${dirPath}`) + } + const mountRoot = + typeof dirRef === 'object' && + dirRef !== null && + (dirRef as CanonicalDirectoryInput).sandboxPath + ? (dirRef as CanonicalDirectoryInput).sandboxPath! + : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` + const descendants = allFiles.filter((file) => { + if (!file.folderPath) return false + return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) + }) + if (descendants.length > MAX_MOUNTED_FILES) { + throw new Error( + `Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.` + ) + } + logger.info('Mounting workspace directory for function_execute', { + vfsPath: dirPath, + sandboxPath: mountRoot, + fileCount: descendants.length, + }) + const childFolders = folders.filter( + (candidate) => + candidate.path !== folder.path && candidate.path.startsWith(`${folder.path}/`) + ) + if (descendants.length === 0 && childFolders.length === 0) { + sandboxFiles.push({ path: `${mountRoot}/.keep`, content: '' }) + continue + } + for (const childFolder of childFolders) { + const hasFiles = descendants.some((file) => { + if (!file.folderPath) return false + return ( + file.folderPath === childFolder.path || + file.folderPath.startsWith(`${childFolder.path}/`) + ) + }) + if (!hasFiles) { + const relativeFolder = childFolder.path.slice(folder.path.length).replace(/^\/+/, '') + sandboxFiles.push({ path: `${mountRoot}/${relativeFolder}/.keep`, content: '' }) + } + } + for (const record of descendants) { + if (record.size > MAX_FILE_SIZE) { + throw new Error(`Input file exceeds size limit: ${record.name}`) + } + if (totalSize + record.size > MAX_TOTAL_SIZE) { + throw new Error('Total input size limit exceeded while mounting directory') + } + const buffer = await fetchWorkspaceFileBuffer(record) + totalSize += buffer.length + const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( + record.type || '' + ) + const relativeFolder = + record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? '' + const relativePath = [relativeFolder, record.name].filter(Boolean).join('/') + sandboxFiles.push({ + path: `${mountRoot}/${relativePath}`, + content: isText ? buffer.toString('utf-8') : buffer.toString('base64'), + encoding: isText ? undefined : 'base64', + }) + } } } if (inputTables?.length) { - for (const tableId of inputTables) { - if (typeof tableId !== 'string') continue - const table = await getTableById(tableId) + const hasTablePathRefs = inputTables.some((tableRef) => { + const tableId = + typeof tableRef === 'string' + ? tableRef + : tableRef && typeof tableRef === 'object' + ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path + : undefined + return typeof tableId === 'string' && tableId.startsWith('tables/') + }) + const tablePathLookup = hasTablePathRefs + ? new Map((await listTables(workspaceId)).map((table) => [table.name, table])) + : undefined + for (const tableRef of inputTables) { + const tableId = + typeof tableRef === 'string' + ? tableRef + : tableRef && typeof tableRef === 'object' + ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path + : undefined + if (!tableId) continue + const table = await resolveTableRef(tableId, tablePathLookup) if (!table || table.workspaceId !== workspaceId) { logger.warn('Input table not found', { tableId }) continue } const rows = await queryRows(table, {}, 'copilot-fn-exec') - if (!rows.rows?.length) continue - const allKeys = new Set() - for (const row of rows.rows) { + const allKeys = new Set(table.schema.columns.map((column) => column.name)) + for (const row of rows.rows ?? []) { if (row.data && typeof row.data === 'object') { for (const key of Object.keys(row.data as Record)) { allKeys.add(key) @@ -79,7 +229,7 @@ async function resolveInputFiles( } const headers = Array.from(allKeys) const csvLines = [headers.join(',')] - for (const row of rows.rows) { + for (const row of rows.rows ?? []) { const data = (row.data || {}) as Record csvLines.push( headers @@ -94,8 +244,12 @@ async function resolveInputFiles( ) } const csvContent = csvLines.join('\n') + const sandboxPath = + typeof tableRef === 'object' && tableRef !== null + ? (tableRef as CanonicalTableInput).sandboxPath + : undefined sandboxFiles.push({ - path: `/home/user/tables/${tableId}.csv`, + path: sandboxPath || `/home/user/tables/${table.id}.csv`, content: csvContent, }) } @@ -118,11 +272,30 @@ export async function executeFunctionExecute( } if (context.workspaceId) { - const inputFiles = enrichedParams.inputFiles as unknown[] | undefined - const inputTables = enrichedParams.inputTables as unknown[] | undefined + const inputs = enrichedParams.inputs as + | { + files?: CanonicalFileInput[] + directories?: CanonicalDirectoryInput[] + tables?: CanonicalTableInput[] + } + | undefined + const inputFiles = [ + ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), + ...(inputs?.files ?? []), + ] + const inputDirectories = inputs?.directories ?? [] + const inputTables = [ + ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), + ...(inputs?.tables ?? []), + ] - if (inputFiles?.length || inputTables?.length) { - const resolved = await resolveInputFiles(context.workspaceId, inputFiles, inputTables) + if (inputFiles?.length || inputTables?.length || inputDirectories.length) { + const resolved = await resolveInputFiles( + context.workspaceId, + inputFiles, + inputTables, + inputDirectories + ) if (resolved.length > 0) { const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] enrichedParams._sandboxFiles = [...existing, ...resolved] diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index 582d52ef92d..31936b1dee6 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -219,15 +219,18 @@ export type OpenResourceType = MothershipResourceType export interface OpenResourceItem { type?: OpenResourceType id?: string + path?: string } export interface OpenResourceParams { resources?: OpenResourceItem[] type?: OpenResourceType id?: string + path?: string } export interface ValidOpenResourceParams { type: OpenResourceType - id: string + id?: string + path?: string } diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index d573959f9db..e5a7cb459f3 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -4,8 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getWorkspaceFileMock } = vi.hoisted(() => ({ +const { getWorkspaceFileMock, resolveWorkspaceFileReferenceMock } = vi.hoisted(() => ({ getWorkspaceFileMock: vi.fn(), + resolveWorkspaceFileReferenceMock: vi.fn(), })) vi.mock('@sim/db', () => ({ @@ -16,6 +17,7 @@ vi.mock('@sim/db/schema', () => ({})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: getWorkspaceFileMock, + resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, })) vi.mock('@/lib/workflows/utils', () => ({ @@ -67,4 +69,34 @@ describe('executeOpenResource', () => { ], }) }) + + it('opens workspace files by canonical VFS path', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'wf_qL_cfff-FskMsXtOdm599', + name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + }) + + const result = await executeOpenResource( + { + resources: [{ type: 'file', path: 'files/Docs/MAC_Brand_Guidelines.docx' }], + }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( + 'workspace-1', + 'files/Docs/MAC_Brand_Guidelines.docx' + ) + expect(result).toMatchObject({ + success: true, + output: { opened: 1, errors: [] }, + resources: [ + { + type: 'file', + id: 'wf_qL_cfff-FskMsXtOdm599', + title: 'MAC_Brand_Guidelines_May_2021 (1).docx', + }, + ], + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 338f187de3e..93e51250ae7 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -3,7 +3,10 @@ import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/r import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { getLogById } from '@/lib/logs/service' import { getTableById } from '@/lib/table/service' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + getWorkspaceFile, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types' @@ -14,18 +17,24 @@ async function resolveResource( context: ExecutionContext ): Promise { const resourceType = item.type - let resourceId = item.id + let resourceId = item.id ?? '' let title: string = resourceType if (resourceType === 'file') { if (!context.workspaceId) return { error: 'Opening a workspace file requires workspace context.' } - const record = await getWorkspaceFile(context.workspaceId, item.id) - if (!record) return { error: `No workspace file with id "${item.id}".` } + const fileRef = item.path || item.id || '' + const record = item.path + ? await resolveWorkspaceFileReference(context.workspaceId, item.path) + : item.id + ? await getWorkspaceFile(context.workspaceId, item.id) + : null + if (!record) return { error: `No workspace file found for "${fileRef}".` } resourceId = record.id title = record.name } if (resourceType === 'workflow') { + if (!item.id) return { error: 'workflow resources require `id`.' } const wf = await getWorkflowById(item.id) if (!wf) return { error: `No workflow with id "${item.id}".` } if (context.workspaceId && wf.workspaceId !== context.workspaceId) @@ -34,6 +43,7 @@ async function resolveResource( title = wf.name } if (resourceType === 'table') { + if (!item.id) return { error: 'table resources require `id`.' } const tbl = await getTableById(item.id) if (!tbl) return { error: `No table with id "${item.id}".` } if (context.workspaceId && tbl.workspaceId !== context.workspaceId) @@ -42,6 +52,7 @@ async function resolveResource( title = tbl.name } if (resourceType === 'knowledgebase') { + if (!item.id) return { error: 'knowledgebase resources require `id`.' } const kb = await getKnowledgeBaseById(item.id) if (!kb) return { error: `No knowledge base with id "${item.id}".` } if (context.workspaceId && kb.workspaceId !== context.workspaceId) @@ -50,6 +61,7 @@ async function resolveResource( title = kb.name } if (resourceType === 'log') { + if (!item.id) return { error: 'log resources require `id`.' } const logRecord = await getLogById(item.id) if (!logRecord) return { error: `No log with id "${item.id}".` } if (context.workspaceId && logRecord.workspaceId !== context.workspaceId) @@ -75,7 +87,10 @@ export async function executeOpenResource( const params = rawParams as OpenResourceParams const items: OpenResourceItem[] = - params.resources ?? (params.type && params.id ? [{ type: params.type, id: params.id }] : []) + params.resources ?? + (params.type && (params.id || params.path) + ? [{ type: params.type, id: params.id, path: params.path }] + : []) if (items.length === 0) { return { success: false, error: 'resources array is required' } @@ -114,8 +129,8 @@ function validateOpenResourceItem( if (!VALID_OPEN_RESOURCE_TYPES.has(item.type)) { return { success: false, error: `Invalid resource type: ${item.type}` } } - if (!item.id) { + if (!item.id && !(item.type === 'file' && item.path)) { return { success: false, error: `${item.type} resources require \`id\`` } } - return { success: true, params: { type: item.type, id: item.id } } + return { success: true, params: { type: item.type, id: item.id, path: item.path } } } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 06126741ae2..1fe8737ac3b 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -80,7 +80,7 @@ describe('vfs handlers oversize policy', () => { getOrMaterializeVFS.mockResolvedValue(vfs) const result = await executeVfsRead( - { path: 'files/big.txt' }, + { path: 'files/big.txt/content' }, { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } ) @@ -103,7 +103,7 @@ describe('vfs handlers oversize policy', () => { getOrMaterializeVFS.mockResolvedValue(vfs) const result = await executeVfsRead( - { path: 'files/chess.png' }, + { path: 'files/chess.png/content' }, { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } ) @@ -111,6 +111,29 @@ describe('vfs handlers oversize policy', () => { expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('image') }) + it('passes through compiled file attachments even when oversized', async () => { + const vfs = makeVfs() + const largeBase64 = 'A'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1) + vfs.readFileContent.mockResolvedValue({ + content: 'Compiled file: report.pdf (500000 bytes, application/pdf)', + totalLines: 1, + attachment: { + type: 'file', + name: 'report.pdf', + source: { type: 'base64', media_type: 'application/pdf', data: largeBase64 }, + }, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/reports/report.pdf/compiled' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('file') + }) + it('fails oversized image placeholder when image exceeds size limit', async () => { const vfs = makeVfs() vfs.readFileContent.mockResolvedValue({ @@ -120,11 +143,65 @@ describe('vfs handlers oversize policy', () => { getOrMaterializeVFS.mockResolvedValue(vfs) const result = await executeVfsRead( - { path: 'files/huge.png' }, + { path: 'files/huge.png/content' }, { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } ) expect(result.success).toBe(false) expect(result.error).toContain('too large') }) + + it('reads canonical file leaf metadata without fetching dynamic content', async () => { + const vfs = makeVfs() + vfs.read.mockReturnValue({ + content: '{"id":"wf_123","vfsPath":"files/report.csv"}', + totalLines: 1, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/report.csv' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect(vfs.readFileContent).not.toHaveBeenCalled() + expect(vfs.read).toHaveBeenCalledWith('files/report.csv', undefined, undefined) + }) + + it('uses dynamic file reads for canonical style paths', async () => { + const vfs = makeVfs() + vfs.readFileContent.mockResolvedValue({ + content: '{"format":"docx"}', + totalLines: 1, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/reports/brief.docx/style' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.docx/style') + expect(vfs.read).not.toHaveBeenCalled() + }) + + it('uses dynamic file reads for canonical compiled paths', async () => { + const vfs = makeVfs() + vfs.readFileContent.mockResolvedValue({ + content: 'Compiled file: brief.pdf (1000 bytes, application/pdf)', + totalLines: 1, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/reports/brief.pdf/compiled' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.pdf/compiled') + expect(vfs.read).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index ab87b264aa5..0f10deb6d41 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -18,16 +18,17 @@ function serializedResultSize(value: unknown): number { function isOversizedReadPlaceholder(content: string): boolean { return ( content.startsWith('[File too large to display inline:') || - content.startsWith('[Image too large:') + content.startsWith('[Image too large:') || + content.startsWith('[Compiled artifact too large:') ) } -function hasImageAttachment(result: unknown): boolean { +function hasModelAttachment(result: unknown): boolean { if (!result || typeof result !== 'object') { return false } const attachment = (result as { attachment?: { type?: string } }).attachment - return attachment?.type === 'image' + return attachment?.type === 'image' || attachment?.type === 'file' || attachment?.type === 'document' } export async function executeVfsGrep( @@ -161,15 +162,15 @@ export async function executeVfsRead( const filename = path.slice('uploads/'.length) const uploadResult = await readChatUpload(filename, context.chatId) if (uploadResult) { - const isImage = hasImageAttachment(uploadResult) + const isAttachment = hasModelAttachment(uploadResult) if ( - !isImage && + !isAttachment && (isOversizedReadPlaceholder(uploadResult.content) || serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS) ) { logger.warn('Upload read result too large', { path, - hasAttachment: isImage, + hasAttachment: isAttachment, contentLength: uploadResult.content.length, serializedSize: serializedResultSize(uploadResult), }) @@ -184,7 +185,7 @@ export async function executeVfsRead( logger.debug('vfs_read resolved chat upload', { path, totalLines: uploadResult.totalLines, - hasAttachment: isImage, + hasAttachment: isAttachment, offset, limit, }) @@ -198,20 +199,24 @@ export async function executeVfsRead( const vfs = await getOrMaterializeVFS(workspaceId, context.userId) - // For workspace file paths (files/ or recently-deleted/files/), try readFileContent - // first so images, PDFs, and documents get proper attachment/parsing handling rather - // than being served as raw VFS metadata text. - const fileContent = await vfs.readFileContent(path) + // Plain canonical file leaves are metadata resources. Dynamic file content + // and inspection paths use explicit suffixes like /content, /style, + // /compiled-check, or /compiled. + const shouldReadDynamicFileContent = + /^files\/by-id\/[^/]+\/(?:content|style|compiled-check|compiled)$/.test(path) || + /^recently-deleted\/files\/.+\/content$/.test(path) || + /^files\/.+\/(?:content|style|compiled-check|compiled)$/.test(path) + const fileContent = shouldReadDynamicFileContent ? await vfs.readFileContent(path) : null if (fileContent) { - const isImage = hasImageAttachment(fileContent) + const isAttachment = hasModelAttachment(fileContent) if ( - !isImage && + !isAttachment && (isOversizedReadPlaceholder(fileContent.content) || serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS) ) { logger.warn('File read result too large', { path, - hasAttachment: isImage, + hasAttachment: isAttachment, contentLength: fileContent.content.length, serializedSize: serializedResultSize(fileContent), }) @@ -226,7 +231,7 @@ export async function executeVfsRead( logger.debug('vfs_read resolved workspace file', { path, totalLines: fileContent.totalLines, - hasAttachment: isImage, + hasAttachment: isAttachment, offset, limit, }) @@ -247,7 +252,7 @@ export async function executeVfsRead( return { success: false, error: `File not found: ${path}.${hint}` } } if ( - !hasImageAttachment(result) && + !hasModelAttachment(result) && (isOversizedReadPlaceholder(result.content) || serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS) ) { diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index 45f0b9f48db..1d5e3629546 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -5,16 +5,8 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - getWorkspaceFileByName, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { - inferContentType, - splitWorkspaceFilePath, - validateFlatWorkspaceFileName, -} from './workspace-file' +import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { inferContentType } from './workspace-file' const logger = createLogger('CreateFileServerTool') const CREATE_FILE_TOOL_ID = 'create_file' @@ -22,6 +14,7 @@ const CREATE_FILE_TOOL_ID = 'create_file' interface CreateFileArgs { fileName: string contentType?: string + outputs?: { files?: Array<{ path: string; mode?: 'create' | 'overwrite'; mimeType?: string }> } args?: Record } @@ -32,6 +25,7 @@ interface CreateFileResult { id: string name: string contentType: string + vfsPath: string } } @@ -50,48 +44,43 @@ export const createFileServerTool: BaseServerTool @@ -35,17 +40,32 @@ export const deleteFileServerTool: BaseServerTool 0) return arr + } + return values + .map((value) => stringValue(value)) + .filter((value): value is string => Boolean(value)) +} + +function decodeFileFolderPath(path: string): string[] | null { + const trimmed = path.trim().replace(/\/+$/, '') + if (!trimmed || trimmed === 'files') return null + const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed + const withoutMarker = withoutPrefix.endsWith('/.folder') + ? withoutPrefix.slice(0, -'/.folder'.length) + : withoutPrefix + const segments = decodeVfsPathSegments(withoutMarker).filter(Boolean) + return segments.length > 0 ? segments : null +} + +async function resolveFolderIdFromPath( + workspaceId: string, + path: string, + label = 'Folder' +): Promise { + const segments = decodeFileFolderPath(path) + if (!segments) throw new Error(`${label} path must identify a folder under files/`) + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (!folderId) throw new Error(`${label} not found at files/${segments.join('/')}`) + return folderId +} + +async function resolveOptionalFolderId( + workspaceId: string, + value: unknown +): Promise { + const raw = nullableStringValue(value) + if (raw === undefined) return undefined + if (raw === null) return null + const segments = decodeFileFolderPath(raw) + if (!segments) return null + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (!folderId) throw new Error(`Target folder not found at files/${segments.join('/')}`) + return folderId +} + +async function resolveFileIdsFromPaths(workspaceId: string, paths: string[]): Promise<{ + fileIds: string[] + failed: string[] +}> { + const fileIds: string[] = [] + const failed: string[] = [] + for (const path of paths) { + const file = await resolveWorkspaceFileReference(workspaceId, path) + if (!file) { + failed.push(path) + continue + } + fileIds.push(file.id) + } + return { fileIds, failed } +} + async function resolveWorkspaceId( params: WorkspaceScopedArgs, context: ServerToolContext | undefined, @@ -146,10 +223,33 @@ export const createFileFolderServerTool: BaseServerTool 1) { + const resolvedParentId = await findWorkspaceFileFolderIdByPath( + workspaceId, + pathSegments.slice(0, -1) + ) + if (!resolvedParentId) { + return { + success: false, + message: `Parent folder not found at files/${pathSegments.slice(0, -1).join('/')}`, + } + } + parentId = resolvedParentId + } assertServerToolNotAborted(context) const result = await performCreateWorkspaceFileFolder({ @@ -193,9 +293,14 @@ export const renameFileFolderServerTool: BaseServerTool 0 + ? await Promise.all(paths.map((path) => resolveFolderIdFromPath(workspaceId, path))) + : (params.folderIds ?? + stringArrayValue(payload?.folderIds) ?? + [stringValue(params.folderId) || stringValue(payload?.folderId) || ''].filter(Boolean)) + if (folderIds.length === 0) return { success: false, message: 'paths is required' } assertServerToolNotAborted(context) const result = await performDeleteWorkspaceFileItems({ @@ -336,13 +455,29 @@ export const moveFileServerTool: BaseServerTool if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) + const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) + const resolvedByPath = + paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths) : undefined + if (resolvedByPath?.failed.length) { + return { + success: false, + message: `Files not found: ${resolvedByPath.failed.join(', ')}`, + } + } const fileIds = + resolvedByPath?.fileIds ?? params.fileIds ?? stringArrayValue(payload?.fileIds) ?? [stringValue(params.fileId) || stringValue(payload?.fileId) || ''].filter(Boolean) - if (fileIds.length === 0) return { success: false, message: 'fileIds is required' } - - const folderId = nullableStringValue(params.folderId ?? payload?.folderId) ?? null + if (fileIds.length === 0) return { success: false, message: 'paths is required' } + + const folderId = + (await resolveOptionalFolderId( + workspaceId, + params.destinationPath ?? payload?.destinationPath + )) ?? + nullableStringValue(params.folderId ?? payload?.folderId) ?? + null assertServerToolNotAborted(context) const result = await performMoveWorkspaceFileItems({ diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index 1f8ac039f38..10bac242eca 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -6,14 +6,18 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + getWorkspaceFile, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performRenameWorkspaceFile } from '@/lib/workspace-files/orchestration' import { validateFlatWorkspaceFileName } from './workspace-file' const logger = createLogger('RenameFileServerTool') interface RenameFileArgs { - fileId: string + path?: string + fileId?: string newName: string args?: Record } @@ -40,18 +44,23 @@ export const renameFileServerTool: BaseServerTool => { + if (!target || (target.kind !== 'path' && target.kind !== 'file_id')) { + return { error: `${operationName} requires target.kind=path with target.path` } + } + const fileRecord = + target.kind === 'path' + ? await resolveWorkspaceFileReference(workspaceId!, target.path) + : await getWorkspaceFile(workspaceId!, target.fileId) + if (!fileRecord) { + const ref = target.kind === 'path' ? target.path : target.fileId + return { error: `File not found: ${ref}` } + } + if (target.fileName && target.fileName !== fileRecord.name) { + return { + error: `Target mismatch: "${target.fileName}" does not match resolved file "${fileRecord.name}"`, + } + } + return { fileRecord } + } + if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } @@ -282,28 +312,13 @@ export const workspaceFileServerTool: BaseServerTool = { '3:4': '768x1024', } -function validateGeneratedWorkspaceFileName(fileName: string): string | null { - const trimmed = fileName.trim() - if (!trimmed) return 'File name cannot be empty' - if (trimmed.includes('/')) { - return 'Workspace files use a flat namespace. Use a plain file name like "generated-image.png", not a path like "images/generated-image.png".' - } - return null -} - interface GenerateImageArgs { prompt: string - referenceFileIds?: string[] + inputs?: { files?: Array<{ path: string }> } aspectRatio?: string - fileName?: string - overwriteFileId?: string + outputs?: { + files?: Array<{ + path: string + mode?: 'create' | 'overwrite' + mimeType?: string + }> + } } interface GenerateImageResult { @@ -51,6 +45,7 @@ interface GenerateImageResult { message: string fileId?: string fileName?: string + vfsPath?: string downloadUrl?: string _serviceCost?: { service: string; cost: number } } @@ -86,14 +81,14 @@ export const generateImageServerTool: BaseServerTool = [] - if (params.referenceFileIds?.length) { - for (const fileId of params.referenceFileIds) { + const referencePaths = params.inputs?.files?.map((file) => file.path) ?? [] + + if (referencePaths.length) { + for (const filePath of referencePaths) { try { - const fileRecord = await getWorkspaceFile(workspaceId, fileId) + const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) if (fileRecord) { - referenceRecords.push({ id: fileRecord.id, name: fileRecord.name }) const buffer = await fetchWorkspaceFileBuffer(fileRecord) const base64 = buffer.toString('base64') const mime = fileRecord.type || 'image/png' @@ -101,17 +96,17 @@ export const generateImageServerTool: BaseServerTool record.name === fileName)?.id - : undefined - const overwriteFileId = params.overwriteFileId ?? inferredOverwriteFileId - - if (inferredOverwriteFileId) { - logger.info('Inferring overwrite target from referenced file name', { - fileName, - overwriteFileId: inferredOverwriteFileId, - }) - } - - if (overwriteFileId) { - const existing = await getWorkspaceFile(workspaceId, overwriteFileId) - if (!existing) { - return { - success: false, - message: `File not found for overwrite: ${overwriteFileId}`, - } - } - assertServerToolNotAborted(context) - const updated = await updateWorkspaceFileContent( - workspaceId, - overwriteFileId, - context.userId, - imageBuffer, - mimeType - ) - logger.info('Generated image overwritten', { - fileId: updated.id, - fileName: updated.name, - size: imageBuffer.length, - mimeType, - }) - const pathPrefix = getServePathPrefix() - return { - success: true, - message: `Image ${params.referenceFileIds?.length ? 'edited' : 'generated'} and updated in "${updated.name}" (${imageBuffer.length} bytes)`, - fileId: updated.id, - fileName: updated.name, - downloadUrl: `${pathPrefix}${encodeURIComponent(updated.key)}?context=workspace`, - _serviceCost: { service: 'nano_banana_2', cost: NANO_BANANA_IMAGE_COST_USD }, - } - } + const mode = outputFile?.mode ?? 'create' assertServerToolNotAborted(context) - const uploaded = await uploadWorkspaceFile( + const written = await writeWorkspaceFileByPath({ workspaceId, - context.userId, - imageBuffer, - fileName, - mimeType - ) + userId: context.userId, + target: { + path: outputPath, + mode, + mimeType: outputFile?.mimeType, + }, + buffer: imageBuffer, + inferredMimeType: mimeType, + }) logger.info('Generated image saved', { - fileId: uploaded.id, - fileName: uploaded.name, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, size: imageBuffer.length, mimeType, }) return { success: true, - message: `Image ${params.referenceFileIds?.length ? 'edited' : 'generated'} and saved as "${uploaded.name}" (${imageBuffer.length} bytes)`, - fileId: uploaded.id, - fileName: uploaded.name, - downloadUrl: uploaded.url, + message: `Image ${referencePaths.length ? 'edited' : 'generated'} and ${written.mode === 'overwrite' ? 'updated' : 'saved'} at "${written.vfsPath}" (${imageBuffer.length} bytes)`, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, + downloadUrl: written.downloadUrl, _serviceCost: { service: 'nano_banana_2', cost: NANO_BANANA_IMAGE_COST_USD }, } } catch (error) { diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 9aa79d7d569..021f2ed82e7 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -272,13 +272,15 @@ export const knowledgeBaseServerTool: BaseServerTool = [] const failedFiles: string[] = [] - for (const fileRef of fileIds) { + for (const fileRef of fileRefs) { const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, fileRef) if (!fileRecord) { failedFiles.push(fileRef) diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 4cf7e02a9bc..e6e91d9513f 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -7,7 +7,6 @@ import { DeleteFileFolder, DownloadToWorkspaceFile, GenerateImage, - GenerateVisualization, KnowledgeBase, ManageCredential, ManageCustomTool, @@ -50,7 +49,6 @@ import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search- import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' -import { generateVisualizationServerTool } from '@/lib/copilot/tools/server/visualization/generate-visualization' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { getExecutionSummaryServerTool } from '@/lib/copilot/tools/server/workflow/get-execution-summary' import { getWorkflowLogsServerTool } from '@/lib/copilot/tools/server/workflow/get-workflow-logs' @@ -113,7 +111,6 @@ const WRITE_ACTIONS: Record = { [MoveFileFolder.id]: ['*'], [DeleteFileFolder.id]: ['*'], [DownloadToWorkspaceFile.id]: ['*'], - [GenerateVisualization.id]: ['generate'], [GenerateImage.id]: ['generate'], } @@ -155,7 +152,6 @@ const serverToolRegistry: Record = { [moveFileFolderServerTool.name]: moveFileFolderServerTool, [deleteFileFolderServerTool.name]: deleteFileFolderServerTool, [downloadToWorkspaceFileServerTool.name]: downloadToWorkspaceFileServerTool, - [generateVisualizationServerTool.name]: generateVisualizationServerTool, [generateImageServerTool.name]: generateImageServerTool, } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 1baee52e9d0..803abae77eb 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -89,7 +89,7 @@ async function resolveWorkspaceFile( const record = await resolveWorkspaceFileReference(workspaceId, fileReference) if (!record) { throw new Error( - `File not found: "${fileReference}". Use glob("files/by-id/*/meta.json") to list canonical file IDs.` + `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` ) } const buffer = await fetchWorkspaceFileBuffer(record) @@ -738,7 +738,7 @@ export const userTableServerTool: BaseServerTool return { success: false, message: - 'fileId is required for create_from_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file ID.', + 'fileId or filePath is required for create_from_file. Use a canonical VFS path from glob("files/**") or a file ID from read("files/{path}/{name}").', } } if (!workspaceId) { @@ -803,7 +803,7 @@ export const userTableServerTool: BaseServerTool return { success: false, message: - 'fileId is required for import_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file ID.', + 'fileId or filePath is required for import_file. Use a canonical VFS path from glob("files/**") or a file ID from read("files/{path}/{name}").', } } if (!tableId) { diff --git a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts deleted file mode 100644 index 629622a1cf3..00000000000 --- a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { GenerateVisualization } from '@/lib/copilot/generated/tool-catalog-v1' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { executeInE2B, type SandboxFile } from '@/lib/execution/e2b' -import { CodeLanguage } from '@/lib/execution/languages' -import { getTableById, queryRows } from '@/lib/table/service' -import { getServePathPrefix } from '@/lib/uploads' -import { - fetchWorkspaceFileBuffer, - findWorkspaceFileRecord, - getSandboxWorkspaceFilePath, - getWorkspaceFile, - listWorkspaceFiles, - updateWorkspaceFileContent, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -const logger = createLogger('GenerateVisualizationTool') - -interface VisualizationArgs { - code: string - inputTables?: string[] - inputFiles?: string[] - fileName?: string - overwriteFileId?: string -} - -interface VisualizationResult { - success: boolean - message: string - fileId?: string - fileName?: string - downloadUrl?: string -} - -function csvEscapeValue(value: unknown): string { - if (value === null || value === undefined) return '' - if (typeof value === 'number' || typeof value === 'boolean') return String(value) - const str = String(value) - if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} - -const TEXT_EXTENSIONS = new Set(['csv', 'json', 'txt', 'md', 'html', 'xml', 'tsv', 'yaml', 'yml']) -const MAX_FILE_SIZE = 10 * 1024 * 1024 -const MAX_TOTAL_SIZE = 50 * 1024 * 1024 - -function validateGeneratedWorkspaceFileName(fileName: string): string | null { - const trimmed = fileName.trim() - if (!trimmed) return 'File name cannot be empty' - if (trimmed.includes('/')) { - return 'Workspace files use a flat namespace. Use a plain file name like "chart.png", not a path like "charts/chart.png".' - } - return null -} - -async function collectSandboxFiles( - workspaceId: string, - inputFiles?: string[], - inputTables?: string[], - messageId?: string -): Promise { - const withMessageId = (message: string) => - messageId ? `${message} [messageId:${messageId}]` : message - const sandboxFiles: SandboxFile[] = [] - let totalSize = 0 - - if (inputFiles?.length) { - const allFiles = await listWorkspaceFiles(workspaceId) - for (const fileRef of inputFiles) { - const record = findWorkspaceFileRecord(allFiles, fileRef) - if (!record) { - logger.warn('Sandbox input file not found', { fileRef }) - continue - } - const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - if (!TEXT_EXTENSIONS.has(ext)) { - logger.warn('Skipping non-text sandbox input file', { - fileId: record.id, - fileName: record.name, - ext, - }) - continue - } - if (record.size > MAX_FILE_SIZE) { - logger.warn('Sandbox input file exceeds size limit', { - fileId: record.id, - fileName: record.name, - size: record.size, - }) - continue - } - if (totalSize + record.size > MAX_TOTAL_SIZE) { - logger.warn('Sandbox input total size limit reached, skipping remaining files') - break - } - const buffer = await fetchWorkspaceFileBuffer(record) - totalSize += buffer.length - const textContent = buffer.toString('utf-8') - sandboxFiles.push({ - path: getSandboxWorkspaceFilePath(record), - content: textContent, - }) - sandboxFiles.push({ - path: `/home/user/${record.name}`, - content: textContent, - }) - } - } - - if (inputTables?.length) { - for (const tableId of inputTables) { - const table = await getTableById(tableId) - if (!table || table.workspaceId !== workspaceId) { - logger.warn('Sandbox input table not found', { tableId }) - continue - } - const { rows } = await queryRows(table, { limit: 10000 }, 'sandbox-input') - const schema = table.schema as { columns: Array<{ name: string; type?: string }> } - const cols = schema.columns.map((c) => c.name) - const typeComment = `# types: ${schema.columns.map((c) => `${c.name}=${c.type || 'string'}`).join(', ')}` - const csvLines = [typeComment, cols.join(',')] - for (const row of rows) { - csvLines.push( - cols.map((c) => csvEscapeValue((row.data as Record)[c])).join(',') - ) - } - const csvContent = csvLines.join('\n') - if (totalSize + csvContent.length > MAX_TOTAL_SIZE) { - logger.warn('Sandbox input total size limit reached, skipping remaining tables') - break - } - totalSize += csvContent.length - sandboxFiles.push({ path: `/home/user/tables/${tableId}.csv`, content: csvContent }) - } - } - - return sandboxFiles -} - -export const generateVisualizationServerTool: BaseServerTool< - VisualizationArgs, - VisualizationResult -> = { - name: GenerateVisualization.id, - - async execute( - params: VisualizationArgs, - context?: ServerToolContext - ): Promise { - const withMessageId = (message: string) => - context?.messageId ? `${message} [messageId:${context.messageId}]` : message - - if (!context?.userId) { - throw new Error('Authentication required') - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - const { code } = params - if (!code) { - return { success: false, message: 'code is required' } - } - - try { - const sandboxFiles = await collectSandboxFiles( - workspaceId, - params.inputFiles, - params.inputTables, - context.messageId - ) - - const wrappedCode = [ - 'import matplotlib', - "matplotlib.use('Agg')", - 'import matplotlib.pyplot as plt', - '', - code, - '', - '# Auto-save if user did not explicitly call savefig', - 'import os as _os', - "if not _os.path.exists('/home/user/output.png'):", - ' if plt.get_fignums():', - " plt.savefig('/home/user/output.png', dpi=150, bbox_inches='tight')", - ' plt.close()', - ].join('\n') - - const result = await executeInE2B({ - code: wrappedCode, - language: CodeLanguage.Python, - timeoutMs: 60_000, - sandboxFiles, - }) - - if (result.error) { - return { success: false, message: `Python execution failed: ${result.error}` } - } - - let imageBase64: string | undefined - - if (result.images?.length) { - imageBase64 = result.images[0] - } - - if (!imageBase64) { - return { - success: false, - message: `Code ran but produced no image. Make sure your code creates a matplotlib figure and calls plt.savefig('/home/user/output.png'). Stdout: ${result.stdout?.slice(0, 500) || '(empty)'}`, - } - } - - const fileName = params.fileName || 'chart.png' - const fileNameValidationError = validateGeneratedWorkspaceFileName(fileName) - if (fileNameValidationError) { - return { success: false, message: fileNameValidationError } - } - const imageBuffer = Buffer.from(imageBase64, 'base64') - - if (params.overwriteFileId) { - const existing = await getWorkspaceFile(workspaceId, params.overwriteFileId) - if (!existing) { - return { - success: false, - message: `File not found for overwrite: ${params.overwriteFileId}`, - } - } - assertServerToolNotAborted(context) - const updated = await updateWorkspaceFileContent( - workspaceId, - params.overwriteFileId, - context.userId, - imageBuffer, - 'image/png' - ) - logger.info('Chart image overwritten', { - fileId: updated.id, - fileName: updated.name, - size: imageBuffer.length, - }) - const pathPrefix = getServePathPrefix() - return { - success: true, - message: `Chart updated in "${updated.name}" (${imageBuffer.length} bytes)`, - fileId: updated.id, - fileName: updated.name, - downloadUrl: `${pathPrefix}${encodeURIComponent(updated.key)}?context=workspace`, - } - } - - assertServerToolNotAborted(context) - const uploaded = await uploadWorkspaceFile( - workspaceId, - context.userId, - imageBuffer, - fileName, - 'image/png' - ) - - logger.info('Chart image saved', { - fileId: uploaded.id, - fileName: uploaded.name, - size: imageBuffer.length, - }) - - return { - success: true, - message: `Chart saved as "${uploaded.name}" (${imageBuffer.length} bytes)`, - fileId: uploaded.id, - fileName: uploaded.name, - downloadUrl: uploaded.url, - } - } catch (error) { - const msg = getErrorMessage(error, 'Unknown error') - logger.error('Visualization generation failed', { error: msg }) - return { success: false, message: `Failed to generate visualization: ${msg}` } - } - }, -} diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index ba464a4c945..9cb0c3a8207 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -261,6 +261,7 @@ export interface FileReadResult { totalLines: number attachment?: { type: string + name?: string source: { type: 'base64' media_type: string @@ -316,6 +317,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise, pattern: string): string[] { const directories = new Set() for (const filePath of files.keys()) { + if (filePath.endsWith('/.folder')) { + directories.add(filePath.slice(0, -'/.folder'.length)) + continue + } const parts = filePath.split('/') for (let i = 1; i < parts.length; i++) { directories.add(parts.slice(0, i).join('/')) @@ -180,6 +184,7 @@ export function glob(files: Map, pattern: string): string[] { } for (const filePath of files.keys()) { + if (filePath.endsWith('/.folder')) continue if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) { result.add(filePath) } @@ -249,6 +254,7 @@ export function list(files: Map, path: string): DirEntry[] { const entries: DirEntry[] = [] for (const filePath of files.keys()) { + if (filePath.endsWith('/.folder')) continue if (!filePath.startsWith(normalizedPath)) continue const remainder = filePath.slice(normalizedPath.length) diff --git a/apps/sim/lib/copilot/vfs/path-utils.test.ts b/apps/sim/lib/copilot/vfs/path-utils.test.ts new file mode 100644 index 00000000000..2618f45415e --- /dev/null +++ b/apps/sim/lib/copilot/vfs/path-utils.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + canonicalWorkspaceFilePath, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' + +describe('VFS path utilities', () => { + it('round trips encoded nested path segments', () => { + const segments = ['Reports', 'Q4 Report (Final)', 'sales/east.csv'] + + const encoded = encodeVfsPathSegments(segments) + + expect(encoded).toBe('Reports/Q4%20Report%20(Final)/sales%2Feast.csv') + expect(decodeVfsPathSegments(encoded)).toEqual(segments) + }) + + it('builds canonical workspace file leaf paths', () => { + expect( + canonicalWorkspaceFilePath({ + folderPath: 'Reports/Q4 Report (Final)', + name: 'sales/east.csv', + }) + ).toBe('files/Reports/Q4%20Report%20(Final)/sales%2Feast.csv') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/path-utils.ts b/apps/sim/lib/copilot/vfs/path-utils.ts new file mode 100644 index 00000000000..c1d77395676 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/path-utils.ts @@ -0,0 +1,60 @@ +const CONTROL_CHARS = /[\x00-\x1f\x7f]/g +const WHITESPACE = /\s+/g + +export class VfsPathError extends Error { + constructor(message: string) { + super(message) + this.name = 'VfsPathError' + } +} + +function normalizeDisplaySegment(segment: string): string { + return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') +} + +export function encodeVfsSegment(segment: string): string { + const normalized = normalizeDisplaySegment(segment) + if (!normalized || normalized === '.' || normalized === '..') { + throw new VfsPathError('VFS path segment cannot be empty or a dot segment') + } + return encodeURIComponent(normalized) +} + +export function decodeVfsSegment(segment: string): string { + try { + const decoded = decodeURIComponent(segment) + const normalized = normalizeDisplaySegment(decoded) + if (!normalized || normalized === '.' || normalized === '..') { + throw new VfsPathError('VFS path segment cannot be empty or a dot segment') + } + return normalized + } catch (error) { + if (error instanceof VfsPathError) throw error + throw new VfsPathError(`Invalid encoded VFS path segment: ${segment}`) + } +} + +export function encodeVfsPathSegments(segments: string[]): string { + return segments.map(encodeVfsSegment).join('/') +} + +export function decodeVfsPathSegments(path: string): string[] { + const trimmed = path.trim().replace(/^\/+|\/+$/g, '') + if (!trimmed) return [] + return trimmed.split('/').map(decodeVfsSegment) +} + +export function canonicalizeVfsPath(path: string): string { + return encodeVfsPathSegments(decodeVfsPathSegments(path)) +} + +export function canonicalWorkspaceFilePath(parts: { + folderPath?: string | null + name: string + prefix?: 'files' | 'recently-deleted/files' +}): string { + const prefix = parts.prefix ?? 'files' + const folderSegments = parts.folderPath ? parts.folderPath.split('/').filter(Boolean) : [] + const encoded = encodeVfsPathSegments([...folderSegments, parts.name]) + return `${prefix}/${encoded}` +} diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts new file mode 100644 index 00000000000..9f1a53d2a61 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -0,0 +1,192 @@ +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { + findWorkspaceFileFolderIdByPath, + normalizeWorkspaceFileItemName, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + getWorkspaceFileByName, + resolveWorkspaceFileReference, + updateWorkspaceFileContent, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +export type WorkspaceFileWriteMode = 'create' | 'overwrite' + +export interface WorkspaceFileWriteTarget { + path: string + mode: WorkspaceFileWriteMode + mimeType?: string +} + +export interface WorkspaceFileWriteResult { + id: string + name: string + size: number + contentType: string + downloadUrl?: string + vfsPath: string + mode: WorkspaceFileWriteMode +} + +interface ResolvedCreateTarget { + fileName: string + folderId: string | null + vfsPath: string +} + +export type WorkspaceFileWriteValidation = + | { + mode: 'create' + vfsPath: string + fileName: string + folderId: string | null + } + | { + mode: 'overwrite' + vfsPath: string + existingFileId: string + } + +function displayFolderPath(segments: string[]): string { + return segments.length > 0 ? `files/${segments.join('/')}` : 'files/' +} + +export function parseWorkspaceFileCreatePath(path: string): { + folderSegments: string[] + fileName: string + vfsPath: string +} { + const trimmed = path.trim().replace(/^\/+/, '') + if (!trimmed.startsWith('files/')) { + throw new Error('Workspace file paths must start with "files/"') + } + + const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length)) + if (decoded.length === 0) { + throw new Error('Workspace file path must include a file name') + } + + const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File') + const folderSegments = decoded + .slice(0, -1) + .map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) + + return { + folderSegments, + fileName, + vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }), + } +} + +async function resolveCreateTarget( + workspaceId: string, + path: string +): Promise { + const parsed = parseWorkspaceFileCreatePath(path) + const folderId = + parsed.folderSegments.length > 0 + ? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) + : null + + if (parsed.folderSegments.length > 0 && !folderId) { + throw new Error( + `Directory not yet created: ${displayFolderPath(parsed.folderSegments)}. Create the directory first, then retry the file write.` + ) + } + + const existing = await getWorkspaceFileByName(workspaceId, parsed.fileName, { folderId }) + if (existing) { + throw new Error(`File already exists at ${parsed.vfsPath}. Use mode "overwrite" to update it.`) + } + + return { + fileName: parsed.fileName, + folderId, + vfsPath: parsed.vfsPath, + } +} + +function vfsPathForRecord(record: WorkspaceFileRecord): string { + return canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) +} + +export async function validateWorkspaceFileWriteTarget(args: { + workspaceId: string + target: WorkspaceFileWriteTarget +}): Promise { + if (args.target.mode === 'overwrite') { + const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + if (!existing) { + throw new Error(`File not found for overwrite: ${args.target.path}`) + } + return { + mode: 'overwrite', + vfsPath: vfsPathForRecord(existing), + existingFileId: existing.id, + } + } + + const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path) + return { + mode: 'create', + vfsPath: createTarget.vfsPath, + fileName: createTarget.fileName, + folderId: createTarget.folderId, + } +} + +export async function writeWorkspaceFileByPath(args: { + workspaceId: string + userId: string + target: WorkspaceFileWriteTarget + buffer: Buffer + inferredMimeType: string +}): Promise { + const contentType = args.target.mimeType || args.inferredMimeType + + if (args.target.mode === 'overwrite') { + const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + if (!existing) { + throw new Error(`File not found for overwrite: ${args.target.path}`) + } + + const updated = await updateWorkspaceFileContent( + args.workspaceId, + existing.id, + args.userId, + args.buffer, + contentType || existing.type + ) + + return { + id: updated.id, + name: updated.name, + size: updated.size, + contentType: updated.type, + downloadUrl: updated.url, + vfsPath: vfsPathForRecord(updated), + mode: 'overwrite', + } + } + + const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path) + const uploaded = await uploadWorkspaceFile( + args.workspaceId, + args.userId, + args.buffer, + createTarget.fileName, + contentType, + { folderId: createTarget.folderId } + ) + + return { + id: uploaded.id, + name: uploaded.name, + size: uploaded.size, + contentType: uploaded.type, + downloadUrl: uploaded.url, + vfsPath: createTarget.vfsPath, + mode: 'create', + } +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 678eaa7f8b0..6ccb05d7994 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -29,6 +29,7 @@ import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-read import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import type { DirEntry, GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' import * as ops from '@/lib/copilot/vfs/operations' +import { canonicalWorkspaceFilePath, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import type { DeploymentData } from '@/lib/copilot/vfs/serializers' import { serializeApiKeys, @@ -74,6 +75,7 @@ import { findWorkspaceFileRecord, getWorkspaceFile, listWorkspaceFiles, + type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' @@ -93,6 +95,7 @@ import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' import { TRIGGER_REGISTRY } from '@/triggers/registry' const logger = createLogger('WorkspaceVFS') +const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024 /** Static component files, computed once and shared across all VFS instances */ let staticComponentFiles: Map | null = null @@ -321,8 +324,7 @@ function getStaticComponentFiles(): Map { * knowledgebases/{name}/documents.json * knowledgebases/{name}/connectors.json * tables/{name}/meta.json - * files/{name}/meta.json - * files/by-id/{id}/meta.json + * files/{name} (workspace file leaf; dynamic content on read) * files/by-id/{id}/style (dynamic — style extraction for .docx/.pptx/.pdf) * files/by-id/{id}/compiled-check (dynamic — compile generated source / validate diagrams, returns {ok,error?}) * jobs/{title}/meta.json @@ -392,7 +394,7 @@ export class WorkspaceVFS { workspaceId, error: toError(error).message, }) - return { tools: [] } + return { tools: [], catalogContext: '' } }), ]) @@ -471,21 +473,96 @@ export class WorkspaceVFS { return ops.suggestSimilar(this.files, missingPath, max) } + private async resolveWorkspaceFileForDynamicRead( + path: string, + suffix: 'style' | 'compiled-check' | 'compiled' + ): Promise { + const byIdMatch = path.match(new RegExp(`^files/by-id/([^/]+)/${suffix}$`)) + if (byIdMatch?.[1]) { + return getWorkspaceFile(this._workspaceId, byIdMatch[1]) + } + + const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) + if (!canonicalMatch?.[1]) return null + + const files = await listWorkspaceFiles(this._workspaceId) + return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) + } + /** * Attempt to read dynamic workspace file content from storage. - * Handles images (base64), parseable documents (PDF, etc.), and text files. + * Handles explicit /content reads for images, PDFs, documents, and text files. * Also handles: - * `files/by-id/{id}/style` — style extraction (.docx / .pptx / .pdf) - * `files/by-id/{id}/compiled-check` — compile JS-source binary files or validate Mermaid diagrams - * Returns null if the path doesn't match `files/{name}` / `files/by-id/{id}` or the file isn't found. + * `files/{path}/{name}/style` — style extraction (.docx / .pptx / .pdf) + * `files/{path}/{name}/compiled-check` — compile JS-source binary files or validate Mermaid diagrams + * `files/{path}/{name}/compiled` — compile JS-source binary files and return the compiled artifact as an attachment + * Legacy `files/by-id/{id}/...` dynamic paths remain supported as a compatibility adapter. + * Returns null if the path doesn't match a dynamic file path or the file isn't found. */ async readFileContent(path: string): Promise { - // Handle compiled-check path: files/by-id/{id}/compiled-check - const compiledCheckMatch = path.match(/^files\/by-id\/([^/]+)\/compiled-check$/) + const compiledMatch = /^files\/(?:by-id\/[^/]+|.+)\/compiled$/.test(path) + if (compiledMatch) { + let record: WorkspaceFileRecord | null = null + try { + record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled') + if (!record) return null + const ext = record.name.split('.').pop()?.toLowerCase() ?? '' + const taskId = BINARY_DOC_TASKS[ext] + if (!taskId) return null + const buffer = await fetchWorkspaceFileBuffer(record) + const code = buffer.toString('utf-8') + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + return { + content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }), + totalLines: 1, + } + } + const compiled = await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) + if (compiled.length > MAX_COMPILED_ATTACHMENT_BYTES) { + return { + content: `[Compiled artifact too large: ${record.name} (${compiled.length} bytes, limit ${MAX_COMPILED_ATTACHMENT_BYTES})]`, + totalLines: 1, + } + } + const contentType = + ext === 'pdf' + ? 'application/pdf' + : ext === 'docx' + ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + : 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + return { + content: `Compiled file: ${record.name} (${compiled.length} bytes, ${contentType})`, + totalLines: 1, + attachment: { + type: 'file', + name: record.name, + source: { + type: 'base64', + media_type: contentType, + data: compiled.toString('base64'), + }, + }, + } + } catch (err) { + logger.warn('Compiled artifact read failed via VFS', { + workspaceId: this._workspaceId, + path, + fileId: record?.id, + error: toError(err).message, + }) + if (err instanceof SandboxUserCodeError) { + const json = JSON.stringify({ ok: false, error: toError(err).message, errorName: err.name }) + return { content: json, totalLines: 1 } + } + return null + } + } + + const compiledCheckMatch = /^files\/(?:by-id\/[^/]+|.+)\/compiled-check$/.test(path) if (compiledCheckMatch) { - const fileId = compiledCheckMatch[1] + let record: WorkspaceFileRecord | null = null try { - const record = await getWorkspaceFile(this._workspaceId, fileId) + record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled-check') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' const taskId = BINARY_DOC_TASKS[ext] @@ -521,19 +598,19 @@ export class WorkspaceVFS { } catch (err) { logger.warn('Compiled check failed via VFS', { workspaceId: this._workspaceId, - fileId, + path, + fileId: record?.id, error: toError(err).message, }) return null } } - // Handle style extraction path: files/by-id/{id}/style - const styleMatch = path.match(/^files\/by-id\/([^/]+)\/style$/) + const styleMatch = /^files\/(?:by-id\/[^/]+|.+)\/style$/.test(path) if (styleMatch) { - const fileId = styleMatch[1] + let record: WorkspaceFileRecord | null = null try { - const record = await getWorkspaceFile(this._workspaceId, fileId) + record = await this.resolveWorkspaceFileForDynamicRead(path, 'style') if (!record) return null const rawExt = record.name.split('.').pop()?.toLowerCase() if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') return null @@ -546,15 +623,16 @@ export class WorkspaceVFS { } catch (err) { logger.warn('Failed to extract document style via VFS', { workspaceId: this._workspaceId, - fileId, + path, + fileId: record?.id, error: toError(err).message, }) return null } } - const deletedMatch = path.match(/^recently-deleted\/files\/(.+?)(?:\/content)?$/) - const activeMatch = path.match(/^files\/(.+?)(?:\/content)?$/) + const deletedMatch = path.match(/^recently-deleted\/files\/(.+)\/content$/) + const activeMatch = path.match(/^files\/(.+)\/content$/) const match = deletedMatch || activeMatch if (!match) return null const fileReference = path @@ -880,36 +958,25 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const files = await listWorkspaceFiles(workspaceId) + const folders = await listWorkspaceFileFolders(workspaceId) + const files = await listWorkspaceFiles(workspaceId, { folders }) + for (const folder of folders) { + this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') + } for (const file of files) { - const safeName = sanitizeName(file.name) - const safeFolderPath = file.folderPath - ?.split('/') - .map((segment) => sanitizeName(segment)) - .join('/') - const fileVfsPath = safeFolderPath ? `${safeFolderPath}/${safeName}` : safeName - this.files.set( - `files/${fileVfsPath}/meta.json`, - serializeFileMeta({ - id: file.id, - name: file.name, - folderId: file.folderId, - folderPath: file.folderPath, - vfsPath: `files/${fileVfsPath}`, - contentType: file.type, - size: file.size, - uploadedAt: file.uploadedAt, - }) - ) + const filePath = canonicalWorkspaceFilePath({ + folderPath: file.folderPath, + name: file.name, + }) this.files.set( - `files/by-id/${file.id}/meta.json`, + filePath, serializeFileMeta({ id: file.id, name: file.name, folderId: file.folderId, folderPath: file.folderPath, - vfsPath: `files/${fileVfsPath}`, + vfsPath: filePath, contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, @@ -1464,20 +1531,19 @@ export class WorkspaceVFS { } for (const file of archivedFiles) { - const safeName = sanitizeName(file.name) - const safeFolderPath = file.folderPath - ?.split('/') - .map((segment) => sanitizeName(segment)) - .join('/') - const fileVfsPath = safeFolderPath ? `${safeFolderPath}/${safeName}` : safeName + const filePath = canonicalWorkspaceFilePath({ + folderPath: file.folderPath, + name: file.name, + prefix: 'recently-deleted/files', + }) this.files.set( - `recently-deleted/files/${fileVfsPath}/meta.json`, + filePath, serializeFileMeta({ id: file.id, name: file.name, folderId: file.folderId, folderPath: file.folderPath, - vfsPath: `recently-deleted/files/${fileVfsPath}`, + vfsPath: filePath, contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index 1338b35d28f..ff8734cb343 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -1,4 +1,4 @@ -import { Sandbox } from '@e2b/code-interpreter' +import type { Sandbox as E2BSandbox } from '@e2b/code-interpreter' import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' import { CodeLanguage } from '@/lib/execution/languages' @@ -15,6 +15,7 @@ export interface E2BExecutionRequest { timeoutMs: number sandboxFiles?: SandboxFile[] outputSandboxPath?: string + outputSandboxPaths?: string[] } export interface E2BShellExecutionRequest { @@ -23,6 +24,7 @@ export interface E2BShellExecutionRequest { timeoutMs: number sandboxFiles?: SandboxFile[] outputSandboxPath?: string + outputSandboxPaths?: string[] } export interface E2BExecutionResult { @@ -31,21 +33,82 @@ export interface E2BExecutionResult { sandboxId?: string error?: string exportedFileContent?: string + exportedFiles?: Record /** Base64-encoded PNG images captured from rich outputs (e.g. matplotlib figures). */ images?: string[] } const logger = createLogger('E2BExecution') -export async function executeInE2B(req: E2BExecutionRequest): Promise { - const { code, language, timeoutMs, outputSandboxPath } = req - +async function createE2BSandbox(kind: 'code' | 'shell'): Promise { const apiKey = env.E2B_API_KEY if (!apiKey) { throw new Error('E2B_API_KEY is required when E2B is enabled') } - const sandbox = await Sandbox.create({ apiKey }) + const templateName = env.MOTHERSHIP_E2B_TEMPLATE_ID + logger.info('Creating E2B sandbox', { + kind, + template: templateName || '(default)', + }) + const { Sandbox } = await import('@e2b/code-interpreter') + return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey }) +} + +function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean { + const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() + const binaryExts = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.pdf', + '.zip', + '.mp3', + '.mp4', + '.docx', + '.pptx', + '.xlsx', + ]) + return binaryExts.has(ext) +} + +async function readSandboxOutputFile( + sandbox: E2BSandbox, + outputSandboxPath: string, + options?: { user?: string } +): Promise { + try { + if (shouldReadSandboxPathAsBase64(outputSandboxPath)) { + const b64Result = await sandbox.commands.run(`base64 -w0 "${outputSandboxPath}"`, options) + return b64Result.stdout + } + return await sandbox.files.read(outputSandboxPath) + } catch (error) { + logger.warn('Failed to read requested sandbox output file', { + outputSandboxPath, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } +} + +function requestedOutputSandboxPaths(req: { + outputSandboxPath?: string + outputSandboxPaths?: string[] +}): string[] { + const paths = [...(req.outputSandboxPaths ?? [])] + if (req.outputSandboxPath && !paths.includes(req.outputSandboxPath)) { + paths.push(req.outputSandboxPath) + } + return paths +} + +export async function executeInE2B(req: E2BExecutionRequest): Promise { + const { code, language, timeoutMs } = req + + const sandbox = await createE2BSandbox('code') const sandboxId = sandbox.sandboxId if (req.sandboxFiles?.length) { @@ -134,36 +197,23 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise = {} + for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { + const content = await readSandboxOutputFile(sandbox, outputSandboxPath) + if (content !== undefined) { + exportedFiles[outputSandboxPath] = content } } + const exportedFileContent = req.outputSandboxPath + ? exportedFiles[req.outputSandboxPath] + : undefined return { result, stdout: cleanedStdout, sandboxId, exportedFileContent, + exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined, images: images.length ? images : undefined, } } finally { @@ -176,20 +226,9 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise { - const { code, envs, timeoutMs, outputSandboxPath } = req - - const apiKey = env.E2B_API_KEY - if (!apiKey) { - throw new Error('E2B_API_KEY is required when E2B is enabled') - } + const { code, envs, timeoutMs } = req - const templateName = env.MOTHERSHIP_E2B_TEMPLATE_ID - logger.info('Creating E2B shell sandbox', { - template: templateName || '(default)', - }) - const sandbox = templateName - ? await Sandbox.create(templateName, { apiKey }) - : await Sandbox.create({ apiKey }) + const sandbox = await createE2BSandbox('shell') const sandboxId = sandbox.sandboxId if (req.sandboxFiles?.length) { @@ -275,34 +314,26 @@ export async function executeShellInE2B( cleanedStdout = filteredLines.join('\n') } - let exportedFileContent: string | undefined - if (outputSandboxPath) { - const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() - const binaryExts = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.pdf', - '.zip', - '.mp3', - '.mp4', - '.docx', - '.pptx', - '.xlsx', - ]) - if (binaryExts.has(ext)) { - const b64Result = await sandbox.commands.run(`base64 -w0 "${outputSandboxPath}"`, { - user: 'root', - }) - exportedFileContent = b64Result.stdout - } else { - exportedFileContent = await sandbox.files.read(outputSandboxPath) + const exportedFiles: Record = {} + for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { + const content = await readSandboxOutputFile(sandbox, outputSandboxPath, { + user: 'root', + }) + if (content !== undefined) { + exportedFiles[outputSandboxPath] = content } } + const exportedFileContent = req.outputSandboxPath + ? exportedFiles[req.outputSandboxPath] + : undefined - return { result: parsed, stdout: cleanedStdout, sandboxId, exportedFileContent } + return { + result: parsed, + stdout: cleanedStdout, + sandboxId, + exportedFileContent, + exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined, + } } finally { try { await sandbox.kill() diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 89b3bd37d91..adc3e54254b 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -16,6 +16,7 @@ import { incrementStorageUsage, } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { getServePathPrefix } from '@/lib/uploads' import { @@ -697,24 +698,24 @@ export function normalizeWorkspaceFileReference(fileReference: string): string { if (withoutDeletedPrefix.startsWith('files/')) { const withoutPrefix = withoutDeletedPrefix.slice('files/'.length) if (withoutPrefix.endsWith('/meta.json')) { - return withoutPrefix.slice(0, -'/meta.json'.length) + return decodeVfsPathSegments(withoutPrefix.slice(0, -'/meta.json'.length)).join('/') } if (withoutPrefix.endsWith('/content')) { - return withoutPrefix.slice(0, -'/content'.length) + return decodeVfsPathSegments(withoutPrefix.slice(0, -'/content'.length)).join('/') } - return withoutPrefix + return decodeVfsPathSegments(withoutPrefix).join('/') } - return withoutDeletedPrefix + return decodeVfsPathSegments(withoutDeletedPrefix).join('/') } /** * Canonical sandbox mount path for an existing workspace file. */ export function getSandboxWorkspaceFilePath( - file: Pick + file: Pick ): string { - return `/home/user/files/${file.id}/${file.name}` + return `/home/user/${canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name })}` } /** diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 558264ea768..f1f0932b6bd 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -83,6 +83,8 @@ const nextConfig: NextConfig = { 'fluent-ffmpeg', 'ws', 'isolated-vm', + '@e2b/code-interpreter', + 'e2b', ], outputFileTracingIncludes: { '/api/tools/stagehand/*': ['./node_modules/ws/**/*'], diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index b174634e57f..2b4a95f9ed1 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -61,9 +61,11 @@ describe('Function Execute Tool', () => { language: 'javascript', outputFormat: undefined, outputMimeType: undefined, + overwriteFileId: undefined, outputPath: undefined, outputSandboxPath: undefined, outputTable: undefined, + title: undefined, timeout: 5000, workflowId: undefined, executionId: undefined, @@ -98,9 +100,11 @@ describe('Function Execute Tool', () => { language: 'javascript', outputFormat: undefined, outputMimeType: undefined, + overwriteFileId: undefined, outputPath: undefined, outputSandboxPath: undefined, outputTable: undefined, + title: undefined, workflowId: undefined, executionId: undefined, workspaceId: undefined, @@ -126,9 +130,11 @@ describe('Function Execute Tool', () => { language: 'javascript', outputFormat: undefined, outputMimeType: undefined, + overwriteFileId: undefined, outputPath: undefined, outputSandboxPath: undefined, outputTable: undefined, + title: undefined, workflowId: undefined, executionId: undefined, workspaceId: undefined, diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index 36360fb8de7..857799f65ef 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -38,6 +38,12 @@ export const functionExecuteTool: ToolConfig + directories?: Array<{ path: string; sandboxPath?: string }> + tables?: Array<{ path?: string; tableId?: string; sandboxPath?: string }> + } + outputs?: { + files?: Array<{ + path: string + mode: 'create' | 'overwrite' + sandboxPath?: string + format?: 'json' | 'csv' | 'txt' | 'md' | 'html' + mimeType?: string + }> + } envVars?: Record workflowVariables?: Record blockData?: Record @@ -37,7 +53,7 @@ export interface CodeExecutionInput { copilotToolExecution?: boolean } isCustomTool?: boolean - _sandboxFiles?: Array<{ path: string; content: string }> + _sandboxFiles?: Array<{ path: string; content: string; encoding?: 'base64' }> } export interface CodeExecutionOutput extends ToolResponse { From c0b16571a143d4c436d2f1a66663335fc6ca61e9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 21 May 2026 16:00:51 -0700 Subject: [PATCH 8/9] Changelog and plan --- apps/sim/app/api/function/execute/route.ts | 2 + .../app/workspace/[workspaceId]/home/home.tsx | 67 +++- .../[workspaceId]/home/hooks/use-chat.ts | 15 +- .../lib/copilot/generated/tool-catalog-v1.ts | 59 ++- .../lib/copilot/generated/tool-schemas-v1.ts | 55 ++- .../request/go/file-preview-adapter.ts | 134 ++++++- .../sim/lib/copilot/request/go/stream.test.ts | 268 ++++++++++++++ apps/sim/lib/copilot/request/types.ts | 2 +- .../tools/handlers/function-execute.ts | 50 ++- .../copilot/tools/handlers/resources.test.ts | 55 +++ .../copilot/tools/server/files/create-file.ts | 10 + .../tools/server/files/touch-plan.test.ts | 139 ++++++++ .../copilot/tools/server/files/touch-plan.ts | 142 ++++++++ .../tools/server/files/workspace-file.ts | 59 ++- apps/sim/lib/copilot/tools/server/router.ts | 4 + .../lib/copilot/vfs/resource-writer.test.ts | 193 ++++++++++ apps/sim/lib/copilot/vfs/resource-writer.ts | 203 ++++++++++- .../vfs/workflow-alias-backing.test.ts | 82 +++++ .../lib/copilot/vfs/workflow-alias-backing.ts | 198 +++++++++++ .../copilot/vfs/workflow-alias-resolver.ts | 55 +++ .../lib/copilot/vfs/workflow-aliases.test.ts | 132 +++++++ apps/sim/lib/copilot/vfs/workflow-aliases.ts | 336 ++++++++++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 198 ++++++++++- .../workspace-file-folder-manager.ts | 5 +- .../workspace/workspace-file-manager.ts | 30 +- apps/sim/lib/workflows/lifecycle.ts | 17 + apps/sim/lib/workflows/utils.ts | 3 + 27 files changed, 2453 insertions(+), 60 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/files/touch-plan.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/files/touch-plan.ts create mode 100644 apps/sim/lib/copilot/vfs/resource-writer.test.ts create mode 100644 apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts create mode 100644 apps/sim/lib/copilot/vfs/workflow-alias-backing.ts create mode 100644 apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts create mode 100644 apps/sim/lib/copilot/vfs/workflow-aliases.test.ts create mode 100644 apps/sim/lib/copilot/vfs/workflow-aliases.ts diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 3bbeea60a8d..49c5ec05e68 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1121,6 +1121,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { preparedFiles.map((prepared) => validateWorkspaceFileWriteTarget({ workspaceId: resolvedWorkspaceId, + userId: args.authUserId, target: prepared.target, }) ) @@ -1205,6 +1206,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { fileId: file.id, fileName: file.name, vfsPath: file.vfsPath, + backingVfsPath: file.backingVfsPath, downloadUrl: file.downloadUrl, sandboxPath: file.sandboxPath, })), diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 52a3bf4442c..02d6675aa66 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' @@ -9,6 +9,12 @@ import { PanelLeft } from '@/components/emcn/icons' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' import { useSession } from '@/lib/auth/auth-client' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { + buildWorkflowAliasWorkflowEntries, + resolveWorkflowAliasPath, + resolveWorkspacePlanAliasPath, +} from '@/lib/copilot/vfs/workflow-aliases' import { LandingPromptStorage, type LandingWorkflowSeed, @@ -16,7 +22,10 @@ import { } from '@/lib/core/utils/browser-storage' import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' +import { useFolders } from '@/hooks/queries/folders' import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import type { ChatContext } from '@/stores/panel' import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components' import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks' @@ -34,6 +43,9 @@ export function Home({ chatId }: HomeProps = {}) { const searchParams = useSearchParams() const initialResourceId = searchParams.get('resource') const { data: session } = useSession() + const { data: workspaceFiles = [] } = useWorkspaceFiles(workspaceId) + const { data: workflows = [] } = useWorkflows(workspaceId) + const { data: folders = [] } = useFolders(workspaceId) const posthog = usePostHog() const posthogRef = useRef(posthog) posthogRef.current = posthog @@ -275,10 +287,59 @@ export function Home({ chatId }: HomeProps = {}) { removeResource(resolved.type, resolved.id) } + const workflowAliasEntries = useMemo( + () => + buildWorkflowAliasWorkflowEntries( + workflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + folderId: workflow.folderId ?? null, + })), + folders.map((folder) => ({ + folderId: folder.id, + folderName: folder.name, + parentId: folder.parentId ?? null, + })) + ), + [folders, workflows] + ) + + const resolveFileResource = useCallback( + (resource: MothershipResource): MothershipResource => { + if (resource.type !== 'file') return resource + + const reference = (resource.path || resource.id).trim() + const workspacePlanAlias = resolveWorkspacePlanAliasPath(reference) + const workflowAlias = workspacePlanAlias + ? null + : resolveWorkflowAliasPath(reference, workflowAliasEntries) + const alias = workspacePlanAlias || workflowAlias + const targetPath = alias && alias.kind !== 'plans_dir' ? alias.backingPath : reference + + const file = workspaceFiles.find((candidate) => { + const candidatePath = canonicalWorkspaceFilePath({ + folderPath: candidate.folderPath, + name: candidate.name, + }) + return candidate.id === reference || candidatePath === reference || candidatePath === targetPath + }) + + if (!file) return resource + return { + ...resource, + id: file.id, + title: resource.title || file.name, + path: alias ? reference : resource.path, + } + }, + [workflowAliasEntries, workspaceFiles] + ) + function handleWorkspaceResourceSelect(resource: MothershipResource) { - const wasAdded = addResource(resource) + const resolvedResource = resolveFileResource(resource) + const wasAdded = addResource(resolvedResource) if (!wasAdded) { - setActiveResourceId(resource.id) + setActiveResourceId(resolvedResource.id) } handleResourceEvent() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 3ec0168918f..098cde0b274 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1797,7 +1797,20 @@ export function useChat( } if (session.fileId && hasRenderableFilePreviewContent(session)) { - setResources((current) => current.filter((resource) => resource.id !== 'streaming-file')) + setResources((current) => { + const withoutStreaming = current.filter((resource) => resource.id !== 'streaming-file') + if (withoutStreaming.some((resource) => resource.type === 'file' && resource.id === session.fileId)) { + return withoutStreaming + } + return [ + ...withoutStreaming, + { + type: 'file', + id: session.fileId!, + title: session.fileName || 'File', + }, + ] + }) if (options?.activate !== false) { setActiveResourceId(session.fileId) } diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index ea73e890a2a..700d74615fa 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -93,6 +93,7 @@ export interface ToolCatalogEntry { | 'superagent' | 'table' | 'tool_search_tool_regex' + | 'touch_plan' | 'update_job_history' | 'update_workspace_mcp_server' | 'user_memory' @@ -188,6 +189,7 @@ export interface ToolCatalogEntry { | 'superagent' | 'table' | 'tool_search_tool_regex' + | 'touch_plan' | 'update_job_history' | 'update_workspace_mcp_server' | 'user_memory' @@ -1145,7 +1147,7 @@ export const FunctionExecute: ToolCatalogEntry = { path: { type: 'string', description: - 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/files/Reports".', + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', }, sandboxPath: { type: 'string', @@ -1165,7 +1167,7 @@ export const FunctionExecute: ToolCatalogEntry = { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/files/Reports/sales.csv".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', }, sandboxPath: { type: 'string', @@ -1230,7 +1232,8 @@ export const FunctionExecute: ToolCatalogEntry = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', }, sandboxPath: { type: 'string', @@ -1306,7 +1309,7 @@ export const GenerateImage: ToolCatalogEntry = { path: { type: 'string', description: - 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/files/Reports".', + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', }, sandboxPath: { type: 'string', @@ -1326,7 +1329,7 @@ export const GenerateImage: ToolCatalogEntry = { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/files/Reports/sales.csv".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', }, sandboxPath: { type: 'string', @@ -1381,7 +1384,8 @@ export const GenerateImage: ToolCatalogEntry = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', }, sandboxPath: { type: 'string', @@ -3050,6 +3054,48 @@ export const ToolSearchToolRegex: ToolCatalogEntry = { }, } +export const TouchPlan: ToolCatalogEntry = { + id: 'touch_plan', + name: 'touch_plan', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: + 'Plan file name or relative path under .plans, e.g. "implementation.md" or "phase-1/implementation.md". If no extension is supplied, ".md" is appended.', + }, + title: { + type: 'string', + description: 'Optional short user-visible label for the plan creation.', + }, + workflowPath: { + type: 'string', + description: + 'Canonical workflow VFS path, e.g. "workflows/My%20Workflow" or "workflows/Folder/My%20Workflow". Copy from glob/read output; do not use workflow IDs.', + }, + }, + required: ['workflowPath', 'name'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Contains id, name, vfsPath, backingVfsPath, and workflowId. Use vfsPath for follow-up workspace_file calls.', + }, + message: { type: 'string', description: 'Human-readable outcome.' }, + success: { type: 'boolean', description: 'Whether the plan file was created.' }, + }, + required: ['success', 'message'], + }, + requiredPermission: 'write', + capabilities: ['file_output'], +} + export const UpdateJobHistory: ToolCatalogEntry = { id: 'update_job_history', name: 'update_job_history', @@ -3882,6 +3928,7 @@ export const TOOL_CATALOG: Record = { [Superagent.id]: Superagent, [Table.id]: Table, [ToolSearchToolRegex.id]: ToolSearchToolRegex, + [TouchPlan.id]: TouchPlan, [UpdateJobHistory.id]: UpdateJobHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserMemory.id]: UserMemory, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 4231d345fb7..9247b2d27a4 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -938,7 +938,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/files/Reports".', + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', }, sandboxPath: { type: 'string', @@ -958,7 +958,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/files/Reports/sales.csv".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', }, sandboxPath: { type: 'string', @@ -1029,7 +1029,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', }, sandboxPath: { type: 'string', @@ -1094,7 +1095,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/files/Reports".', + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', }, sandboxPath: { type: 'string', @@ -1114,7 +1115,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/files/Reports/sales.csv".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', }, sandboxPath: { type: 'string', @@ -1175,7 +1176,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', }, sandboxPath: { type: 'string', @@ -2805,6 +2807,47 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + ['touch_plan']: { + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: + 'Plan file name or relative path under .plans, e.g. "implementation.md" or "phase-1/implementation.md". If no extension is supplied, ".md" is appended.', + }, + title: { + type: 'string', + description: 'Optional short user-visible label for the plan creation.', + }, + workflowPath: { + type: 'string', + description: + 'Canonical workflow VFS path, e.g. "workflows/My%20Workflow" or "workflows/Folder/My%20Workflow". Copy from glob/read output; do not use workflow IDs.', + }, + }, + required: ['workflowPath', 'name'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Contains id, name, vfsPath, backingVfsPath, and workflowId. Use vfsPath for follow-up workspace_file calls.', + }, + message: { + type: 'string', + description: 'Human-readable outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the plan file was created.', + }, + }, + required: ['success', 'message'], + }, + }, ['update_job_history']: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index f9c63d13027..46c40413c01 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -26,6 +26,7 @@ import { buildFilePreviewText, loadWorkspaceFileTextForPreview, } from '@/lib/copilot/tools/server/files/file-preview' +import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('CopilotFilePreviewAdapter') @@ -64,6 +65,27 @@ function toPreviewTargetKind(kind: string | undefined): FilePreviewTargetKind | return kind === 'new_file' || kind === 'file_id' ? kind : undefined } +async function resolvePreviewTarget(args: { + workspaceId?: string + target: FileIntent['target'] +}): Promise { + if (args.target.kind !== 'path' || !args.workspaceId || !args.target.path) { + return args.target + } + + const file = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + if (!file) { + return args.target + } + + return { + kind: 'file_id', + fileId: file.id, + fileName: args.target.fileName ?? file.name, + path: args.target.path, + } +} + function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undefined { const args = asJsonRecord(value) if (!args) { @@ -79,6 +101,7 @@ function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undef const fileId = typeof target.fileId === 'string' ? target.fileId : undefined const fileName = typeof target.fileName === 'string' ? target.fileName : undefined + const path = typeof target.path === 'string' ? target.path : undefined const title = typeof args.title === 'string' ? args.title : undefined const contentType = typeof args.contentType === 'string' ? args.contentType : undefined const edit = asJsonRecord(args.edit) @@ -89,6 +112,7 @@ function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undef kind: targetKind, ...(fileId ? { fileId } : {}), ...(fileName ? { fileName } : {}), + ...(path ? { path } : {}), }, ...(title ? { title } : {}), ...(contentType ? { contentType } : {}), @@ -96,6 +120,41 @@ function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undef } } +function extractWorkspaceFileResult(output: unknown): { fileId?: string; fileName?: string } { + const candidates: JsonRecord[] = [] + const root = asJsonRecord(output) + if (root) { + candidates.push(root) + const rootData = asJsonRecord(root.data) + if (rootData) candidates.push(rootData) + const rootOutput = asJsonRecord(root.output) + if (rootOutput) { + candidates.push(rootOutput) + const outputData = asJsonRecord(rootOutput.data) + if (outputData) candidates.push(outputData) + } + } + + for (const candidate of candidates) { + const fileId = + typeof candidate.id === 'string' + ? candidate.id + : typeof candidate.fileId === 'string' + ? candidate.fileId + : undefined + if (!fileId) continue + + const fileName = + typeof candidate.name === 'string' + ? candidate.name + : typeof candidate.fileName === 'string' + ? candidate.fileName + : undefined + return { fileId, fileName } + } + return {} +} + export function decodeJsonStringPrefix(input: string): string { let output = '' for (let i = 0; i < input.length; i++) { @@ -293,7 +352,11 @@ export async function processFilePreviewStreamEvent(input: { const toolCallId = streamEvent.payload.toolCallId const parsedArgs = parseWorkspaceFileArgs(streamEvent.payload.arguments) if (toolCallId && parsedArgs) { - const { operation, target, title, contentType, edit } = parsedArgs + const { operation, title, contentType, edit } = parsedArgs + const target = await resolvePreviewTarget({ + workspaceId: execContext.workspaceId, + target: parsedArgs.target, + }) const previewTargetKind = toPreviewTargetKind(target.kind) const { fileId, fileName } = target @@ -384,6 +447,75 @@ export async function processFilePreviewStreamEvent(input: { } } + if ( + isToolResultStreamEvent(streamEvent) && + streamEvent.payload.toolName === 'workspace_file' && + context.activeFileIntent && + isContentOperation(context.activeFileIntent.operation) + ) { + const result = extractWorkspaceFileResult(streamEvent.payload.output) + if (result.fileId && context.activeFileIntent.target.kind === 'path') { + context.activeFileIntent = { + ...context.activeFileIntent, + target: { + kind: 'file_id', + fileId: result.fileId, + fileName: result.fileName ?? context.activeFileIntent.target.fileName, + path: context.activeFileIntent.target.path, + }, + } + + let previewBaseContent: string | undefined + if ( + execContext.workspaceId && + (context.activeFileIntent.operation === 'append' || + context.activeFileIntent.operation === 'patch') + ) { + previewBaseContent = await loadWorkspaceFileTextForPreview( + execContext.workspaceId, + result.fileId + ) + } + + let session = buildPreviewSessionFromIntent(streamId, context.activeFileIntent) + if (previewBaseContent !== undefined) { + session = { ...session, baseContent: previewBaseContent } + } + filePreviewState.set(context.activeFileIntent.toolCallId, { + session, + lastEmittedPreviewText: '', + lastSnapshotAt: 0, + }) + await persistFilePreviewSession(session) + + await emitPreviewEvent(streamEvent, options, { + toolCallId: context.activeFileIntent.toolCallId, + toolName: 'workspace_file', + previewPhase: 'file_preview_start', + }) + await emitPreviewEvent(streamEvent, options, { + toolCallId: context.activeFileIntent.toolCallId, + toolName: 'workspace_file', + previewPhase: 'file_preview_target', + operation: context.activeFileIntent.operation, + target: { + kind: 'file_id', + fileId: result.fileId, + ...(result.fileName ? { fileName: result.fileName } : {}), + }, + ...(context.activeFileIntent.title ? { title: context.activeFileIntent.title } : {}), + }) + if (context.activeFileIntent.edit) { + await emitPreviewEvent(streamEvent, options, { + toolCallId: context.activeFileIntent.toolCallId, + toolName: 'workspace_file', + previewPhase: 'file_preview_edit_meta', + edit: context.activeFileIntent.edit, + }) + } + } + } + if ( isToolResultStreamEvent(streamEvent) && streamEvent.payload.toolName === 'workspace_file' && diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 8d08f755d8f..17786b00db3 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -21,6 +21,12 @@ vi.mock('@/lib/copilot/request/session', async () => { } }) +const resolveWorkspaceFileReferenceMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, +})) + import { buildPreviewContentUpdate, decodeJsonStringPrefix, @@ -93,6 +99,8 @@ function createStreamingContext(): StreamingContext { describe('copilot go stream helpers', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) + resolveWorkspaceFileReferenceMock.mockReset() + resolveWorkspaceFileReferenceMock.mockResolvedValue(null) }) afterEach(() => { @@ -140,6 +148,266 @@ describe('copilot go stream helpers', () => { }) }) + it('hydrates path-based workspace_file edits into file preview events before edit_content streams', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'file-1', + name: 'notes.md', + }) + + const workspaceFileCall = createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'workspace-file-1', + toolName: 'workspace_file', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + arguments: { + operation: 'update', + target: { kind: 'path', path: 'files/notes.md' }, + title: 'Update notes', + }, + }, + }) + const workspaceFileResult = createEvent({ + streamId: 'stream-1', + cursor: '2', + seq: 2, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'workspace-file-1', + toolName: 'workspace_file', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: true, + output: { + success: true, + data: { id: 'file-1', name: 'notes.md', operation: 'update' }, + }, + }, + }) + const editContentDelta = createEvent({ + streamId: 'stream-1', + cursor: '3', + seq: 3, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'edit-content-1', + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.args_delta, + argumentsDelta: '{"content":"hello world', + }, + }) + const editContentResult = createEvent({ + streamId: 'stream-1', + cursor: '4', + seq: 4, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'edit-content-1', + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: true, + output: { + success: true, + data: { id: 'file-1', name: 'notes.md' }, + }, + }, + }) + const complete = createEvent({ + streamId: 'stream-1', + cursor: '5', + seq: 5, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { + status: MothershipStreamV1CompletionStatus.complete, + }, + }) + + vi.mocked(fetch).mockResolvedValueOnce( + createSseResponse([ + workspaceFileCall, + workspaceFileResult, + editContentDelta, + editContentResult, + complete, + ]) + ) + + const onEvent = vi.fn() + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + messageId: 'msg-1', + } + + await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + onEvent, + timeout: 1000, + }) + + const previewEvents = onEvent.mock.calls + .map(([event]) => event) + .filter((event) => event.type === MothershipStreamV1EventType.tool && 'previewPhase' in event.payload) + + expect(previewEvents.map((event) => event.payload.previewPhase)).toEqual([ + 'file_preview_start', + 'file_preview_target', + 'file_preview_content', + 'file_preview_complete', + ]) + expect(previewEvents[1].payload).toMatchObject({ + previewPhase: 'file_preview_target', + target: { kind: 'file_id', fileId: 'file-1', fileName: 'notes.md' }, + }) + expect(previewEvents[2].payload).toMatchObject({ + previewPhase: 'file_preview_content', + fileId: 'file-1', + targetKind: 'file_id', + content: 'hello world', + }) + expect(previewEvents[3].payload).toMatchObject({ + previewPhase: 'file_preview_complete', + fileId: 'file-1', + }) + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', 'files/notes.md') + }) + + it('resolves workflow alias paths to the backing file before streaming previews', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'changelog-file-1', + name: 'workflow-1.md', + }) + + const workspaceFileCall = createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'workspace-file-1', + toolName: 'workspace_file', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + arguments: { + operation: 'append', + target: { kind: 'path', path: 'workflows/My%20Workflow/changelog.md' }, + title: 'Update changelog', + }, + }, + }) + const editContentDelta = createEvent({ + streamId: 'stream-1', + cursor: '2', + seq: 2, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'edit-content-1', + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.args_delta, + argumentsDelta: '{"content":"\\n- Added a workflow step', + }, + }) + const editContentResult = createEvent({ + streamId: 'stream-1', + cursor: '3', + seq: 3, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'edit-content-1', + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: true, + output: { + success: true, + data: { id: 'changelog-file-1', name: 'workflow-1.md' }, + }, + }, + }) + const complete = createEvent({ + streamId: 'stream-1', + cursor: '4', + seq: 4, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { + status: MothershipStreamV1CompletionStatus.complete, + }, + }) + + vi.mocked(fetch).mockResolvedValueOnce( + createSseResponse([workspaceFileCall, editContentDelta, editContentResult, complete]) + ) + + const onEvent = vi.fn() + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + messageId: 'msg-1', + } + + await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + onEvent, + timeout: 1000, + }) + + const previewEvents = onEvent.mock.calls + .map(([event]) => event) + .filter( + (event) => event.type === MothershipStreamV1EventType.tool && 'previewPhase' in event.payload + ) + + expect(previewEvents.map((event) => event.payload.previewPhase)).toEqual([ + 'file_preview_start', + 'file_preview_target', + 'file_preview_content', + 'file_preview_complete', + ]) + expect(previewEvents[1].payload).toMatchObject({ + previewPhase: 'file_preview_target', + target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'workflow-1.md' }, + }) + expect(previewEvents[2].payload).toMatchObject({ + previewPhase: 'file_preview_content', + fileId: 'changelog-file-1', + targetKind: 'file_id', + content: '\n- Added a workflow step', + }) + expect(previewEvents[3].payload).toMatchObject({ + previewPhase: 'file_preview_complete', + fileId: 'changelog-file-1', + }) + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( + 'workspace-1', + 'workflows/My%20Workflow/changelog.md' + ) + }) + it('drops duplicate tool_result events before forwarding them', async () => { const toolResult = createEvent({ streamId: 'stream-1', diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 2b310e9d648..6b6c7eae5ed 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -97,7 +97,7 @@ export interface StreamingContext { activeFileIntent?: { toolCallId: string operation: string - target: { kind: string; fileId?: string; fileName?: string } + target: { kind: string; fileId?: string; fileName?: string; path?: string } title?: string contentType?: string edit?: Record diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 941017b7e85..54f835e0e2b 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { encodeVfsPathSegments, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases' import { getTableById, listTables, queryRows } from '@/lib/table/service' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { @@ -69,7 +71,7 @@ async function resolveInputFiles( let totalSize = 0 if (inputFiles?.length && workspaceId) { - const allFiles = await listWorkspaceFiles(workspaceId) + const allFiles = await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) for (const fileRef of inputFiles) { const filePath = typeof fileRef === 'string' @@ -78,7 +80,16 @@ async function resolveInputFiles( ? (fileRef as CanonicalFileInput).path : undefined if (!filePath) continue - const record = findWorkspaceFileRecord(allFiles, filePath) + const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: filePath }) + if (!alias && isPlanAliasPath(filePath)) { + logger.warn('Unsupported plan alias input file path', { filePath }) + continue + } + if (alias?.kind === 'plans_dir') { + logger.warn('Input file is a plan alias directory', { filePath }) + continue + } + const record = findWorkspaceFileRecord(allFiles, alias?.backingPath ?? filePath) if (!record) { logger.warn('Input file not found', { fileRef }) continue @@ -102,7 +113,9 @@ async function resolveInputFiles( ? (fileRef as CanonicalFileInput).sandboxPath : undefined sandboxFiles.push({ - path: explicitSandboxPath || getSandboxWorkspaceFilePath(record), + path: + explicitSandboxPath || + (alias ? workflowAliasSandboxPath(alias.aliasPath) : getSandboxWorkspaceFilePath(record)), content, encoding: isText ? undefined : 'base64', }) @@ -110,8 +123,13 @@ async function resolveInputFiles( } if (inputDirectories?.length && workspaceId) { - const folders = await listWorkspaceFileFolders(workspaceId) - const allFiles = await listWorkspaceFiles(workspaceId, { folders }) + const folders = await listWorkspaceFileFolders(workspaceId, { + includeReservedSystemFolders: true, + }) + const allFiles = await listWorkspaceFiles(workspaceId, { + folders, + includeReservedSystemFiles: true, + }) for (const dirRef of inputDirectories) { const dirPath = typeof dirRef === 'string' @@ -120,7 +138,15 @@ async function resolveInputFiles( ? (dirRef as CanonicalDirectoryInput).path : undefined if (!dirPath) continue - const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, '')) + const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: dirPath }) + if (alias && alias.kind !== 'plans_dir') { + throw new Error(`Input directory is a plan alias file, not a directory: ${dirPath}`) + } + if (!alias && isPlanAliasPath(dirPath)) { + throw new Error(`Unsupported plan alias directory: ${dirPath}`) + } + const backingDirPath = alias?.backingPath ?? dirPath + const folderSegments = decodeVfsPathSegments(backingDirPath.replace(/^\/?files\/?/, '')) const folderDisplayPath = folderSegments.join('/') const folder = folders.find((candidate) => candidate.path === folderDisplayPath) if (!folder) { @@ -131,7 +157,9 @@ async function resolveInputFiles( dirRef !== null && (dirRef as CanonicalDirectoryInput).sandboxPath ? (dirRef as CanonicalDirectoryInput).sandboxPath! - : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` + : alias + ? workflowAliasSandboxPath(alias.aliasPath) + : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` const descendants = allFiles.filter((file) => { if (!file.folderPath) return false return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) @@ -181,7 +209,11 @@ async function resolveInputFiles( ) const relativeFolder = record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? '' - const relativePath = [relativeFolder, record.name].filter(Boolean).join('/') + const relativePath = alias + ? encodeVfsPathSegments( + [relativeFolder, record.name].filter(Boolean).join('/').split('/') + ) + : [relativeFolder, record.name].filter(Boolean).join('/') sandboxFiles.push({ path: `${mountRoot}/${relativePath}`, content: isText ? buffer.toString('utf-8') : buffer.toString('base64'), diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index e5a7cb459f3..c942919b7c6 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -99,4 +99,59 @@ describe('executeOpenResource', () => { ], }) }) + + it('opens workflow alias file paths through workspace file reference resolution', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'wf_plan_file', + name: 'implementation.md', + }) + + const result = await executeOpenResource( + { + resources: [{ type: 'file', path: 'workflows/My%20Workflow/.plans/implementation.md' }], + }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( + 'workspace-1', + 'workflows/My%20Workflow/.plans/implementation.md' + ) + expect(result).toMatchObject({ + success: true, + resources: [ + { + type: 'file', + id: 'wf_plan_file', + title: 'implementation.md', + }, + ], + }) + }) + + it('opens root plan alias file paths through workspace file reference resolution', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'wf_root_plan', + name: 'root.md', + }) + + const result = await executeOpenResource( + { + resources: [{ type: 'file', path: '.plans/root.md' }], + }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', '.plans/root.md') + expect(result).toMatchObject({ + success: true, + resources: [ + { + type: 'file', + id: 'wf_root_plan', + title: 'root.md', + }, + ], + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index 1d5e3629546..d7b9489d6fd 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -6,6 +6,7 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases' import { inferContentType } from './workspace-file' const logger = createLogger('CreateFileServerTool') @@ -26,6 +27,7 @@ interface CreateFileResult { name: string contentType: string vfsPath: string + backingVfsPath?: string } } @@ -50,6 +52,13 @@ export const createFileServerTool: BaseServerTool ({ + ensureWorkspaceAccess: vi.fn(), + resolveWorkflowAliasForWorkspace: vi.fn(), + writeWorkspaceFileByPath: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, +})) + +vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({ + resolveWorkflowAliasForWorkspace: mocks.resolveWorkflowAliasForWorkspace, +})) + +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ + writeWorkspaceFileByPath: mocks.writeWorkspaceFileByPath, +})) + +import { touchPlanServerTool } from './touch-plan' + +describe('touch_plan server tool', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) + }) + + it('creates a workflow-local plan alias and returns backing metadata', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/.plans/implementation.md', + backingPath: 'files/.plans/wf_1/implementation.md', + backingFolderPath: 'files/.plans/wf_1', + planRelativePath: 'implementation.md', + }) + mocks.writeWorkspaceFileByPath.mockResolvedValue({ + id: 'file-plan', + name: 'implementation.md', + vfsPath: 'workflows/My%20Workflow/.plans/implementation.md', + backingVfsPath: 'files/.plans/wf_1/implementation.md', + }) + + const result = await touchPlanServerTool.execute( + { workflowPath: 'workflows/My Workflow', name: 'implementation' }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(mocks.resolveWorkflowAliasForWorkspace).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + path: 'workflows/My%20Workflow/.plans/implementation.md', + }) + expect(mocks.writeWorkspaceFileByPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/.plans/implementation.md', + mode: 'create', + mimeType: 'text/markdown', + }, + buffer: Buffer.from('', 'utf-8'), + inferredMimeType: 'text/markdown', + }) + expect(result).toMatchObject({ + success: true, + data: { + id: 'file-plan', + scope: 'workflow', + vfsPath: 'workflows/My%20Workflow/.plans/implementation.md', + backingVfsPath: 'files/.plans/wf_1/implementation.md', + workflowId: 'wf_1', + }, + }) + }) + + it('creates a workspace root plan alias and returns backing metadata', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workspace', + aliasPath: '.plans/migration.md', + backingPath: 'files/.plans/workspace/migration.md', + backingFolderPath: 'files/.plans/workspace', + planRelativePath: 'migration.md', + }) + mocks.writeWorkspaceFileByPath.mockResolvedValue({ + id: 'file-root-plan', + name: 'migration.md', + vfsPath: '.plans/migration.md', + backingVfsPath: 'files/.plans/workspace/migration.md', + }) + + const result = await touchPlanServerTool.execute( + { scope: 'workspace', name: 'migration' }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(mocks.resolveWorkflowAliasForWorkspace).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + path: '.plans/migration.md', + }) + expect(mocks.writeWorkspaceFileByPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: '.plans/migration.md', + mode: 'create', + mimeType: 'text/markdown', + }, + buffer: Buffer.from('', 'utf-8'), + inferredMimeType: 'text/markdown', + }) + expect(result).toMatchObject({ + success: true, + data: { + id: 'file-root-plan', + scope: 'workspace', + vfsPath: '.plans/migration.md', + backingVfsPath: 'files/.plans/workspace/migration.md', + }, + }) + }) + + it('rejects missing workflows before writing', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + + const result = await touchPlanServerTool.execute( + { workflowPath: 'workflows/Missing', name: 'implementation.md' }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(result.message).toContain('Workflow not found') + expect(mocks.writeWorkspaceFileByPath).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/files/touch-plan.ts b/apps/sim/lib/copilot/tools/server/files/touch-plan.ts new file mode 100644 index 00000000000..6e0e6c949ec --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/touch-plan.ts @@ -0,0 +1,142 @@ +import { createLogger } from '@sim/logger' +import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/copilot/tools/server/base-tool' +import { + canonicalizeVfsPath, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' +import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' + +const logger = createLogger('TouchPlanServerTool') +const TOUCH_PLAN_TOOL_ID = 'touch_plan' + +interface TouchPlanArgs { + scope?: 'workspace' | 'workflow' + workflowPath?: string + name: string + title?: string + args?: Record +} + +interface TouchPlanResult { + success: boolean + message: string + data?: { + id: string + name: string + vfsPath: string + backingVfsPath?: string + scope: 'workspace' | 'workflow' + workflowId?: string + } +} + +function normalizeWorkflowPath(path: string): string { + const trimmed = path.trim().replace(/^\/+|\/+$/g, '') + const withoutKnownLeaf = trimmed + .replace(/\/(meta|state|executions|deployment|versions|links)\.json$/, '') + .replace(/\/changelog\.md$/, '') + .replace(/\/\.plans$/, '') + + const canonical = canonicalizeVfsPath(withoutKnownLeaf) + if (!canonical.startsWith('workflows/')) { + throw new Error('workflowPath must be a canonical workflows/... VFS path') + } + return canonical +} + +function normalizePlanRelativePath(name: string): string { + const segments = decodeVfsPathSegments(name) + if (segments.length === 0) { + throw new Error('Plan name is required') + } + const leaf = segments.at(-1) ?? '' + const leafWithExtension = leaf.includes('.') ? leaf : `${leaf}.md` + return encodeVfsPathSegments([...segments.slice(0, -1), leafWithExtension]) +} + +export const touchPlanServerTool: BaseServerTool = { + name: TOUCH_PLAN_TOOL_ID, + async execute(params: TouchPlanArgs, context?: ServerToolContext): Promise { + if (!context?.userId) { + throw new Error('Authentication required') + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + + const nested = params.args + const nestedScope = nested?.scope as TouchPlanArgs['scope'] | undefined + const scope = params.scope || nestedScope || (params.workflowPath || nested?.workflowPath ? 'workflow' : 'workspace') + const workflowPath = params.workflowPath || (nested?.workflowPath as string) || '' + const name = params.name || (nested?.name as string) || '' + if (!name) { + return { success: false, message: 'touch_plan requires name' } + } + if (scope !== 'workspace' && scope !== 'workflow') { + return { success: false, message: 'touch_plan scope must be "workspace" or "workflow"' } + } + if (scope === 'workflow' && !workflowPath) { + return { success: false, message: 'touch_plan with workflow scope requires workflowPath and name' } + } + + const planRelativePath = normalizePlanRelativePath(name) + const aliasPath = + scope === 'workspace' + ? `.plans/${planRelativePath}` + : `${normalizeWorkflowPath(workflowPath)}/.plans/${planRelativePath}` + const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: aliasPath }) + if (!alias || alias.kind !== 'plan_file') { + return { + success: false, + message: + scope === 'workflow' + ? `Workflow not found for plan path: ${aliasPath}` + : `Unsupported workspace plan path: ${aliasPath}`, + } + } + + assertServerToolNotAborted(context) + const result = await writeWorkspaceFileByPath({ + workspaceId, + userId: context.userId, + target: { + path: aliasPath, + mode: 'create', + mimeType: 'text/markdown', + }, + buffer: Buffer.from('', 'utf-8'), + inferredMimeType: 'text/markdown', + }) + + logger.info('Workflow plan touched via copilot', { + workspaceId, + workflowId: alias.scope === 'workflow' ? alias.workflowId : undefined, + scope: alias.scope, + vfsPath: result.vfsPath, + backingVfsPath: result.backingVfsPath, + userId: context.userId, + }) + + return { + success: true, + message: `${alias.scope === 'workspace' ? 'Workspace' : 'Workflow'} plan "${result.vfsPath}" created successfully`, + data: { + id: result.id, + name: result.name, + vfsPath: result.vfsPath, + backingVfsPath: result.backingVfsPath, + scope: alias.scope, + workflowId: alias.scope === 'workflow' ? alias.workflowId : undefined, + }, + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index eed25638798..dd5dfb7abdc 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -8,6 +8,9 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { @@ -205,14 +208,42 @@ export const workspaceFileServerTool: BaseServerTool => { + ): Promise<{ fileRecord?: WorkspaceFileRecord; vfsPath?: string; error?: string }> => { if (!target || (target.kind !== 'path' && target.kind !== 'file_id')) { return { error: `${operationName} requires target.kind=path with target.path` } } - const fileRecord = - target.kind === 'path' - ? await resolveWorkspaceFileReference(workspaceId!, target.path) - : await getWorkspaceFile(workspaceId!, target.fileId) + let fileRecord: WorkspaceFileRecord | null = null + let vfsPath: string | undefined + if (target.kind === 'path') { + const alias = await resolveWorkflowAliasForWorkspace({ + workspaceId: workspaceId!, + path: target.path, + }) + if (!alias && isPlanAliasPath(target.path)) { + return { error: `Unsupported plan alias path or missing workflow: ${target.path}` } + } + if (alias) { + if (alias.kind === 'plans_dir') { + return { error: `Plan alias directory is not a file: ${target.path}` } + } + fileRecord = await resolveWorkspaceFileReference(workspaceId!, alias.backingPath) + if (!fileRecord && alias.kind === 'changelog') { + await ensureWorkflowAliasBacking({ + workspaceId: workspaceId!, + userId: context.userId, + workflowId: alias.workflowId, + workflowName: alias.workflowName, + }) + fileRecord = await resolveWorkspaceFileReference(workspaceId!, alias.backingPath) + } + vfsPath = alias.aliasPath + } else { + fileRecord = await resolveWorkspaceFileReference(workspaceId!, target.path) + vfsPath = target.path + } + } else { + fileRecord = await getWorkspaceFile(workspaceId!, target.fileId) + } if (!fileRecord) { const ref = target.kind === 'path' ? target.path : target.fileId return { error: `File not found: ${ref}` } @@ -222,7 +253,7 @@ export const workspaceFileServerTool: BaseServerTool = { [WorkspaceFile.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], [editContentServerTool.name]: ['*'], [CreateFile.id]: ['*'], + [TouchPlan.id]: ['*'], [RenameFile.id]: ['*'], [DeleteFile.id]: ['*'], [MoveFile.id]: ['*'], @@ -143,6 +146,7 @@ const serverToolRegistry: Record = { [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, + [touchPlanServerTool.name]: touchPlanServerTool, [renameFileServerTool.name]: renameFileServerTool, [deleteFileServerTool.name]: deleteFileServerTool, [moveFileServerTool.name]: moveFileServerTool, diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/copilot/vfs/resource-writer.test.ts new file mode 100644 index 00000000000..4097ad03e24 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/resource-writer.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + ensureWorkflowAliasBacking: vi.fn(), + ensureWorkspacePlanBacking: vi.fn(), + resolveWorkflowAliasForWorkspace: vi.fn(), + ensureWorkspaceFileFolderPath: vi.fn(), + findWorkspaceFileFolderIdByPath: vi.fn(), + normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), + getWorkspaceFileByName: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), + updateWorkspaceFileContent: vi.fn(), + uploadWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/copilot/vfs/workflow-alias-backing', () => ({ + ensureWorkflowAliasBacking: mocks.ensureWorkflowAliasBacking, + ensureWorkspacePlanBacking: mocks.ensureWorkspacePlanBacking, +})) + +vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({ + resolveWorkflowAliasForWorkspace: mocks.resolveWorkflowAliasForWorkspace, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, + findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, + normalizeWorkspaceFileItemName: mocks.normalizeWorkspaceFileItemName, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFileByName: mocks.getWorkspaceFileByName, + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, + updateWorkspaceFileContent: mocks.updateWorkspaceFileContent, + uploadWorkspaceFile: mocks.uploadWorkspaceFile, +})) + +import { writeWorkspaceFileByPath } from './resource-writer' + +describe('resource writer workflow aliases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkflowAliasBacking.mockResolvedValue({}) + mocks.ensureWorkspacePlanBacking.mockResolvedValue({}) + mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-id') + }) + + it('creates workflow plan aliases through backing workspace files', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/.plans/launch.md', + backingPath: 'files/.plans/wf_1/launch.md', + backingFolderPath: 'files/.plans/wf_1', + planRelativePath: 'launch.md', + }) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockResolvedValue({ + id: 'file-plan', + name: 'launch.md', + size: 7, + type: 'text/markdown', + url: '/download', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/.plans/launch.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + + expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('content'), + 'launch.md', + 'text/markdown', + { folderId: 'folder-id' } + ) + expect(result).toMatchObject({ + id: 'file-plan', + vfsPath: 'workflows/My%20Workflow/.plans/launch.md', + backingVfsPath: 'files/.plans/wf_1/launch.md', + mode: 'create', + }) + }) + + it('overwrites workflow changelog aliases through backing workspace files', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'changelog', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/changelog.md', + backingPath: 'files/.changelogs/wf_1.md', + backingFolderPath: 'files/.changelogs', + }) + mocks.getWorkspaceFileByName.mockResolvedValue({ + id: 'file-changelog', + name: 'wf_1.md', + type: 'text/markdown', + folderPath: '.changelogs', + }) + mocks.updateWorkspaceFileContent.mockResolvedValue({ + id: 'file-changelog', + name: 'wf_1.md', + size: 7, + type: 'text/markdown', + url: '/download', + folderPath: '.changelogs', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/changelog.md', + mode: 'overwrite', + }, + buffer: Buffer.from('updated'), + inferredMimeType: 'text/markdown', + }) + + expect(mocks.updateWorkspaceFileContent).toHaveBeenCalledWith( + 'workspace-1', + 'file-changelog', + 'user-1', + Buffer.from('updated'), + 'text/markdown' + ) + expect(result).toMatchObject({ + id: 'file-changelog', + vfsPath: 'workflows/My%20Workflow/changelog.md', + backingVfsPath: 'files/.changelogs/wf_1.md', + mode: 'overwrite', + }) + }) + + it('creates root workspace plan aliases through workspace backing files', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workspace', + aliasPath: '.plans/root.md', + backingPath: 'files/.plans/workspace/root.md', + backingFolderPath: 'files/.plans/workspace', + planRelativePath: 'root.md', + }) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockResolvedValue({ + id: 'file-root-plan', + name: 'root.md', + size: 7, + type: 'text/markdown', + url: '/download', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: '.plans/root.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + + expect(mocks.ensureWorkspacePlanBacking).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['.plans', 'workspace'], + }) + expect(result).toMatchObject({ + id: 'file-root-plan', + vfsPath: '.plans/root.md', + backingVfsPath: 'files/.plans/workspace/root.md', + mode: 'create', + }) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 9f1a53d2a61..ba8f32dda3b 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -1,5 +1,18 @@ import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { + ensureWorkflowAliasBacking, + ensureWorkspacePlanBacking, +} from '@/lib/copilot/vfs/workflow-alias-backing' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { + isPlanAliasPath, + WORKFLOW_CHANGELOG_BACKING_FOLDER, + WORKFLOW_PLANS_BACKING_FOLDER, + WORKSPACE_PLANS_BACKING_FOLDER, + type WorkflowAliasTarget, +} from '@/lib/copilot/vfs/workflow-aliases' +import { + ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, normalizeWorkspaceFileItemName, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' @@ -26,6 +39,7 @@ export interface WorkspaceFileWriteResult { contentType: string downloadUrl?: string vfsPath: string + backingVfsPath?: string mode: WorkspaceFileWriteMode } @@ -39,12 +53,14 @@ export type WorkspaceFileWriteValidation = | { mode: 'create' vfsPath: string + backingVfsPath?: string fileName: string folderId: string | null } | { mode: 'overwrite' vfsPath: string + backingVfsPath?: string existingFileId: string } @@ -86,7 +102,9 @@ async function resolveCreateTarget( const parsed = parseWorkspaceFileCreatePath(path) const folderId = parsed.folderSegments.length > 0 - ? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) + ? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { + includeReservedSystemFolders: true, + }) : null if (parsed.folderSegments.length > 0 && !folderId) { @@ -111,10 +129,132 @@ function vfsPathForRecord(record: WorkspaceFileRecord): string { return canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) } +async function resolveWorkflowAliasFileTarget(args: { + workspaceId: string + userId?: string + alias: WorkflowAliasTarget +}): Promise { + if (args.alias.kind === 'plans_dir') { + throw new Error( + `Cannot write file content to plan alias directory: ${args.alias.aliasPath}` + ) + } + + if (args.userId && args.alias.scope === 'workflow') { + await ensureWorkflowAliasBacking({ + workspaceId: args.workspaceId, + userId: args.userId, + workflowId: args.alias.workflowId, + workflowName: args.alias.workflowName, + }) + } else if (args.userId && args.alias.scope === 'workspace') { + await ensureWorkspacePlanBacking({ + workspaceId: args.workspaceId, + userId: args.userId, + }) + } + + if (args.alias.kind === 'changelog') { + const folderSegments = [WORKFLOW_CHANGELOG_BACKING_FOLDER] + const folderId = args.userId + ? await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: folderSegments, + }) + : await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, { + includeReservedSystemFolders: true, + }) + if (!folderId) { + throw new Error( + `Workflow changelog backing folder is not provisioned for ${args.alias.aliasPath}` + ) + } + const fileName = `${args.alias.workflowId}.md` + return { + fileName, + folderId, + vfsPath: args.alias.aliasPath, + existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }), + } + } + + const relativeSegments = decodeVfsPathSegments(args.alias.planRelativePath ?? '') + if (relativeSegments.length === 0) { + throw new Error(`Workflow plan alias must include a file path: ${args.alias.aliasPath}`) + } + const fileName = normalizeWorkspaceFileItemName(relativeSegments.at(-1) ?? '', 'File') + const folderSegments = [ + WORKFLOW_PLANS_BACKING_FOLDER, + args.alias.scope === 'workflow' ? args.alias.workflowId : WORKSPACE_PLANS_BACKING_FOLDER, + ...relativeSegments.slice(0, -1), + ].map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) + const folderId = args.userId + ? await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: folderSegments, + }) + : await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, { + includeReservedSystemFolders: true, + }) + if (!folderId) { + throw new Error( + `Plan backing directory is not provisioned for ${args.alias.aliasPath}. Create the plan with touch_plan first.` + ) + } + + return { + fileName, + folderId, + vfsPath: args.alias.aliasPath, + existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }), + } +} + export async function validateWorkspaceFileWriteTarget(args: { workspaceId: string + userId?: string target: WorkspaceFileWriteTarget }): Promise { + const alias = await resolveWorkflowAliasForWorkspace({ + workspaceId: args.workspaceId, + path: args.target.path, + }) + if (!alias && isPlanAliasPath(args.target.path)) { + throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`) + } + if (alias) { + const resolved = await resolveWorkflowAliasFileTarget({ + workspaceId: args.workspaceId, + userId: args.userId, + alias, + }) + if (args.target.mode === 'overwrite') { + if (!resolved.existingFile) { + throw new Error(`File not found for overwrite: ${alias.aliasPath}`) + } + return { + mode: 'overwrite', + vfsPath: alias.aliasPath, + backingVfsPath: alias.backingPath, + existingFileId: resolved.existingFile.id, + } + } + if (resolved.existingFile) { + throw new Error( + `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` + ) + } + return { + mode: 'create', + vfsPath: alias.aliasPath, + backingVfsPath: alias.backingPath, + fileName: resolved.fileName, + folderId: resolved.folderId, + } + } + if (args.target.mode === 'overwrite') { const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) if (!existing) { @@ -144,6 +284,67 @@ export async function writeWorkspaceFileByPath(args: { inferredMimeType: string }): Promise { const contentType = args.target.mimeType || args.inferredMimeType + const alias = await resolveWorkflowAliasForWorkspace({ + workspaceId: args.workspaceId, + path: args.target.path, + }) + if (!alias && isPlanAliasPath(args.target.path)) { + throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`) + } + if (alias) { + const resolved = await resolveWorkflowAliasFileTarget({ + workspaceId: args.workspaceId, + userId: args.userId, + alias, + }) + + if (args.target.mode === 'overwrite') { + if (!resolved.existingFile) { + throw new Error(`File not found for overwrite: ${alias.aliasPath}`) + } + const updated = await updateWorkspaceFileContent( + args.workspaceId, + resolved.existingFile.id, + args.userId, + args.buffer, + contentType || resolved.existingFile.type + ) + return { + id: updated.id, + name: updated.name, + size: updated.size, + contentType: updated.type, + downloadUrl: updated.url, + vfsPath: alias.aliasPath, + backingVfsPath: vfsPathForRecord(updated), + mode: 'overwrite', + } + } + + if (resolved.existingFile) { + throw new Error( + `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` + ) + } + const uploaded = await uploadWorkspaceFile( + args.workspaceId, + args.userId, + args.buffer, + resolved.fileName, + contentType, + { folderId: resolved.folderId } + ) + return { + id: uploaded.id, + name: uploaded.name, + size: uploaded.size, + contentType: uploaded.type, + downloadUrl: uploaded.url, + vfsPath: alias.aliasPath, + backingVfsPath: alias.backingPath, + mode: 'create', + } + } if (args.target.mode === 'overwrite') { const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts new file mode 100644 index 00000000000..55e4918b0f2 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + ensureWorkspaceFileFolderPath: vi.fn(), + listWorkspaceFileFolders: vi.fn(), + getWorkspaceFileByName: vi.fn(), + listWorkspaceFiles: vi.fn(), + uploadWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, + listWorkspaceFileFolders: mocks.listWorkspaceFileFolders, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFileByName: mocks.getWorkspaceFileByName, + listWorkspaceFiles: mocks.listWorkspaceFiles, + uploadWorkspaceFile: mocks.uploadWorkspaceFile, +})) + +import { ensureWorkflowAliasBacking } from './workflow-alias-backing' + +describe('workflow alias backing', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkspaceFileFolderPath.mockImplementation(({ pathSegments }) => + Promise.resolve(`folder:${pathSegments.join('/')}`) + ) + }) + + it('provisions reserved folders and creates a headed changelog when missing', async () => { + mocks.getWorkspaceFileByName + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'file-1', name: 'wf_1.md' }) + + const result = await ensureWorkflowAliasBacking({ + workspaceId: 'workspace-1', + userId: 'user-1', + workflowId: 'wf_1', + workflowName: 'My Workflow', + }) + + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['.changelogs'], + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['.plans', 'wf_1'], + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['.plans', 'workspace'], + }) + expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('# My Workflow Changelog\n', 'utf-8'), + 'wf_1.md', + 'text/markdown', + { folderId: 'folder:.changelogs' } + ) + expect(result.changelogFile).toMatchObject({ id: 'file-1' }) + }) + + it('reuses an existing changelog backing file', async () => { + mocks.getWorkspaceFileByName.mockResolvedValueOnce({ id: 'file-existing', name: 'wf_2.md' }) + + const result = await ensureWorkflowAliasBacking({ + workspaceId: 'workspace-1', + userId: 'user-1', + workflowId: 'wf_2', + }) + + expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled() + expect(result.changelogFile).toMatchObject({ id: 'file-existing' }) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts new file mode 100644 index 00000000000..41e23b1a14f --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts @@ -0,0 +1,198 @@ +import { db } from '@sim/db' +import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { and, eq, inArray, isNull } from 'drizzle-orm' +import { + WORKFLOW_CHANGELOG_BACKING_FOLDER, + WORKFLOW_PLANS_BACKING_FOLDER, + WORKSPACE_PLANS_BACKING_FOLDER, +} from '@/lib/copilot/vfs/workflow-aliases' +import { + ensureWorkspaceFileFolderPath, + listWorkspaceFileFolders, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + getWorkspaceFileByName, + listWorkspaceFiles, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const logger = createLogger('WorkflowAliasBacking') + +export interface WorkflowAliasBacking { + changelogFolderId: string + plansRootFolderId: string + workflowPlansFolderId: string + workspacePlansFolderId: string + changelogFile: WorkspaceFileRecord | null +} + +function initialChangelogContent(workflowName?: string): string { + const title = workflowName?.trim() || 'Workflow' + return `# ${title} Changelog\n` +} + +export async function ensureWorkflowAliasBacking(args: { + workspaceId: string + userId: string + workflowId: string + workflowName?: string +}): Promise { + const changelogFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_CHANGELOG_BACKING_FOLDER], + }) + const plansRootFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER], + }) + const workflowPlansFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, args.workflowId], + }) + const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER], + }) + + if (!changelogFolderId || !plansRootFolderId || !workflowPlansFolderId || !workspacePlansFolderId) { + throw new Error('Failed to provision workflow alias backing folders') + } + + const changelogName = `${args.workflowId}.md` + let changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, { + folderId: changelogFolderId, + }) + if (!changelogFile) { + await uploadWorkspaceFile( + args.workspaceId, + args.userId, + Buffer.from(initialChangelogContent(args.workflowName), 'utf-8'), + changelogName, + 'text/markdown', + { folderId: changelogFolderId } + ) + changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, { + folderId: changelogFolderId, + }) + } + + return { + changelogFolderId, + plansRootFolderId, + workflowPlansFolderId, + workspacePlansFolderId, + changelogFile, + } +} + +export async function ensureWorkflowAliasBackingQuietly(args: { + workspaceId: string + userId: string + workflowId: string + workflowName?: string +}): Promise { + try { + return await ensureWorkflowAliasBacking(args) + } catch (error) { + logger.warn('Failed to ensure workflow alias backing', { + workspaceId: args.workspaceId, + workflowId: args.workflowId, + error: toError(error).message, + }) + return null + } +} + +export async function ensureWorkspacePlanBacking(args: { + workspaceId: string + userId: string +}): Promise<{ plansRootFolderId: string; workspacePlansFolderId: string }> { + const plansRootFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER], + }) + const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER], + }) + if (!plansRootFolderId || !workspacePlansFolderId) { + throw new Error('Failed to provision workspace plan backing folders') + } + return { plansRootFolderId, workspacePlansFolderId } +} + +export async function cleanupWorkflowAliasBacking(args: { + workspaceId: string + workflowId: string + deletedAt?: Date +}): Promise<{ files: number; folders: number }> { + const deletedAt = args.deletedAt ?? new Date() + const folders = await listWorkspaceFileFolders(args.workspaceId, { + scope: 'all', + includeReservedSystemFolders: true, + }) + const files = await listWorkspaceFiles(args.workspaceId, { + scope: 'all', + folders, + includeReservedSystemFiles: true, + }) + + const ownedFileIds = files + .filter((file) => { + if (file.deletedAt) return false + const changelogMatch = + file.folderPath === WORKFLOW_CHANGELOG_BACKING_FOLDER && file.name === `${args.workflowId}.md` + const workflowPlanMatch = + file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}` || + Boolean(file.folderPath?.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}/`)) + return changelogMatch || workflowPlanMatch + }) + .map((file) => file.id) + + const ownedFolderIds = folders + .filter((folder) => { + if (folder.deletedAt) return false + return ( + folder.path === `${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}` || + folder.path.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}/`) + ) + }) + .map((folder) => folder.id) + + if (ownedFileIds.length > 0) { + await db + .update(workspaceFiles) + .set({ deletedAt }) + .where( + and( + eq(workspaceFiles.workspaceId, args.workspaceId), + inArray(workspaceFiles.id, ownedFileIds), + isNull(workspaceFiles.deletedAt) + ) + ) + } + + if (ownedFolderIds.length > 0) { + await db + .update(workspaceFileFolder) + .set({ deletedAt }) + .where( + and( + eq(workspaceFileFolder.workspaceId, args.workspaceId), + inArray(workspaceFileFolder.id, ownedFolderIds), + isNull(workspaceFileFolder.deletedAt) + ) + ) + } + + return { files: ownedFileIds.length, folders: ownedFolderIds.length } +} diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts new file mode 100644 index 00000000000..c289d2eeccd --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts @@ -0,0 +1,55 @@ +import { db } from '@sim/db' +import { workflow, workflowFolder } from '@sim/db/schema' +import { and, asc, eq, isNull } from 'drizzle-orm' +import { + buildWorkflowAliasWorkflowEntries, + isPlanAliasPath, + resolveWorkspacePlanAliasPath, + resolveWorkflowAliasPath, + type WorkflowAliasTarget, +} from '@/lib/copilot/vfs/workflow-aliases' +import { canonicalizeVfsPath } from './path-utils' + +export async function resolveWorkflowAliasForWorkspace(args: { + workspaceId: string + path: string +}): Promise { + if (!isPlanAliasPath(args.path)) return null + + let canonicalPath: string + try { + canonicalPath = canonicalizeVfsPath(args.path) + } catch { + canonicalPath = args.path.trim().replace(/^\/+|\/+$/g, '') + } + + const workspacePlanAlias = resolveWorkspacePlanAliasPath(canonicalPath) + if (workspacePlanAlias) return workspacePlanAlias + + const [workflowRows, folderRows] = await Promise.all([ + db + .select({ + id: workflow.id, + name: workflow.name, + folderId: workflow.folderId, + }) + .from(workflow) + .where(and(eq(workflow.workspaceId, args.workspaceId), isNull(workflow.archivedAt))) + .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt)), + db + .select({ + folderId: workflowFolder.id, + folderName: workflowFolder.name, + parentId: workflowFolder.parentId, + }) + .from(workflowFolder) + .where( + and(eq(workflowFolder.workspaceId, args.workspaceId), isNull(workflowFolder.archivedAt)) + ) + .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)), + ]) + return resolveWorkflowAliasPath( + canonicalPath, + buildWorkflowAliasWorkflowEntries(workflowRows, folderRows) + ) +} diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts new file mode 100644 index 00000000000..2a73755cd70 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { + buildWorkflowAliasWorkflowEntries, + resolveWorkspacePlanAliasPath, + resolveWorkflowAliasPath, + workspacePlanBackingPath, + workflowChangelogBackingPath, +} from './workflow-aliases' + +describe('workflow aliases', () => { + const folders = [ + { folderId: 'root-a', folderName: 'Folder A', parentId: null }, + { folderId: 'nested', folderName: 'Nested', parentId: 'root-a' }, + { folderId: 'root-b', folderName: 'Folder B', parentId: null }, + ] + + it('resolves root workspace plan aliases to workspace backing files', () => { + const alias = resolveWorkspacePlanAliasPath('.plans/root.md') + + expect(alias).toMatchObject({ + kind: 'plan_file', + scope: 'workspace', + aliasPath: '.plans/root.md', + planRelativePath: 'root.md', + backingPath: workspacePlanBackingPath('root.md'), + }) + }) + + it('preserves nested root workspace plan paths in backing storage', () => { + const alias = resolveWorkspacePlanAliasPath('.plans/nested/phase-1.md') + + expect(alias).toMatchObject({ + kind: 'plan_file', + scope: 'workspace', + planRelativePath: 'nested/phase-1.md', + backingPath: 'files/.plans/workspace/nested/phase-1.md', + }) + }) + + it('rejects root plan directory paths as file aliases', () => { + expect(resolveWorkspacePlanAliasPath('.plans')).toMatchObject({ + kind: 'plans_dir', + scope: 'workspace', + }) + expect(resolveWorkspacePlanAliasPath('.plans/.folder')).toMatchObject({ + kind: 'plans_dir', + scope: 'workspace', + }) + expect(resolveWorkspacePlanAliasPath('.plans/links.json')).toBeNull() + }) + + it('resolves root workflow changelog aliases to workflow-id keyed backing files', () => { + const workflows = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_123', name: 'Root Flow', folderId: null }], + [] + ) + + const alias = resolveWorkflowAliasPath('workflows/Root%20Flow/changelog.md', workflows) + + expect(alias).toMatchObject({ + kind: 'changelog', + workflowId: 'wf_123', + aliasPath: 'workflows/Root%20Flow/changelog.md', + backingPath: workflowChangelogBackingPath('wf_123'), + }) + }) + + it('resolves nested plan aliases using the workflow folder path', () => { + const workflows = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_nested', name: 'Planner', folderId: 'nested' }], + folders + ) + + const alias = resolveWorkflowAliasPath( + 'workflows/Folder%20A/Nested/Planner/.plans/launch.md', + workflows + ) + + expect(alias).toMatchObject({ + kind: 'plan_file', + workflowId: 'wf_nested', + planRelativePath: 'launch.md', + backingPath: 'files/.plans/wf_nested/launch.md', + }) + }) + + it('keeps same-name workflows in different folders distinct', () => { + const workflows = buildWorkflowAliasWorkflowEntries( + [ + { id: 'wf_a', name: 'Duplicate', folderId: 'root-a' }, + { id: 'wf_b', name: 'Duplicate', folderId: 'root-b' }, + ], + folders + ) + + expect( + resolveWorkflowAliasPath('workflows/Folder%20A/Duplicate/changelog.md', workflows) + ).toMatchObject({ workflowId: 'wf_a', backingPath: 'files/.changelogs/wf_a.md' }) + expect( + resolveWorkflowAliasPath('workflows/Folder%20B/Duplicate/changelog.md', workflows) + ).toMatchObject({ workflowId: 'wf_b', backingPath: 'files/.changelogs/wf_b.md' }) + }) + + it('keeps backing paths stable across workflow rename', () => { + const before = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_stable', name: 'Old Name', folderId: null }], + [] + ) + const after = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_stable', name: 'New Name', folderId: null }], + [] + ) + + expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', before)?.backingPath).toBe( + 'files/.changelogs/wf_stable.md' + ) + expect(resolveWorkflowAliasPath('workflows/New%20Name/changelog.md', after)?.backingPath).toBe( + 'files/.changelogs/wf_stable.md' + ) + expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', after)).toBeNull() + }) + + it('rejects arbitrary workflow-local files and missing workflows', () => { + const workflows = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_123', name: 'Root Flow', folderId: null }], + [] + ) + + expect(resolveWorkflowAliasPath('workflows/Root%20Flow/random.md', workflows)).toBeNull() + expect(resolveWorkflowAliasPath('workflows/Missing/changelog.md', workflows)).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.ts new file mode 100644 index 00000000000..102a3f3d375 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-aliases.ts @@ -0,0 +1,336 @@ +import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { + canonicalWorkspaceFilePath, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +export const WORKFLOW_CHANGELOG_ALIAS_NAME = 'changelog.md' +export const WORKFLOW_PLANS_ALIAS_DIR = '.plans' +export const WORKFLOW_ALIAS_LINKS_NAME = 'links.json' +export const WORKFLOW_CHANGELOG_BACKING_FOLDER = '.changelogs' +export const WORKFLOW_PLANS_BACKING_FOLDER = '.plans' +export const WORKSPACE_PLANS_BACKING_FOLDER = 'workspace' + +export type WorkflowAliasKind = 'changelog' | 'plan_file' | 'plans_dir' +export type WorkflowAliasScope = 'workspace' | 'workflow' + +export interface WorkflowAliasWorkflow { + id: string + name: string + folderPath?: string | null +} + +export interface WorkflowAliasWorkflowRow { + id: string + name: string + folderId?: string | null +} + +export interface WorkflowAliasFolderRow { + folderId: string + folderName: string + parentId: string | null +} + +interface BaseWorkflowAliasTarget { + kind: WorkflowAliasKind + scope: WorkflowAliasScope + aliasPath: string + backingPath: string + backingFolderPath: string + planRelativePath?: string +} + +export type WorkflowAliasTarget = + | (BaseWorkflowAliasTarget & { + kind: 'changelog' + scope: 'workflow' + workflowId: string + workflowName: string + workflowPath: string + }) + | (BaseWorkflowAliasTarget & { + kind: 'plans_dir' + scope: 'workflow' + workflowId: string + workflowName: string + workflowPath: string + }) + | (BaseWorkflowAliasTarget & { + kind: 'plan_file' + scope: 'workflow' + workflowId: string + workflowName: string + workflowPath: string + planRelativePath: string + }) + | (BaseWorkflowAliasTarget & { + kind: 'plans_dir' + scope: 'workspace' + }) + | (BaseWorkflowAliasTarget & { + kind: 'plan_file' + scope: 'workspace' + planRelativePath: string + }) + +export interface WorkflowAliasLink { + kind: WorkflowAliasKind + aliasPath: string + backingPath: string + backingFileId?: string +} + +export function workflowVfsPath(workflow: WorkflowAliasWorkflow): string { + const safeName = normalizeVfsSegment(workflow.name) + return workflow.folderPath + ? `workflows/${workflow.folderPath}/${safeName}` + : `workflows/${safeName}` +} + +export function buildWorkflowAliasWorkflowEntries( + workflows: WorkflowAliasWorkflowRow[], + folders: WorkflowAliasFolderRow[] +): WorkflowAliasWorkflow[] { + const folderMap = new Map() + for (const folder of folders) { + folderMap.set(folder.folderId, { name: folder.folderName, parentId: folder.parentId }) + } + + const folderPathCache = new Map() + const folderPath = (folderId: string): string => { + const cached = folderPathCache.get(folderId) + if (cached) return cached + + const folder = folderMap.get(folderId) + if (!folder) return '' + + const safeName = normalizeVfsSegment(folder.name) + const path = folder.parentId ? `${folderPath(folder.parentId)}/${safeName}` : safeName + folderPathCache.set(folderId, path) + return path + } + + return workflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + folderPath: workflow.folderId ? folderPath(workflow.folderId) : null, + })) +} + +export function workflowChangelogBackingPath(workflowId: string): string { + return canonicalWorkspaceFilePath({ + folderPath: WORKFLOW_CHANGELOG_BACKING_FOLDER, + name: `${workflowId}.md`, + }) +} + +export function workflowPlansBackingFolderPath(workflowId: string): string { + return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(workflowId)}` +} + +export function workspacePlansBackingFolderPath(): string { + return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(WORKSPACE_PLANS_BACKING_FOLDER)}` +} + +export function workspacePlanBackingPath(planRelativePath: string): string { + const segments = decodeVfsPathSegments(planRelativePath) + if (segments.length === 0) { + throw new Error('Workspace plan alias must include a plan file path') + } + return canonicalWorkspaceFilePath({ + folderPath: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER, ...segments.slice(0, -1)].join('/'), + name: segments[segments.length - 1], + }) +} + +export function workflowPlanBackingPath(workflowId: string, planRelativePath: string): string { + const segments = decodeVfsPathSegments(planRelativePath) + if (segments.length === 0) { + throw new Error('Workflow plan alias must include a plan file path') + } + return canonicalWorkspaceFilePath({ + folderPath: [WORKFLOW_PLANS_BACKING_FOLDER, workflowId, ...segments.slice(0, -1)].join('/'), + name: segments[segments.length - 1], + }) +} + +function workflowAliasTargetForPath(workflow: WorkflowAliasWorkflow, rawPath: string) { + const workflowPath = workflowVfsPath(workflow) + const changelogPath = `${workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}` + if (rawPath === changelogPath) { + return { + kind: 'changelog' as const, + scope: 'workflow' as const, + workflowId: workflow.id, + workflowName: workflow.name, + workflowPath, + aliasPath: changelogPath, + backingPath: workflowChangelogBackingPath(workflow.id), + backingFolderPath: `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}`, + } + } + + const plansDirPath = `${workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}` + if (rawPath === plansDirPath || rawPath === `${plansDirPath}/.folder`) { + return { + kind: 'plans_dir' as const, + scope: 'workflow' as const, + workflowId: workflow.id, + workflowName: workflow.name, + workflowPath, + aliasPath: plansDirPath, + backingPath: workflowPlansBackingFolderPath(workflow.id), + backingFolderPath: workflowPlansBackingFolderPath(workflow.id), + } + } + + const plansPrefix = `${plansDirPath}/` + if (rawPath.startsWith(plansPrefix)) { + const planRelativePath = rawPath.slice(plansPrefix.length) + if (!planRelativePath || planRelativePath === '.folder') return null + return { + kind: 'plan_file' as const, + scope: 'workflow' as const, + workflowId: workflow.id, + workflowName: workflow.name, + workflowPath, + aliasPath: rawPath, + backingPath: workflowPlanBackingPath(workflow.id, planRelativePath), + backingFolderPath: workflowPlansBackingFolderPath(workflow.id), + planRelativePath, + } + } + + return null +} + +export function resolveWorkspacePlanAliasPath(path: string): WorkflowAliasTarget | null { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + if (normalizedPath === WORKFLOW_PLANS_ALIAS_DIR || normalizedPath === `${WORKFLOW_PLANS_ALIAS_DIR}/.folder`) { + return { + kind: 'plans_dir', + scope: 'workspace', + aliasPath: WORKFLOW_PLANS_ALIAS_DIR, + backingPath: workspacePlansBackingFolderPath(), + backingFolderPath: workspacePlansBackingFolderPath(), + } + } + + const plansPrefix = `${WORKFLOW_PLANS_ALIAS_DIR}/` + if (!normalizedPath.startsWith(plansPrefix)) return null + const planRelativePath = normalizedPath.slice(plansPrefix.length) + if (!planRelativePath || planRelativePath === '.folder' || planRelativePath === WORKFLOW_ALIAS_LINKS_NAME) { + return null + } + return { + kind: 'plan_file', + scope: 'workspace', + aliasPath: normalizedPath, + backingPath: workspacePlanBackingPath(planRelativePath), + backingFolderPath: workspacePlansBackingFolderPath(), + planRelativePath, + } +} + +export function resolveWorkflowAliasPath( + path: string, + workflows: WorkflowAliasWorkflow[] +): WorkflowAliasTarget | null { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + if (!normalizedPath.startsWith('workflows/')) return null + + const bySpecificity = [...workflows].sort( + (a, b) => workflowVfsPath(b).length - workflowVfsPath(a).length + ) + for (const workflow of bySpecificity) { + const target = workflowAliasTargetForPath(workflow, normalizedPath) + if (target) return target + } + return null +} + +export function isWorkflowAliasPath(path: string): boolean { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + return ( + normalizedPath.startsWith('workflows/') && + (normalizedPath.endsWith(`/${WORKFLOW_CHANGELOG_ALIAS_NAME}`) || + normalizedPath.includes(`/${WORKFLOW_PLANS_ALIAS_DIR}/`) || + normalizedPath.endsWith(`/${WORKFLOW_PLANS_ALIAS_DIR}`)) + ) +} + +export function isWorkspacePlanAliasPath(path: string): boolean { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + return normalizedPath === WORKFLOW_PLANS_ALIAS_DIR || normalizedPath.startsWith(`${WORKFLOW_PLANS_ALIAS_DIR}/`) +} + +export function isPlanAliasPath(path: string): boolean { + return isWorkspacePlanAliasPath(path) || isWorkflowAliasPath(path) +} + +export function isWorkflowAliasBackingPath(path: string): boolean { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + return ( + normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}` || + normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}` || + normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}/`) || + normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/`) + ) +} + +export function isReservedWorkflowAliasBackingDisplayPath(path?: string | null): boolean { + if (!path) return false + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + return ( + normalizedPath === WORKFLOW_CHANGELOG_BACKING_FOLDER || + normalizedPath === WORKFLOW_PLANS_BACKING_FOLDER || + normalizedPath.startsWith(`${WORKFLOW_CHANGELOG_BACKING_FOLDER}/`) || + normalizedPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/`) + ) +} + +export function workflowAliasSandboxPath(aliasPath: string): string { + return `/home/user/${aliasPath.trim().replace(/^\/+/, '')}` +} + +export function buildWorkflowAliasLinks(args: { + workflowPath: string + workflowId: string + changelog?: WorkspaceFileRecord | null + planFiles?: WorkspaceFileRecord[] +}): WorkflowAliasLink[] { + const links: WorkflowAliasLink[] = [ + { + kind: 'changelog', + aliasPath: `${args.workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}`, + backingPath: workflowChangelogBackingPath(args.workflowId), + backingFileId: args.changelog?.id, + }, + { + kind: 'plans_dir', + aliasPath: `${args.workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}`, + backingPath: workflowPlansBackingFolderPath(args.workflowId), + }, + ] + + for (const file of args.planFiles ?? []) { + const relativePath = file.folderPath + ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}`, '') + .replace(/^\/+/, '') + const aliasRelativePath = encodeVfsPathSegments( + [relativePath, file.name].filter(Boolean).join('/').split('/') + ) + const aliasPath = [args.workflowPath, WORKFLOW_PLANS_ALIAS_DIR, aliasRelativePath].join('/') + links.push({ + kind: 'plan_file', + aliasPath, + backingPath: canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }), + backingFileId: file.id, + }) + } + + return links +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 6ccb05d7994..93404ffe38a 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -58,6 +58,19 @@ import { serializeVersions, serializeWorkflowMeta, } from '@/lib/copilot/vfs/serializers' +import { ensureWorkflowAliasBackingQuietly } from '@/lib/copilot/vfs/workflow-alias-backing' +import { + buildWorkflowAliasLinks, + isWorkflowAliasBackingPath, + WORKSPACE_PLANS_BACKING_FOLDER, + WORKFLOW_ALIAS_LINKS_NAME, + WORKFLOW_CHANGELOG_ALIAS_NAME, + WORKFLOW_PLANS_ALIAS_DIR, + WORKFLOW_PLANS_BACKING_FOLDER, + workspacePlanBackingPath, + workspacePlansBackingFolderPath, + workflowChangelogBackingPath, +} from '@/lib/copilot/vfs/workflow-aliases' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, @@ -485,7 +498,7 @@ export class WorkspaceVFS { const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) if (!canonicalMatch?.[1]) return null - const files = await listWorkspaceFiles(this._workspaceId) + const files = await listWorkspaceFiles(this._workspaceId, { includeReservedSystemFiles: true }) return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) } @@ -551,7 +564,11 @@ export class WorkspaceVFS { error: toError(err).message, }) if (err instanceof SandboxUserCodeError) { - const json = JSON.stringify({ ok: false, error: toError(err).message, errorName: err.name }) + const json = JSON.stringify({ + ok: false, + error: toError(err).message, + errorName: err.name, + }) return { content: json, totalLines: 1 } } return null @@ -645,7 +662,10 @@ export class WorkspaceVFS { const scope = deletedMatch ? 'archived' : 'active' try { - const files = await listWorkspaceFiles(this._workspaceId, { scope }) + const files = await listWorkspaceFiles(this._workspaceId, { + scope, + includeReservedSystemFiles: true, + }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null return readFileRecord(record) @@ -699,7 +719,7 @@ export class WorkspaceVFS { */ private async materializeWorkflows( workspaceId: string, - _userId: string + userId: string ): Promise { const [workflowRows, folderRows] = await Promise.all([ listWorkflows(workspaceId), @@ -708,6 +728,18 @@ export class WorkspaceVFS { const folderPaths = this.buildFolderPaths(folderRows) + await Promise.all( + workflowRows.map((wf) => + ensureWorkflowAliasBackingQuietly({ + workspaceId, + userId, + workflowId: wf.id, + workflowName: wf.name, + }) + ) + ) + const workspaceFiles = await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) + // Register all folders in the VFS so empty folders are discoverable. for (const { folderId } of folderRows) { const folderPath = folderPaths.get(folderId) @@ -723,9 +755,76 @@ export class WorkspaceVFS { const prefix = folderPath ? `workflows/${folderPath}/${safeName}/` : `workflows/${safeName}/` + const workflowPath = prefix.replace(/\/$/, '') this.files.set(`${prefix}meta.json`, serializeWorkflowMeta(wf)) + const changelog = findWorkspaceFileRecord( + workspaceFiles, + workflowChangelogBackingPath(wf.id) + ) + let changelogContent = '' + if (changelog) { + try { + changelogContent = (await readFileRecord(changelog))?.content ?? '' + } catch (err) { + logger.warn('Failed to read workflow changelog alias backing file', { + workspaceId, + workflowId: wf.id, + fileId: changelog.id, + error: toError(err).message, + }) + } + } + if (changelog) { + this.files.set(`${prefix}${WORKFLOW_CHANGELOG_ALIAS_NAME}`, changelogContent) + } + this.files.set(`${prefix}${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '') + + const planFiles = workspaceFiles.filter((file) => { + if (!file.folderPath) return false + return ( + file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}` || + file.folderPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}/`) + ) + }) + for (const planFile of planFiles) { + const relativeFolder = planFile.folderPath + ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}`, '') + .replace(/^\/+/, '') + const aliasPlanPath = [ + prefix, + `${WORKFLOW_PLANS_ALIAS_DIR}/`, + relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '', + normalizeVfsSegment(planFile.name), + ].join('') + try { + this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '') + } catch (err) { + logger.warn('Failed to read workflow plan alias backing file', { + workspaceId, + workflowId: wf.id, + fileId: planFile.id, + error: toError(err).message, + }) + } + } + this.files.set( + `${prefix}${WORKFLOW_ALIAS_LINKS_NAME}`, + JSON.stringify( + { + aliases: buildWorkflowAliasLinks({ + workflowPath, + workflowId: wf.id, + changelog, + planFiles, + }), + }, + null, + 2 + ) + ) + let normalized: Awaited> = null try { normalized = await loadWorkflowFromNormalizedTables(wf.id) @@ -958,8 +1057,13 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const folders = await listWorkspaceFileFolders(workspaceId) - const files = await listWorkspaceFiles(workspaceId, { folders }) + const folders = await listWorkspaceFileFolders(workspaceId, { + includeReservedSystemFolders: true, + }) + const files = await listWorkspaceFiles(workspaceId, { + folders, + includeReservedSystemFiles: true, + }) for (const folder of folders) { this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') } @@ -984,13 +1088,81 @@ export class WorkspaceVFS { ) } - return files.map((f) => ({ - id: f.id, - name: f.name, - type: f.type, - size: f.size, - folderPath: f.folderPath ?? null, - })) + this.files.set(`${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '') + const workspacePlanFiles = files.filter((file) => { + if (!file.folderPath) return false + return ( + file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}` || + file.folderPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}/`) + ) + }) + const workspacePlanLinks = [] + for (const planFile of workspacePlanFiles) { + const relativeFolder = planFile.folderPath + ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}`, '') + .replace(/^\/+/, '') + const aliasRelativePath = [ + relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '', + normalizeVfsSegment(planFile.name), + ].join('') + const aliasPlanPath = `${WORKFLOW_PLANS_ALIAS_DIR}/${aliasRelativePath}` + const relativeSegments = aliasRelativePath.split('/').slice(0, -1) + for (let index = 0; index < relativeSegments.length; index++) { + this.files.set( + `${WORKFLOW_PLANS_ALIAS_DIR}/${relativeSegments.slice(0, index + 1).join('/')}/.folder`, + '' + ) + } + try { + this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '') + workspacePlanLinks.push({ + kind: 'plan_file', + scope: 'workspace', + aliasPath: aliasPlanPath, + backingPath: workspacePlanBackingPath(aliasRelativePath), + backingFileId: planFile.id, + }) + } catch (err) { + logger.warn('Failed to read workspace plan alias backing file', { + workspaceId, + fileId: planFile.id, + error: toError(err).message, + }) + } + } + this.files.set( + `${WORKFLOW_PLANS_ALIAS_DIR}/${WORKFLOW_ALIAS_LINKS_NAME}`, + JSON.stringify( + { + aliases: [ + { + kind: 'plans_dir', + scope: 'workspace', + aliasPath: WORKFLOW_PLANS_ALIAS_DIR, + backingPath: workspacePlansBackingFolderPath(), + }, + ...workspacePlanLinks, + ], + }, + null, + 2 + ) + ) + + return files + .filter( + (f) => + !isWorkflowAliasBackingPath( + canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) + ) + ) + .map((f) => ({ + id: f.id, + name: f.name, + type: f.type, + size: f.size, + folderPath: f.folderPath ?? null, + })) } catch (err) { logger.warn('Failed to materialize files', { workspaceId, diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 683d210004d..2428e6056ac 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -246,7 +246,8 @@ export async function getWorkspaceFileFolderPath( export async function findWorkspaceFileFolderIdByPath( workspaceId: string, - pathSegments: string[] + pathSegments: string[], + _options?: { includeReservedSystemFolders?: boolean } ): Promise { let parentId: string | null = null @@ -268,7 +269,7 @@ export async function findWorkspaceFileFolderIdByPath( export async function listWorkspaceFileFolders( workspaceId: string, - options?: { scope?: WorkspaceFileFolderScope } + options?: { scope?: WorkspaceFileFolderScope; includeReservedSystemFolders?: boolean } ): Promise { const { scope = 'active' } = options ?? {} const rows = await db diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index adc3e54254b..235e4228d5d 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -17,6 +17,7 @@ import { } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { getServePathPrefix } from '@/lib/uploads' import { @@ -76,6 +77,7 @@ interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] hydrateFolderPaths?: boolean + includeReservedSystemFiles?: boolean } /** @@ -630,7 +632,11 @@ export async function listWorkspaceFiles( options?: ListWorkspaceFilesOptions ): Promise { try { - const { scope = 'active', hydrateFolderPaths = true } = options ?? {} + const { + scope = 'active', + hydrateFolderPaths = true, + includeReservedSystemFiles = false, + } = options ?? {} const files = await db .select() .from(workspaceFiles) @@ -654,9 +660,15 @@ export async function listWorkspaceFiles( ) .orderBy(workspaceFiles.uploadedAt) - const needsFolderPaths = hydrateFolderPaths && files.some((file) => file.folderId) + const needsFolderPaths = + files.some((file) => file.folderId) && (hydrateFolderPaths || !includeReservedSystemFiles) const folders = needsFolderPaths - ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) + ? includeReservedSystemFiles && options?.folders + ? options.folders + : await listWorkspaceFileFolders(workspaceId, { + scope: 'all', + includeReservedSystemFolders: true, + }) : [] const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() @@ -770,7 +782,9 @@ async function getWorkspaceFileByExactReference( return getWorkspaceFileByName(workspaceId, segments[0], { folderId: null }) } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1), { + includeReservedSystemFolders: true, + }) return folderId ? getWorkspaceFileByName(workspaceId, segments.at(-1) ?? '', { folderId }) : null } @@ -781,6 +795,12 @@ export async function resolveWorkspaceFileReference( workspaceId: string, fileReference: string ): Promise { + const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: fileReference }) + if (alias) { + if (alias.kind === 'plans_dir') return null + return resolveWorkspaceFileReference(workspaceId, alias.backingPath) + } + const normalizedReference = normalizeWorkspaceFileReference(fileReference) if (normalizedReference.startsWith('wf_')) { const file = await getWorkspaceFile(workspaceId, normalizedReference) @@ -793,7 +813,7 @@ export async function resolveWorkspaceFileReference( ) if (exactReferenceFile) return exactReferenceFile - const files = await listWorkspaceFiles(workspaceId) + const files = await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) return findWorkspaceFileRecord(files, fileReference) } diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index ae81dea2842..ac245821fbf 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -20,6 +20,7 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { mcpPubSub } from '@/lib/mcp/pubsub' +import { cleanupWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' import { getWorkflowById } from '@/lib/workflows/utils' const logger = createLogger('WorkflowLifecycle') @@ -218,6 +219,22 @@ export async function archiveWorkflow( await notifyWorkflowArchived(workflowId, options.requestId) } + if (existingWorkflow.workspaceId) { + try { + await cleanupWorkflowAliasBacking({ + workspaceId: existingWorkflow.workspaceId, + workflowId, + deletedAt: now, + }) + } catch (error) { + logger.warn(`[${options.requestId}] Failed to clean up workflow alias backing`, { + workflowId, + workspaceId: existingWorkflow.workspaceId, + error, + }) + } + } + await cleanupExternalWebhooksForWorkflow(workflowId, options.requestId) if (existingWorkflow.workspaceId && mcpPubSub && affectedWorkflowMcpServers.length > 0) { diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 3b12ccde4d6..c4d23cf13cb 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -6,6 +6,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' import { materializeInlineExecutionValue } from '@/lib/execution/payloads/inline-materialization.server' import type { ExecutionMaterializationContext } from '@/lib/execution/payloads/materialization.server' import { getNextWorkflowColor } from '@/lib/workflows/colors' @@ -490,6 +491,8 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { throw new Error(saveResult.error || 'Failed to save workflow state') } + await ensureWorkflowAliasBacking({ workspaceId, userId, workflowId, workflowName: name }) + return { workflowId, name, workspaceId, folderId, sortOrder, createdAt: now, updatedAt: now } } From cec7b0eb4d015a1195ca9cab4afb386e3f6d54bb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 21 May 2026 18:51:03 -0700 Subject: [PATCH 9/9] VFS update --- .../[workspaceId]/home/hooks/use-chat.ts | 25 ++- .../sim/lib/copilot/request/go/stream.test.ts | 14 +- .../copilot/request/handlers/handlers.test.ts | 89 +++++++++++ apps/sim/lib/copilot/request/handlers/tool.ts | 33 +++- .../sim/lib/copilot/request/sse-utils.test.ts | 61 ++++++++ apps/sim/lib/copilot/request/sse-utils.ts | 7 + .../lib/copilot/request/tools/files.test.ts | 16 ++ apps/sim/lib/copilot/request/tools/files.ts | 13 +- .../lib/copilot/vfs/resource-writer.test.ts | 145 ++++++++++++++++-- apps/sim/lib/copilot/vfs/resource-writer.ts | 25 ++- .../lib/copilot/vfs/workflow-aliases.test.ts | 7 + apps/sim/lib/copilot/vfs/workflow-aliases.ts | 12 +- .../workspace-file-folder-manager.ts | 19 ++- .../workspace/workspace-file-manager.ts | 28 +++- 14 files changed, 448 insertions(+), 46 deletions(-) create mode 100644 apps/sim/lib/copilot/request/sse-utils.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 098cde0b274..bd65b979319 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -52,6 +52,7 @@ import { DeployApi, DeployChat, DeployMcp, + FunctionExecute, GetPageContents, GetWorkflowLogs, Glob, @@ -581,9 +582,20 @@ function resolveOperationDisplayTitle( return label ?? fallback } +function prefixFunctionExecuteTitle(title: string | undefined, language: unknown): string { + const modePrefix = language === 'shell' ? 'CLI' : 'Code' + const baseTitle = title ?? 'Running code' + if (baseTitle.startsWith(`${modePrefix}:`)) return baseTitle + return `${modePrefix}: ${baseTitle}` +} + function resolveToolDisplayTitle(name: string, args?: Record): string | undefined { if (!args) return undefined + if (name === FunctionExecute.id) { + return prefixFunctionExecuteTitle(stringParam(args.title), args.language) + } + if (name === WorkspaceFile.id) { const target = asPayloadRecord(args.target) return resolveWorkspaceFileDisplayTitle(args.operation, args.title, target?.fileName) @@ -731,6 +743,13 @@ function matchStreamingStringArg(streamingArgs: string, key: string): string | u } function resolveStreamingToolDisplayTitle(name: string, streamingArgs: string): string | undefined { + if (name === FunctionExecute.id) { + return prefixFunctionExecuteTitle( + matchStreamingStringArg(streamingArgs, 'title'), + matchStreamingStringArg(streamingArgs, 'language') + ) + } + if (name === WorkspaceFile.id) { return resolveWorkspaceFileDisplayTitle( matchStreamingStringArg(streamingArgs, 'operation'), @@ -1799,7 +1818,11 @@ export function useChat( if (session.fileId && hasRenderableFilePreviewContent(session)) { setResources((current) => { const withoutStreaming = current.filter((resource) => resource.id !== 'streaming-file') - if (withoutStreaming.some((resource) => resource.type === 'file' && resource.id === session.fileId)) { + if ( + withoutStreaming.some( + (resource) => resource.type === 'file' && resource.id === session.fileId + ) + ) { return withoutStreaming } return [ diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 17786b00db3..3eee9bf8ed8 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -161,7 +161,7 @@ describe('copilot go stream helpers', () => { requestId: 'req-1', type: MothershipStreamV1EventType.tool, payload: { - toolCallId: 'workspace-file-1', + toolCallId: 'workspace-file-path-1', toolName: 'workspace_file', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -180,7 +180,7 @@ describe('copilot go stream helpers', () => { requestId: 'req-1', type: MothershipStreamV1EventType.tool, payload: { - toolCallId: 'workspace-file-1', + toolCallId: 'workspace-file-path-1', toolName: 'workspace_file', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -199,7 +199,7 @@ describe('copilot go stream helpers', () => { requestId: 'req-1', type: MothershipStreamV1EventType.tool, payload: { - toolCallId: 'edit-content-1', + toolCallId: 'edit-content-path-1', toolName: 'edit_content', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -214,7 +214,7 @@ describe('copilot go stream helpers', () => { requestId: 'req-1', type: MothershipStreamV1EventType.tool, payload: { - toolCallId: 'edit-content-1', + toolCallId: 'edit-content-path-1', toolName: 'edit_content', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -301,7 +301,7 @@ describe('copilot go stream helpers', () => { requestId: 'req-1', type: MothershipStreamV1EventType.tool, payload: { - toolCallId: 'workspace-file-1', + toolCallId: 'workspace-file-alias-1', toolName: 'workspace_file', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -320,7 +320,7 @@ describe('copilot go stream helpers', () => { requestId: 'req-1', type: MothershipStreamV1EventType.tool, payload: { - toolCallId: 'edit-content-1', + toolCallId: 'edit-content-alias-1', toolName: 'edit_content', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -335,7 +335,7 @@ describe('copilot go stream helpers', () => { requestId: 'req-1', type: MothershipStreamV1EventType.tool, payload: { - toolCallId: 'edit-content-1', + toolCallId: 'edit-content-alias-1', toolName: 'edit_content', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index a173ef968c0..f5d32dcf2ee 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -320,6 +320,95 @@ describe('sse-handlers tool lifecycle', () => { expect(context.toolCalls.get('tool-hidden')?.name).toBe('load_agent_skill') }) + it('does not add ui-hidden tool calls to content blocks', async () => { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-ui-hidden', + toolName: 'read', + arguments: { path: 'components/integrations/slack/README.md' }, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + ui: { hidden: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.contentBlocks).toEqual([]) + expect(context.toolCalls.get('tool-ui-hidden')?.name).toBe('read') + }) + + it('removes an existing content block when a later frame marks the tool hidden', async () => { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-hidden-after-partial', + toolName: 'read', + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + arguments: { path: 'components/integrations' }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + expect(context.contentBlocks).toHaveLength(1) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-hidden-after-partial', + toolName: 'read', + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + arguments: { path: 'components/integrations/slack/README.md' }, + ui: { hidden: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.contentBlocks).toEqual([]) + }) + + it('does not show pathless read or glob generating placeholders', async () => { + for (const toolName of ['read', 'glob'] as const) { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: `${toolName}-generating`, + toolName, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + } + + expect(context.contentBlocks).toEqual([]) + expect(context.toolCalls.has('read-generating')).toBe(false) + expect(context.toolCalls.has('glob-generating')).toBe(false) + }) + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.subAgentParentToolCallId = 'parent-1' diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index dad427fa458..f10c50ebfaa 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -51,7 +51,7 @@ const logger = createLogger('CopilotToolHandler') function applyToolDisplay( toolCall: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string } + ui: { title?: string; phaseLabel?: string; hidden?: boolean } ): void { if (!toolCall) return const displayTitle = ui.title || ui.phaseLabel @@ -235,6 +235,8 @@ async function handleCallPhase( const isSubagent = scope === 'subagent' const ui = getToolCallUI(data) + if (isPartial && shouldDelayVfsPlaceholder(toolName, args)) return + if (isSubagent) { if (wasToolResultSeen(toolCallId) || existing?.endTime) { if (existing && !existing.name && toolName) existing.name = toolName @@ -308,23 +310,40 @@ async function handleCallPhase( ) } +function shouldDelayVfsPlaceholder( + toolName: string, + args: Record | undefined +): boolean { + return (toolName === 'read' || toolName === 'glob') && !args +} + +function removeToolCallContentBlock(context: StreamingContext, toolCallId: string): void { + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const block = context.contentBlocks[i] + if (block.type === 'tool_call' && block.toolCall?.id === toolCallId) { + context.contentBlocks.splice(i, 1) + } + } +} + function registerSubagentToolCall( context: StreamingContext, toolCallId: string, toolName: string, args: Record | undefined, parentToolCallId: string, - ui: { title?: string; phaseLabel?: string } + ui: { title?: string; phaseLabel?: string; hidden?: boolean } ): void { if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] } - const hideFromUi = isToolHiddenInUi(toolName) + const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { if (!toolCall.name && toolName) toolCall.name = toolName if (args && !toolCall.params) toolCall.params = args applyToolDisplay(toolCall, ui) + if (hideFromUi) removeToolCallContentBlock(context, toolCallId) } else { toolCall = { id: toolCallId, @@ -363,12 +382,16 @@ function registerMainToolCall( toolName: string, args: Record | undefined, existing: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string } + ui: { title?: string; phaseLabel?: string; hidden?: boolean } ): void { - const hideFromUi = isToolHiddenInUi(toolName) + const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { if (args && !existing.params) existing.params = args applyToolDisplay(existing, ui) + if (hideFromUi) { + removeToolCallContentBlock(context, toolCallId) + return + } if ( !hideFromUi && !context.contentBlocks.some((b) => b.type === 'tool_call' && b.toolCall?.id === toolCallId) diff --git a/apps/sim/lib/copilot/request/sse-utils.test.ts b/apps/sim/lib/copilot/request/sse-utils.test.ts new file mode 100644 index 00000000000..f6a5c63b88e --- /dev/null +++ b/apps/sim/lib/copilot/request/sse-utils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test' +import { + MothershipStreamV1EventType, + MothershipStreamV1ToolExecutor, + MothershipStreamV1ToolMode, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { TOOL_CALL_STATUS } from '@/lib/copilot/request/session' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { shouldSkipToolCallEvent } from './sse-utils' + +describe('shouldSkipToolCallEvent', () => { + it('skips pathless read and glob generating placeholders without marking the call seen', () => { + const readEvent = toolCallEvent('read-generating-placeholder', 'read', undefined, true) + const globEvent = toolCallEvent('glob-generating-placeholder', 'glob', undefined, true) + + expect(shouldSkipToolCallEvent(readEvent)).toBe(true) + expect(shouldSkipToolCallEvent(globEvent)).toBe(true) + + expect( + shouldSkipToolCallEvent( + toolCallEvent('read-generating-placeholder', 'read', { + path: 'components/integrations/slack/README.md', + }) + ) + ).toBe(false) + expect( + shouldSkipToolCallEvent( + toolCallEvent('glob-generating-placeholder', 'glob', { + pattern: 'components/blocks/*/README.md', + }) + ) + ).toBe(false) + }) + + it('keeps non-vfs generating placeholders visible', () => { + expect( + shouldSkipToolCallEvent(toolCallEvent('search-generating-placeholder', 'search_online', undefined, true)) + ).toBe(false) + }) +}) + +function toolCallEvent( + toolCallId: string, + toolName: string, + args?: Record, + generating = false +): StreamEvent { + return { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId, + toolName, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + ...(generating ? { status: TOOL_CALL_STATUS.generating } : {}), + ...(args ? { arguments: args } : {}), + }, + } satisfies StreamEvent +} diff --git a/apps/sim/lib/copilot/request/sse-utils.ts b/apps/sim/lib/copilot/request/sse-utils.ts index 7c403b2fd51..310c1b9eaa8 100644 --- a/apps/sim/lib/copilot/request/sse-utils.ts +++ b/apps/sim/lib/copilot/request/sse-utils.ts @@ -55,6 +55,7 @@ export function wasToolResultSeen(toolCallId: string): boolean { export function shouldSkipToolCallEvent(event: StreamEvent): boolean { if (!isToolCallStreamEvent(event)) return false + if (isPathlessVfsGeneratingEvent(event)) return true if (event.payload.status === TOOL_CALL_STATUS.generating) return false const toolCallId = getToolCallIdFromCallEvent(event) if (event.payload.partial === true) return false @@ -63,6 +64,12 @@ export function shouldSkipToolCallEvent(event: StreamEvent): boolean { return false } +function isPathlessVfsGeneratingEvent(event: ToolCallStreamEvent): boolean { + if (event.payload.status !== TOOL_CALL_STATUS.generating) return false + if (event.payload.toolName !== 'read' && event.payload.toolName !== 'glob') return false + return event.payload.arguments === undefined +} + export function shouldSkipToolResultEvent(event: StreamEvent): boolean { return isToolResultStreamEvent(event) && wasToolResultSeen(getToolCallIdFromResultEvent(event)) } diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index e72d6455f89..0d3cb404549 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { extractTabularData, + normalizeOutputWorkspaceFileName, serializeOutputForFile, unwrapFunctionExecuteOutput, } from '@/lib/copilot/request/tools/files' @@ -71,6 +72,21 @@ describe('serializeOutputForFile (json / txt / md)', () => { }) }) +describe('normalizeOutputWorkspaceFileName', () => { + it('derives the leaf file name from workflow alias output paths', () => { + expect(normalizeOutputWorkspaceFileName('workflows/My%20Workflow/changelog.md')).toBe( + 'changelog.md' + ) + expect( + normalizeOutputWorkspaceFileName('workflows/My%20Workflow/.plans/phase%201/implementation.md') + ).toBe('implementation.md') + }) + + it('still handles normal workspace file output paths', () => { + expect(normalizeOutputWorkspaceFileName('files/Reports/output.csv')).toBe('output.csv') + }) +}) + describe('extractTabularData', () => { it('extracts rows directly from an array input', () => { expect(extractTabularData([{ a: 1 }, { a: 2 }])).toEqual([{ a: 1 }, { a: 2 }]) diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 82fa7a00a77..98a52ae8ab8 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -7,10 +7,8 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { - parseWorkspaceFileCreatePath, - writeWorkspaceFileByPath, -} from '@/lib/copilot/vfs/resource-writer' +import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' const logger = createLogger('CopilotToolResultFiles') @@ -114,7 +112,12 @@ export function convertRowsToCsv(rows: Record[]): string { } export function normalizeOutputWorkspaceFileName(outputPath: string): string { - return parseWorkspaceFileCreatePath(outputPath).fileName + const segments = decodeVfsPathSegments(outputPath.trim().replace(/^\/+|\/+$/g, '')) + const fileName = segments.at(-1) + if (!fileName) { + throw new Error('Output path must include a file name') + } + return fileName } export function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat { diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/copilot/vfs/resource-writer.test.ts index 4097ad03e24..144216ef8c2 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.test.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.test.ts @@ -1,17 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - ensureWorkflowAliasBacking: vi.fn(), - ensureWorkspacePlanBacking: vi.fn(), - resolveWorkflowAliasForWorkspace: vi.fn(), - ensureWorkspaceFileFolderPath: vi.fn(), - findWorkspaceFileFolderIdByPath: vi.fn(), - normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), - getWorkspaceFileByName: vi.fn(), - resolveWorkspaceFileReference: vi.fn(), - updateWorkspaceFileContent: vi.fn(), - uploadWorkspaceFile: vi.fn(), -})) +const mocks = vi.hoisted(() => { + class FileConflictError extends Error { + readonly code = 'FILE_EXISTS' as const + } + + return { + FileConflictError, + ensureWorkflowAliasBacking: vi.fn(), + ensureWorkspacePlanBacking: vi.fn(), + resolveWorkflowAliasForWorkspace: vi.fn(), + ensureWorkspaceFileFolderPath: vi.fn(), + findWorkspaceFileFolderIdByPath: vi.fn(), + normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), + getWorkspaceFileByName: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), + updateWorkspaceFileContent: vi.fn(), + uploadWorkspaceFile: vi.fn(), + } +}) vi.mock('@/lib/copilot/vfs/workflow-alias-backing', () => ({ ensureWorkflowAliasBacking: mocks.ensureWorkflowAliasBacking, @@ -29,13 +36,14 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + FileConflictError: mocks.FileConflictError, getWorkspaceFileByName: mocks.getWorkspaceFileByName, resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, updateWorkspaceFileContent: mocks.updateWorkspaceFileContent, uploadWorkspaceFile: mocks.uploadWorkspaceFile, })) -import { writeWorkspaceFileByPath } from './resource-writer' +import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath } from './resource-writer' describe('resource writer workflow aliases', () => { beforeEach(() => { @@ -83,7 +91,7 @@ describe('resource writer workflow aliases', () => { Buffer.from('content'), 'launch.md', 'text/markdown', - { folderId: 'folder-id' } + { folderId: 'folder-id', exactName: true } ) expect(result).toMatchObject({ id: 'file-plan', @@ -190,4 +198,113 @@ describe('resource writer workflow aliases', () => { mode: 'create', }) }) + + it('rejects direct writes to reserved workflow alias backing paths', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + + await expect( + writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/.plans/wf_1/launch.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + ).rejects.toThrow('Reserved workflow alias backing paths must be accessed through their alias path') + + expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('rejects validation of reserved workflow alias backing paths', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + + await expect( + validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/.changelogs/wf_1.md', + mode: 'overwrite', + }, + }) + ).rejects.toThrow('Reserved workflow alias backing paths must be accessed through their alias path') + + expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled() + }) + + it('uses exact-name creates for alias backing files', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/.plans/launch.md', + backingPath: 'files/.plans/wf_1/launch.md', + backingFolderPath: 'files/.plans/wf_1', + planRelativePath: 'launch.md', + }) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockResolvedValue({ + id: 'file-plan', + name: 'launch.md', + size: 7, + type: 'text/markdown', + url: '/download', + }) + + await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/.plans/launch.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + + expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('content'), + 'launch.md', + 'text/markdown', + { folderId: 'folder-id', exactName: true } + ) + }) + + it('reports alias path when exact-name alias backing creation conflicts', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/.plans/launch.md', + backingPath: 'files/.plans/wf_1/launch.md', + backingFolderPath: 'files/.plans/wf_1', + planRelativePath: 'launch.md', + }) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockRejectedValue(new mocks.FileConflictError('launch.md')) + + await expect( + writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/.plans/launch.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + ).rejects.toThrow( + 'File already exists at workflows/My%20Workflow/.plans/launch.md. Use mode "overwrite" to update it.' + ) + }) }) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index ba8f32dda3b..c0db9c99b8a 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -6,6 +6,7 @@ import { import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' import { isPlanAliasPath, + isWorkflowAliasBackingPath, WORKFLOW_CHANGELOG_BACKING_FOLDER, WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER, @@ -17,6 +18,7 @@ import { normalizeWorkspaceFileItemName, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { + FileConflictError, getWorkspaceFileByName, resolveWorkspaceFileReference, updateWorkspaceFileContent, @@ -129,6 +131,14 @@ function vfsPathForRecord(record: WorkspaceFileRecord): string { return canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) } +function assertNotReservedWorkflowAliasBackingPath(path: string): void { + if (isWorkflowAliasBackingPath(path)) { + throw new Error( + `Reserved workflow alias backing paths must be accessed through their alias path: ${path}` + ) + } +} + async function resolveWorkflowAliasFileTarget(args: { workspaceId: string userId?: string @@ -255,6 +265,8 @@ export async function validateWorkspaceFileWriteTarget(args: { } } + assertNotReservedWorkflowAliasBackingPath(args.target.path) + if (args.target.mode === 'overwrite') { const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) if (!existing) { @@ -332,8 +344,15 @@ export async function writeWorkspaceFileByPath(args: { args.buffer, resolved.fileName, contentType, - { folderId: resolved.folderId } - ) + { folderId: resolved.folderId, exactName: true } + ).catch((error: unknown) => { + if (error instanceof FileConflictError) { + throw new Error( + `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` + ) + } + throw error + }) return { id: uploaded.id, name: uploaded.name, @@ -346,6 +365,8 @@ export async function writeWorkspaceFileByPath(args: { } } + assertNotReservedWorkflowAliasBackingPath(args.target.path) + if (args.target.mode === 'overwrite') { const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) if (!existing) { diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts index 2a73755cd70..617b091c76c 100644 --- a/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts +++ b/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildWorkflowAliasWorkflowEntries, + isWorkflowAliasBackingPath, resolveWorkspacePlanAliasPath, resolveWorkflowAliasPath, workspacePlanBackingPath, @@ -129,4 +130,10 @@ describe('workflow aliases', () => { expect(resolveWorkflowAliasPath('workflows/Root%20Flow/random.md', workflows)).toBeNull() expect(resolveWorkflowAliasPath('workflows/Missing/changelog.md', workflows)).toBeNull() }) + + it('recognizes reserved backing paths after VFS segment canonicalization', () => { + expect(isWorkflowAliasBackingPath('files/.plans/wf_1/launch.md')).toBe(true) + expect(isWorkflowAliasBackingPath('files/%2Eplans/wf_1/launch.md')).toBe(true) + expect(isWorkflowAliasBackingPath('files/ordinary/launch.md')).toBe(false) + }) }) diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.ts index 102a3f3d375..06112fcf30e 100644 --- a/apps/sim/lib/copilot/vfs/workflow-aliases.ts +++ b/apps/sim/lib/copilot/vfs/workflow-aliases.ts @@ -272,7 +272,17 @@ export function isPlanAliasPath(path: string): boolean { } export function isWorkflowAliasBackingPath(path: string): boolean { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + const trimmedPath = path.trim().replace(/^\/+|\/+$/g, '') + let normalizedPath = trimmedPath + if (trimmedPath.startsWith('files/')) { + try { + normalizedPath = `files/${decodeVfsPathSegments(trimmedPath.slice('files/'.length)) + .map((segment) => normalizeVfsSegment(segment)) + .join('/')}` + } catch { + normalizedPath = trimmedPath + } + } return ( normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}` || normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}` || diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 2428e6056ac..b524a094a73 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' +import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolders') @@ -247,8 +248,15 @@ export async function getWorkspaceFileFolderPath( export async function findWorkspaceFileFolderIdByPath( workspaceId: string, pathSegments: string[], - _options?: { includeReservedSystemFolders?: boolean } + options?: { includeReservedSystemFolders?: boolean } ): Promise { + if ( + !options?.includeReservedSystemFolders && + isReservedWorkflowAliasBackingDisplayPath(pathSegments.join('/')) + ) { + return null + } + let parentId: string | null = null for (const rawSegment of pathSegments) { @@ -271,7 +279,7 @@ export async function listWorkspaceFileFolders( workspaceId: string, options?: { scope?: WorkspaceFileFolderScope; includeReservedSystemFolders?: boolean } ): Promise { - const { scope = 'active' } = options ?? {} + const { scope = 'active', includeReservedSystemFolders = false } = options ?? {} const rows = await db .select() .from(workspaceFileFolder) @@ -291,7 +299,12 @@ export async function listWorkspaceFileFolders( .orderBy(asc(workspaceFileFolder.sortOrder), asc(workspaceFileFolder.createdAt)) const paths = buildWorkspaceFileFolderPathMap(rows) - return rows.map((row) => mapFolder(row, paths)) + return rows + .map((row) => mapFolder(row, paths)) + .filter( + (folder) => + includeReservedSystemFolders || !isReservedWorkflowAliasBackingDisplayPath(folder.path) + ) } export async function getWorkspaceFileFolder( diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 235e4228d5d..fff4366d138 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -18,6 +18,7 @@ import { import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { getServePathPrefix } from '@/lib/uploads' import { @@ -170,12 +171,13 @@ export async function uploadWorkspaceFile( fileBuffer: Buffer, fileName: string, contentType: string, - options?: { folderId?: string | null } + options?: { folderId?: string | null; exactName?: boolean } ): Promise { logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`) const folderId = await assertWorkspaceFileFolderTarget(workspaceId, options?.folderId) const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File') + const exactName = options?.exactName ?? false const quotaCheck = await checkStorageQuota(userId, fileBuffer.length) if (!quotaCheck.allowed) { @@ -183,12 +185,14 @@ export async function uploadWorkspaceFile( } let lastError: unknown - for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) { - const uniqueName = await allocateUniqueWorkspaceFileName( - workspaceId, - normalizedFileName, - folderId - ) + const maxAttempts = exactName ? 1 : MAX_UPLOAD_UNIQUE_RETRIES + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const uniqueName = exactName + ? normalizedFileName + : await allocateUniqueWorkspaceFileName(workspaceId, normalizedFileName, folderId) + if (exactName && (await fileExistsInWorkspace(workspaceId, uniqueName, folderId))) { + throw new FileConflictError(uniqueName) + } const storageKey = generateWorkspaceFileKey(workspaceId, uniqueName) let fileId = `wf_${generateShortId()}` @@ -283,6 +287,9 @@ export async function uploadWorkspaceFile( throw error } if (getPostgresErrorCode(error) === '23505') { + if (exactName) { + throw new FileConflictError(normalizedFileName) + } logger.warn( `Unique name conflict on upload (attempt ${attempt + 1}/${MAX_UPLOAD_UNIQUE_RETRIES}), retrying with a new name` ) @@ -672,7 +679,12 @@ export async function listWorkspaceFiles( : [] const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() - return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) + return files + .map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) + .filter((file) => { + if (includeReservedSystemFiles) return true + return !isReservedWorkflowAliasBackingDisplayPath(file.folderPath) + }) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) return []